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/// twig's cap on retained undo steps (`MAX_UNDO` in its splicer), which
387/// [`Doc::can_undo`]'s count follows: past it twig drops the oldest step.
388const UNDO_CAP: usize = 200;
389
390/// An open [`Doc::begin_undo_group`]: how deeply it is nested, and whether an
391/// edit made inside it has put its one step on the history yet.
392#[derive(Clone, Copy, Debug)]
393struct UndoGroup {
394    depth: usize,
395    has_step: bool,
396}
397
398/// What [`Doc::set_unrevealed`] sets aside: the screen's map, what it was
399/// built from, and the caret state a paper build's clamp must not move.
400struct ScreenMap {
401    vmap: VisualMap,
402    key: Option<(u64, Option<usize>, Option<Reveal>)>,
403    caret: usize,
404    anchor: Option<usize>,
405    goal_col: Option<usize>,
406}
407
408/// Where a locator lands — the answer to [`Doc::locate`].
409///
410/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
411/// document, and a place is a span rather than a point: a reader following one
412/// wants the caret at its first byte, and a reader merely *peeking* at one wants
413/// the block it covers drawn. Both are served by carrying the whole span, and
414/// only one of the two can be recovered from an offset alone.
415#[derive(Clone, PartialEq, Eq, Debug)]
416pub struct Landing {
417    /// The first byte of the block the locator names — where a caret goes.
418    pub start: usize,
419    /// One past its last byte, so a frontend can map the pair through
420    /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
421    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
422    pub end: usize,
423}
424
425/// The heading a place in the document sits under — the answer to
426/// [`Doc::heading_at`].
427///
428/// What a host writing a link *to* a position needs: the `#its-slug` half of a
429/// reference is made from `text`, and a peek at the target draws `span`.
430#[derive(Clone, PartialEq, Eq, Debug)]
431pub struct Heading {
432    /// The heading's inline content with its markup stripped — `A *big* day`
433    /// is `A big day` — which is what a slug is made from.
434    pub text: String,
435    /// 1 for `#`, 2 for `##`, and so on.
436    pub level: u32,
437    /// The heading block's own source span, marker and all — the heading, not
438    /// the section it opens.
439    pub span: Range<usize>,
440}
441
442/// Where a dragged block would land — the answer to [`Doc::drop_target_at`].
443///
444/// A drop is aimed at a *row* (the one under the pointer) and lands at a
445/// *boundary* (between two blocks), and a frontend needs both halves: the
446/// offset to hand [`Doc::move_block`], and the row to draw the indicator
447/// above — which is not the row aimed at, since a drop on the lower half of a
448/// block lands below it.
449#[derive(Clone, Copy, PartialEq, Eq, Debug)]
450pub struct DropTarget {
451    /// The boundary offset — `move_block`'s `to`.
452    pub offset: usize,
453    /// The rendered row the indicator is drawn above; `rows.len()` for a drop
454    /// below everything.
455    pub row: usize,
456}
457
458/// A selection cited out of the source: the text itself, up to a requested
459/// number of characters either side, and the byte range it came from. See
460/// [`Doc::selection_quote`].
461///
462/// The prefix and suffix are what make the quote *re-findable*: the same text
463/// can occur twice, and a little of what surrounded it is how a later reader —
464/// or the same document after an edit — tells the occurrences apart. The Web
465/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
466/// the name.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct Quote {
469    /// The selected source, verbatim.
470    pub exact: String,
471    /// What immediately preceded it — possibly empty, at the document's start.
472    pub prefix: String,
473    /// What immediately followed it — possibly empty, at the document's end.
474    pub suffix: String,
475    /// Byte offset in the source where the selection begins.
476    pub start: usize,
477    /// Byte offset where it ends (exclusive).
478    pub end: usize,
479}
480
481/// A host-painted range of the source — an annotation's footprint, a search
482/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
483/// glyphs whose source falls inside it) and hands back the `id` when the
484/// reader activates it; what the range *means* is entirely the host's.
485///
486/// Ranges are source bytes, like the caret and the selection, so a host that
487/// anchors quotes against the source ([`Doc::selection_quote`] is the other
488/// half of that loop) can paint what it found without any coordinate
489/// conversion. A range that drifts off the text it meant is the host's to
490/// re-anchor; leaf draws what it is told.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub struct Highlight {
493    /// Byte offset in the source where the wash begins.
494    pub start: usize,
495    /// Byte offset where it ends (exclusive).
496    pub end: usize,
497    /// The host's name for it, handed back on activation. Opaque to leaf.
498    pub id: String,
499    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
500    /// nothing for the theme's default wash.
501    pub color: Option<String>,
502    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
503    /// a marker gets a small glyph in the margin beside its first line, and
504    /// the glyph — not the wash — is what activates it: the wash is ink, the
505    /// marker is the control, which is what lets a reader put a caret in (or
506    /// copy from) annotated text without a card leaping at them. The name is
507    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
508    /// as a class.
509    pub marker: Option<String>,
510}
511
512impl Highlight {
513    /// The range covering source `offset` in a list [`Doc::set_highlights`]
514    /// sorted, first by start where several overlap — the one place that
515    /// question is answered, for the frontends that paint by asking it as well
516    /// as for [`Doc::highlight_at`].
517    ///
518    /// The list is sorted by `(start, end)`, so the scan can stop at the first
519    /// range starting past `offset` rather than running to the end. A painter
520    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
521    /// one-shot form, for the host asking what the reader just activated.
522    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
523        highlights
524            .iter()
525            .take_while(|h| h.start <= offset)
526            .find(|h| offset < h.end)
527    }
528}
529
530/// [`Highlight::covering`] for a caller walking the document in order — which
531/// is every painter, since a frontend draws rows top to bottom and glyphs left
532/// to right.
533///
534/// The one-shot form is a scan from the front of the list per glyph, and a
535/// document with two hundred search hits pays that two hundred times a row. A
536/// range that ends at or before an offset can never cover that offset *or any
537/// later one*, so the cursor retires those permanently and each glyph costs the
538/// ranges that actually reach it. The answer is identical to
539/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
540/// the part that was being redone dropped, not a cheaper approximation.
541///
542/// Offsets are expected to arrive non-decreasing. One that goes backwards is
543/// still answered correctly: the cursor re-seats to the front, since a painter
544/// that revisits a row is asking a question the retired ranges may own again.
545pub struct HighlightCursor<'a> {
546    highlights: &'a [Highlight],
547    /// The first range not yet retired.
548    at: usize,
549    /// The last offset asked about, to notice a caller going backwards.
550    last: usize,
551}
552
553impl<'a> HighlightCursor<'a> {
554    pub fn new(highlights: &'a [Highlight]) -> Self {
555        HighlightCursor {
556            highlights,
557            at: 0,
558            last: 0,
559        }
560    }
561
562    /// The range covering `offset`, advancing the cursor past every range that
563    /// can no longer cover anything.
564    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
565        if offset < self.last {
566            self.at = 0;
567        }
568        self.last = offset;
569        while self
570            .highlights
571            .get(self.at)
572            .is_some_and(|h| h.end <= offset)
573        {
574            self.at += 1;
575        }
576        Highlight::covering(&self.highlights[self.at..], offset)
577    }
578}
579
580/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
581/// purpose: the only useful question is whether two of them are the same map,
582/// and what is behind it — which `Doc` built it, and the (revision, wrap,
583/// reveal line) it was built from — is core's business.
584///
585/// The document is part of it because the rest is not unique to one: two
586/// documents opened at the same width are both at revision zero with no reveal
587/// line, and a frontend holding one copy of a map across the two would take
588/// the second's key for the first's and paint the wrong document.
589#[derive(Clone, PartialEq, Eq, Debug)]
590pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
591
592pub struct Doc {
593    editor: Editor,
594    pub format: Format,
595    pub path: PathBuf,
596    /// Current source, refreshed from the editor after every successful edit.
597    pub source: String,
598    /// The caret, as a byte offset into `source` (always on a char boundary).
599    pub caret: usize,
600    /// The selection's fixed end, if a selection is active; the moving end is
601    /// the caret. `None` means no selection.
602    pub anchor: Option<usize>,
603    pub dirty: bool,
604    pub status: Option<String>,
605    pub view: View,
606    /// Whether the document refuses to change — a *reading* surface over the
607    /// same rendering, selection, and navigation the editor has.
608    ///
609    /// Enforced here rather than by each frontend hiding its input paths,
610    /// because every mutation funnels through a few doors —
611    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
612    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
613    /// verbs directly rather than through the splice (a typed literal, a link,
614    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
615    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
616    /// gated door reports exactly like a rolled-back splice, a path every
617    /// caller already handles. `a_read_only_document_refuses_every_door` is
618    /// the list; a new `self.editor.insert_*` call belongs on it.
619    read_only: bool,
620    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
621    /// State like the selection rather than like the text: no edit history,
622    /// no dirty bit, redrawn from whatever the host last set.
623    highlights: Vec<Highlight>,
624    /// How much of the source markup the rich view exposes — a frontend preference (see
625    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
626    /// [`reveal_line`](Self::reveal_line), the editing one by
627    /// [`insert`](Self::insert).
628    markup_mode: MarkupMode,
629    /// Whether soft breaks fold into the reflowed paragraph or render where
630    /// they were written (see [`LineFlow`]) — an independent frontend
631    /// preference the WYSIWYG builder consults when it lays out a block.
632    line_flow: LineFlow,
633    /// The kind of the last edit, for coalescing: twig owns the undo *history*
634    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
635    /// call, so leaf decides when a run continues and tells twig to coalesce.
636    last_edit_kind: Option<EditKind>,
637    /// The inline marks the user has toggled *at a collapsed caret* with no
638    /// selection — "start typing bold here". Held as the XOR delta from the marks
639    /// already in force at [`pending_at`](Self::pending_at): a set bit means
640    /// "flip this kind for the next typed text", so it both turns a mark on where
641    /// none is (type into bold) and off where one already covers the caret (type
642    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
643    /// freshly typed text and then clears it — a mark once realised is carried by
644    /// the caret sitting inside the run, not by this delta.
645    pending_marks: InlineMarks,
646    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
647    /// delta is live only while the caret still stands here with no selection;
648    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
649    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
650    pending_at: Option<usize>,
651    /// The source as of the last open/save — `dirty` is `source != clean_source`,
652    /// so undoing back to the saved state correctly clears the modified flag.
653    clean_source: String,
654    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
655    /// while the document has no file behind it. [`Doc::disk_state`] compares
656    /// the file against this to catch an edit made *outside* leaf before a save
657    /// silently overwrites it — `clean_source` only knows what leaf itself did.
658    ///
659    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
660    /// writes inside one filesystem timestamp tick are indistinguishable, a
661    /// clock that steps backwards (or a writer that restores an mtime) hides a
662    /// real change, and a `touch` invents one. The whole point of the watermark
663    /// is to not clobber someone's work, so it reads the bytes and compares what
664    /// is actually there. That costs a file read per question, which is why the
665    /// question is asked on a user event (focus, save) and not every frame.
666    disk_hash: Option<u64>,
667    /// The "sticky" display column vertical motion aims for, in the active
668    /// view's grid. Set on the first `move_up`/`move_down` of a run and
669    /// reused by every subsequent one in that run, so passing through a
670    /// shorter line doesn't permanently forget the original column. Any
671    /// horizontal motion or edit clears it.
672    ///
673    /// A column, not a character index: dropping down a line of `你好` onto one
674    /// of ASCII has to land under the glyph the caret was drawn beneath, which
675    /// is the only thing the user can see to aim by. Where the goal falls inside
676    /// a wide character on the target line, the mapping resolves it to that
677    /// character — the caret lands on it rather than between its cells.
678    goal_col: Option<usize>,
679    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
680    /// and clicks read it to stay in visible space.
681    pub vmap: VisualMap,
682    /// The syntax map for the source view; empty in the WYSIWYG view, which
683    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
684    /// frontend that never calls it paints raw source unstyled, which is what
685    /// every frontend did before this map existed.
686    pub smap: SourceMap,
687    /// The revision `smap` was built from, or `None` before the first build.
688    /// The map is a pure function of the text alone — no width, no caret, no
689    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
690    /// whole key.
691    smap_key: Option<u64>,
692    /// Everything the map is built from, as one number: bumped whenever the
693    /// document's text changes, and never by a motion, a selection, or a save.
694    /// A frontend can hold work against it — see [`Doc::revision`].
695    revision: u64,
696    /// How many history steps stand behind the caret, and how many ahead of
697    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
698    /// twig's history does not answer itself. A mirror of twig's two stacks,
699    /// kept exact by following every move twig makes to them: a step onto the
700    /// undo stack at [`refresh`](Self::refresh), the funnel every edit comes
701    /// through, capped where twig caps it ([`UNDO_CAP`]); one fewer at each
702    /// [`coalesce_last_undo`](Self::coalesce_last_undo), under twig's own
703    /// two-step rule; and moved back and forth by [`undo`](Self::undo) and
704    /// [`redo`](Self::redo). A counter that only ever went up — which this was
705    /// — answered `true` after a coalesced run had been undone whole. Should
706    /// it ever drift anyway, the first undo twig refuses sets it to zero.
707    undo_steps: usize,
708    redo_steps: usize,
709    /// The undo group a host has open, if any — see
710    /// [`begin_undo_group`](Self::begin_undo_group). `None` outside one.
711    undo_group: Option<UndoGroup>,
712    /// Make the next literal insert fail as twig would refuse one — a test's
713    /// way to reach the rollback no document provokes.
714    #[cfg(test)]
715    refuse_next_literal: bool,
716    /// What `vmap` was built from, or `None` before the first build. The map is
717    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
718    /// moved, rebuilding it produces the identical map — see
719    /// [`Doc::build_visual`].
720    ///
721    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
722    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
723    /// text and width alone, and a caret motion still rebuilds nothing.
724    vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
725    /// The screen's map, set aside while [`Doc::set_unrevealed`] lays the
726    /// document out as paper, and put back when it stops — `Some` exactly
727    /// while the map in `vmap` is the page's.
728    screen: Option<Box<ScreenMap>>,
729    /// Which `Doc` this is, distinct from every other one built in this
730    /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
731    /// can never be mistaken for another document's — see
732    /// [`Doc::visual_key`]. Nothing else reads it.
733    identity: u64,
734    /// Per-block row cache backing the incremental rebuild: when the text
735    /// changes, only the top-level blocks whose bytes moved are re-rendered and
736    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
737    /// builds; a pure accelerator, so it's never read for correctness.
738    block_cache: wysiwyg::BlockCache,
739    /// What the frontend has said about itself — how tall its pictures came
740    /// out, keyed by destination or by TeX, and whether it paints a picture in
741    /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`],
742    /// [`Doc::set_directive_rows`] and [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
743    /// so this is the only way it learns a height or a capability. Threaded
744    /// into every build; a change drops both caches, since none of it is in a
745    /// block's bytes.
746    surface: wysiwyg::Surface,
747
748    // View geometry the renderer stamps each frame, so mouse events can map a
749    // screen cell back to a byte offset.
750    pub scroll: usize,
751    pub body_origin: (u16, u16),
752    /// Width of the body rectangle last painted by the frontend. Zero means
753    /// unknown (used by tests or a frontend that has not drawn yet).
754    pub body_width: u16,
755    pub body_height: u16,
756    /// The caret as of the last frame drawn, or `None` before the first.
757    ///
758    /// Scrolling is the viewport's business, not the caret's: the view follows
759    /// the caret when the caret *moves*, but a wheel that doesn't touch the
760    /// caret has to be free to scroll away from it — otherwise the view is
761    /// pinned to the caret and stops dead at the edge of the document you can
762    /// see. Comparing against this is what tells the two apart, and it catches a
763    /// caret set by any route, including a frontend assigning the field itself.
764    pub drawn_caret: Option<usize>,
765}
766
767/// The Markdown extensions every leaf document is parsed with — five of them,
768/// each departing from twig's defaults for a reason leaf can state.
769///
770/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
771/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
772/// node the frontends can frame and rasterize instead of opaque `raw_block`
773/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
774/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
775/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
776/// plain tinted container, agnostic of `name`.
777///
778/// `highlight` and `highlight_colors` are the pair that makes Markdown read
779/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
780/// `data-color`. leaf already had somewhere to put both: the
781/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
782/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
783/// from a Djot one it was converted from — the button wrote `==…==` and the
784/// reparse read it straight back as text.
785/// They are on together because a colour is inert without the highlight itself,
786/// and a document that writes `==🔴 x==` means the colour by it.
787///
788/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
789/// a `display_math` one — what djot reads natively and what leaf has
790/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
791/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
792/// twig has already read as an escaped comma, and an author who types a
793/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
794/// twig's own rule that a dollar followed by whitespace never opens math, so
795/// `$5 and $6` stays prose.
796///
797/// Every flag is inert for non-Markdown formats, so it's safe to pass them
798/// unconditionally. Threading this through every constructor (not just `open`)
799/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
800/// way — twig reparses with these same flags after each edit.
801pub(crate) fn parse_extensions() -> MarkdownExtensions {
802    MarkdownExtensions {
803        html_elements: true,
804        directives: true,
805        highlight: true,
806        highlight_colors: true,
807        math: true,
808    }
809}
810
811/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
812/// mapping twig's error into the `anyhow` context every constructor shares.
813fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
814    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
815}
816
817/// Does `format` spell a table as a **pipe table** — the one grid twig's table
818/// editor knows how to emit?
819///
820/// This is the single capability leaf still has to answer for itself, and the
821/// only hand-maintained format list left in this file. Every other gesture is
822/// [`Format::supports`], which is twig's own answer read across the C ABI — but
823/// twig deliberately leaves the table ops out of that query, because they read
824/// no `Syntax` table at all. They rewrite a grid that is already in the source
825/// and refuse on *position*, never on format. Handed a caret inside an HTML
826/// `<table>`, `table_insert_row` therefore re-emits the whole element as
827/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
828/// `dirty` flag, and nothing downstream able to tell it from a good edit.
829///
830/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
831/// wildcard answers "no" for a format leaf has never heard of: a new twig
832/// language that *does* spell pipe tables loses its grid controls until this
833/// line is updated, which shows up as a missing button. The other default hands
834/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
835fn spells_pipe_tables(format: Format) -> bool {
836    matches!(format, Format::Markdown | Format::Djot)
837}
838
839/// Which of leaf's authoring controls this document's format can actually
840/// spell — one flag per toolbar button, resolved once so a frontend can build
841/// its chrome instead of discovering each refusal on a click.
842///
843/// Every field but [`table`](Self::table) is `Format::supports_with` on the
844/// gesture the matching [`Doc`] method calls, so this record cannot drift from
845/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
846/// doesn't export.
847///
848/// `supports_with` rather than `supports` because two of these are facts about
849/// the *parse options* as much as about the format. `Format::supports` answers
850/// for twig's defaults, and leaf never parses with those — it parses with
851/// [`parse_extensions`], and a document's toolbar has to describe the document
852/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
853/// without it would mint bytes its own reparse hands back as plain text, which
854/// is why twig asks before it writes.
855///
856/// **The formats are ragged, and that is the point.** A single per-document
857/// boolean was enough while the two authorable formats were Markdown and djot
858/// and everything else spelled nothing. HTML is neither: it writes seven of the
859/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
860/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
861/// pair, and since 3.5 a quote, a list, a code block, a link and an image
862/// printed as fresh nodes; it spells no task box (a form control there) and
863/// no footnote, and its `<table>` is one twig reads but will not write. So
864/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
865/// table buttons do not, and no one flag can say that. Markdown and djot
866/// differ from each other too:
867/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
868#[derive(Clone, Copy, Debug, Eq, PartialEq)]
869pub struct Capabilities {
870    /// ⌘B — `InlineKind::Strong`.
871    pub bold: bool,
872    /// ⌘I — `InlineKind::Emph`.
873    pub italic: bool,
874    /// Inline code — `InlineKind::Verbatim`.
875    pub code: bool,
876    /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
877    /// under the `highlight` extension [`parse_extensions`] turns on: the
878    /// button writes `==text==`, which is what the reparse reads back.
879    pub mark: bool,
880    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
881    pub underline: bool,
882    /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
883    /// out of the box, since twig parses it out of the box.
884    pub strike: bool,
885    /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
886    /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
887    /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
888    /// so a toolbar offering the swatches wherever the button lights would offer
889    /// them in a document that cannot write one. Pair with
890    /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
891    /// a highlight to colour as much as a format that spells one.
892    pub mark_color: bool,
893    pub superscript: bool,
894    pub subscript: bool,
895    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
896    pub heading: bool,
897    pub blockquote: bool,
898    pub bullet_list: bool,
899    pub ordered_list: bool,
900    /// The checkbox controls: giving an item a box, and ticking one.
901    pub task: bool,
902    pub link: bool,
903    /// Covers [`Doc::insert_media`] too — see the note there on why the three
904    /// media kinds stand or fall together.
905    pub image: bool,
906    /// The horizontal-rule button. HTML spells this one (`<hr>`).
907    pub thematic_break: bool,
908    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
909    /// the pair; HTML has no footnote of its own, so the button goes away rather
910    /// than writing brackets that would render as brackets.
911    pub footnote: bool,
912    /// The code-block button — [`Doc::toggle_code_block`], twig's
913    /// `Gesture::ToggleCodeBlock`. Markdown and djot spell the fence; HTML
914    /// rebuilds the block as `<pre><code>`.
915    pub code_block: bool,
916    /// Setting a fenced block's language — a control only ever offered with the
917    /// caret already in a fence.
918    pub code_language: bool,
919    /// The grid controls: insert/delete/move a row or column, set a column's
920    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
921    /// question — an HTML `<table>` holds the caret and still can't be edited.
922    pub table: bool,
923    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
924    /// idiomatic in-cell break.
925    pub cell_line_break: bool,
926    /// The alignment control — [`Doc::set_alignment`], twig's
927    /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
928    /// block's attributes, Markdown under the `html_elements`
929    /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
930    /// through its `[…]` line.
931    pub alignment: bool,
932    /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
933    /// [`alignment`](Self::alignment) and so the same answer, and its own flag
934    /// because a toolbar dims controls one at a time and the pair may yet
935    /// diverge.
936    pub line_spacing: bool,
937    /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
938    /// over a selection. **Narrower than the block pair**: AsciiDoc's
939    /// `[#id.role]#text#` keeps an id and a role and has no slot for a
940    /// `data-` key, so twig refuses the span there and this is `false` while
941    /// [`alignment`](Self::alignment) is `true`. The block-level form of the
942    /// same property — the caret in a paragraph, no selection — goes through
943    /// `SetBlockAttrs` and still works, which is why the flag describes the
944    /// control rather than the caret.
945    pub font_size: bool,
946    /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
947    /// [`font_size`](Self::font_size) is.
948    pub font_family: bool,
949    /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
950    /// and not to be confused with [`mark_color`](Self::mark_color): that is a
951    /// highlight's background and rides the `mark` node twig already owns,
952    /// this is a run's foreground and rides an attributed span.
953    pub text_color: bool,
954    /// The page-break button — [`Doc::insert_page_break`], twig's
955    /// `Gesture::InsertDirective`. Markdown under the `directives` extension
956    /// [`parse_extensions`] turns on (`::page-break`), djot, which spells it
957    /// as an empty `::: page-break` fence, HTML (`<page-break></page-break>`)
958    /// and AsciiDoc (`<<<`) — each drawn as the same placeholder row.
959    pub page_break: bool,
960    /// The directives a host names — [`Doc::insert_directive`], twig's
961    /// `Gesture::InsertDirective` for any name. **Narrower than
962    /// [`page_break`](Self::page_break)**, which is the same gesture: twig
963    /// spells an arbitrary directive in HTML (a custom element) and AsciiDoc
964    /// (an open block with a style), and the walker reads back neither as a
965    /// directive — it reads their spellings of `page-break` and no other name.
966    /// So this is Markdown under the `directives` extension and djot, the two
967    /// formats where what is written comes back in
968    /// [`VisualMap::directives`](crate::wysiwyg::VisualMap::directives) with
969    /// its name. djot has no leaf directive label: see
970    /// [`Doc::insert_directive`].
971    pub directives: bool,
972    /// Moving a block — [`Doc::move_block`] and the Alt+↑/↓ pair, twig's
973    /// `Gesture::MoveBlock`. Every format with blocks a caret can name; XML
974    /// has none.
975    pub move_block: bool,
976}
977
978impl Capabilities {
979    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
980    /// twig computes each from a static table — but a frontend that wants to
981    /// hold them can.
982    ///
983    /// The extensions are not a parameter because they are not a choice a
984    /// caller makes: every leaf document is parsed with [`parse_extensions`],
985    /// so the format is the whole of what varies.
986    pub fn of(format: Format) -> Self {
987        let exts = parse_extensions();
988        let supports = |g| format.supports_with(exts, g);
989        let inline = |k| supports(Gesture::ToggleInline(k));
990        let container = |k| supports(Gesture::ToggleBlockContainer(k));
991        Self {
992            bold: inline(InlineKind::Strong),
993            italic: inline(InlineKind::Emph),
994            code: inline(InlineKind::Verbatim),
995            mark: inline(InlineKind::Mark),
996            underline: inline(InlineKind::Insert),
997            strike: inline(InlineKind::Delete),
998            mark_color: supports(Gesture::SetMarkColor),
999            superscript: inline(InlineKind::Superscript),
1000            subscript: inline(InlineKind::Subscript),
1001            heading: supports(Gesture::SetBlock),
1002            blockquote: container(BlockContainerKind::BlockQuote),
1003            bullet_list: container(BlockContainerKind::BulletList),
1004            ordered_list: container(BlockContainerKind::OrderedList),
1005            // Both halves of the checkbox story, and leaf offers no control that
1006            // needs only one: the item gesture mints the box, the checked one
1007            // ticks it, and a format spelling a `task_marker` spells both.
1008            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
1009            link: supports(Gesture::InsertLink),
1010            image: supports(Gesture::InsertImage),
1011            thematic_break: supports(Gesture::InsertThematicBreak),
1012            footnote: supports(Gesture::InsertFootnote),
1013            code_block: supports(Gesture::ToggleCodeBlock),
1014            code_language: supports(Gesture::SetCodeLanguage),
1015            table: spells_pipe_tables(format),
1016            cell_line_break: supports(Gesture::InsertLineBreak),
1017            // The presentation vocabulary, one gesture per level: the two
1018            // line-level properties are a block's attributes and the three
1019            // run-level ones a span's. They are asked separately because the
1020            // formats answer differently — AsciiDoc spells the block and not
1021            // the span — and a toolbar that dimmed all five together would dim
1022            // three controls that work.
1023            alignment: supports(Gesture::SetBlockAttrs),
1024            line_spacing: supports(Gesture::SetBlockAttrs),
1025            font_size: supports(Gesture::WrapRangeAttrs),
1026            font_family: supports(Gesture::WrapRangeAttrs),
1027            text_color: supports(Gesture::WrapRangeAttrs),
1028            // Every format twig spells the directive in: the walker draws
1029            // HTML's `<page-break>` and AsciiDoc's `<<<` as the same
1030            // placeholder Markdown's `::page-break` and djot's fence get.
1031            page_break: supports(Gesture::InsertDirective),
1032            // Only where the walker reads an arbitrary name back — see the
1033            // field. twig's own answer is also `true` for HTML and AsciiDoc.
1034            directives: supports(Gesture::InsertDirective)
1035                && matches!(format, Format::Markdown | Format::Djot),
1036            move_block: supports(Gesture::MoveBlock),
1037        }
1038    }
1039}
1040
1041/// The source of [`Doc::identity`], one per document ever built.
1042static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1043
1044impl Doc {
1045    #[cfg(feature = "fs")]
1046    pub fn open(path: PathBuf) -> Result<Self> {
1047        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1048        Self::from_disk_bytes(path, bytes)
1049    }
1050
1051    /// An empty document *named* `path`, for a file that isn't there yet — what
1052    /// every other terminal editor gives you when you name a file that doesn't
1053    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
1054    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
1055    /// the header shows the name the user asked for.
1056    ///
1057    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
1058    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
1059    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
1060    /// parse is still an error: a mistyped flag or a stray argument should say
1061    /// so, not open a buffer promising to save somewhere.
1062    ///
1063    /// The watermark is the hash of *no bytes*, not `None`, and that is the
1064    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
1065    /// answering [`DiskState::Untitled`] for a document that has a path and
1066    /// intends to write to it. Hashing `""` instead makes the answers the true
1067    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
1068    /// recreates it, which is exactly what this is for), and
1069    /// [`DiskState::Changed`] if somebody creates it underneath us between
1070    /// launch and save, so the frontend's overwrite prompt guards a new file as
1071    /// it guards an opened one.
1072    ///
1073    /// Nothing is written here. A buffer that is never typed into never touches
1074    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
1075    /// open — the write is where that fails, and it says so then.
1076    #[cfg(feature = "fs")]
1077    pub fn create(path: PathBuf) -> Result<Self> {
1078        Self::from_disk_bytes(path, Vec::new())
1079    }
1080
1081    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1082    /// the call a CLI frontend wants for its path argument.
1083    ///
1084    /// The decision is made from the failed read itself rather than a `exists()`
1085    /// check first, so there is no window between the two for the file to appear
1086    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1087    /// a directory in the way is still an error, because pretending those are
1088    /// "no file yet" would offer to save over something leaf couldn't read.
1089    #[cfg(feature = "fs")]
1090    pub fn open_or_create(path: PathBuf) -> Result<Self> {
1091        match std::fs::read(&path) {
1092            Ok(bytes) => Self::from_disk_bytes(path, bytes),
1093            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1094            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1095        }
1096    }
1097
1098    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1099    /// stand in for) the file at `path`, parsed as the format its extension
1100    /// names. Keeping the two on one path is what makes a new file's document
1101    /// identical in every respect to an opened one but its contents.
1102    #[cfg(feature = "fs")]
1103    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1104        let format = detect_format(&path)?;
1105        let editor = new_editor(&bytes, format)?;
1106        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1107        let disk_hash = Some(hash_bytes(source.as_bytes()));
1108        // Store the document's *absolute* path. A relative one (`leaf README.md`)
1109        // has an empty parent, so a frontend can't resolve a relative image
1110        // destination (`![](pic.png)`) against the document's directory and the
1111        // picture silently falls back to its text placeholder. `absolute` is
1112        // purely lexical — it prefixes the current directory and normalizes, but
1113        // reads nothing and resolves no symlinks — so `file_name` and save are
1114        // unchanged; it only gives `path.parent()` something to join against.
1115        let path = std::path::absolute(&path).unwrap_or(path);
1116        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1117    }
1118
1119    /// Build a document from an in-memory string, the format named explicitly —
1120    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1121    /// path and sniffs the format from its extension). A wasm or FFI host, which
1122    /// has no path to read, uses this: it hands over bytes it fetched however it
1123    /// could, and later persists [`Doc::source`] however it can (a browser
1124    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1125    ///
1126    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1127    /// true) exactly like a [`Doc::blank`] that has been given content.
1128    pub fn from_source(source: String, format: Format) -> Result<Self> {
1129        let editor = new_editor(source.as_bytes(), format)?;
1130        Ok(Doc::from_parts(
1131            editor,
1132            format,
1133            PathBuf::new(),
1134            source,
1135            None,
1136        ))
1137    }
1138
1139    /// An untitled, empty document — the `+` button and a `leaf` launched with
1140    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1141    ///
1142    /// It is Markdown, because a format has to be chosen before a name exists to
1143    /// read one from: `detect_format` reads the extension and an untitled
1144    /// document has neither. Markdown is what leaf's own files are, what its
1145    /// block markers are already written for (`insert_block_prefix`), and the
1146    /// extension a Save As will overwhelmingly pick — a wrong guess here would
1147    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1148    /// *doesn't* revisit this: see [`Doc::save_as`].
1149    pub fn blank() -> Result<Self> {
1150        let format = Format::Markdown;
1151        let editor = new_editor(b"", format)?;
1152        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1153        // field two frontends already read; making it an `Option` to say this
1154        // would break both). `is_untitled` is the question to ask, not the
1155        // representation to copy.
1156        Ok(Doc::from_parts(
1157            editor,
1158            format,
1159            PathBuf::new(),
1160            String::new(),
1161            None,
1162        ))
1163    }
1164
1165    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1166    /// apart in the ones neither of them has an opinion about.
1167    // `identity` is taken from a counter rather than from the `Doc`'s address,
1168    // which moves — a session that holds one is moved into and out of
1169    // containers freely, and an identity that changed with it would defeat the
1170    // one comparison it exists for.
1171    fn from_parts(
1172        editor: Editor,
1173        format: Format,
1174        path: PathBuf,
1175        source: String,
1176        disk_hash: Option<u64>,
1177    ) -> Self {
1178        Doc {
1179            editor,
1180            format,
1181            path,
1182            disk_hash,
1183            clean_source: source.clone(),
1184            source,
1185            caret: 0,
1186            anchor: None,
1187            dirty: false,
1188            status: None,
1189            read_only: false,
1190            highlights: Vec::new(),
1191            // leaf opens in the rich-text (WYSIWYG) view by default — the
1192            // markup-resolved surface is leaf's differentiator. Frontends can
1193            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1194            // toggles at runtime.
1195            view: View::Wysiwyg,
1196            // `None` by default — the clean surface Diaryx ships, with typed
1197            // syntax kept literal; a markup-fluent frontend can climb the
1198            // ladder to `Shortcuts` or `Full`.
1199            markup_mode: MarkupMode::default(),
1200            // Fold by default — flowing prose that reflows to the viewport, the
1201            // behaviour every frontend had before this preference existed.
1202            line_flow: LineFlow::default(),
1203            last_edit_kind: None,
1204            pending_marks: InlineMarks::empty(),
1205            pending_at: None,
1206            goal_col: None,
1207            vmap: VisualMap::default(),
1208            smap: SourceMap::default(),
1209            // No map yet — the first `build_source` always builds.
1210            smap_key: None,
1211            revision: 0,
1212            undo_steps: 0,
1213            redo_steps: 0,
1214            undo_group: None,
1215            #[cfg(test)]
1216            refuse_next_literal: false,
1217            // No map yet — the first `build_visual` always builds.
1218            vmap_key: None,
1219            screen: None,
1220            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1221            block_cache: wysiwyg::BlockCache::default(),
1222            surface: wysiwyg::Surface::default(),
1223            scroll: 0,
1224            body_origin: (0, 0),
1225            body_width: 0,
1226            body_height: 0,
1227            drawn_caret: None,
1228        }
1229    }
1230
1231    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1232    /// has never been saved. The question a ⌘S handler asks to know it should
1233    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1234    /// header asks to know the name it shows is a placeholder.
1235    pub fn is_untitled(&self) -> bool {
1236        self.path.as_os_str().is_empty()
1237    }
1238
1239    pub fn toggle_view(&mut self) {
1240        self.view = match self.view {
1241            View::Source => View::Wysiwyg,
1242            View::Wysiwyg => View::Source,
1243        };
1244        self.scroll = 0;
1245        self.status = None;
1246        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1247        // lift it to the first rendered offset.
1248        self.clamp_caret();
1249    }
1250
1251    /// The current markup-exposure preference (see [`MarkupMode`]).
1252    pub fn markup_mode(&self) -> MarkupMode {
1253        self.markup_mode
1254    }
1255
1256    /// Set the markup-exposure preference. Both of its axes take effect at
1257    /// once: the editing one on the next [`insert`](Self::insert), and the
1258    /// rendering one on the next build — which is why this drops the cached
1259    /// visual map and the per-block render cache, exactly as
1260    /// [`set_line_flow`](Self::set_line_flow) does.
1261    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1262        if self.markup_mode == mode {
1263            return;
1264        }
1265        self.markup_mode = mode;
1266        // Neither cache is keyed on the mode, and moving between `Full` and the
1267        // hidden modes changes every row the caret's line renders to — so
1268        // invalidate both explicitly.
1269        self.vmap_key = None;
1270        self.block_cache = wysiwyg::BlockCache::default();
1271    }
1272
1273    /// The line the caret sits on, when that line should render something
1274    /// raw — `None` when nothing on it would, which is what the builder reads
1275    /// as "reveal nothing" and what keeps caret motion from costing a build.
1276    ///
1277    /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1278    /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1279    /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1280    /// formula's content is not its picture and hiding the `$` alone would
1281    /// leave nothing to edit; there the line is threaded through only when it
1282    /// meets a block that holds one, which the last build's layout knows
1283    /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1284    /// keeps the `None` it always had, and one with math pays a rebuild only
1285    /// while the caret is in the formula's block.
1286    ///
1287    /// A *source* line (newline to newline), not a visual row: a wrapped
1288    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1289    /// line across several rows, and revealing half a delimiter pair because the
1290    /// other half wrapped would be worse than revealing neither. The range
1291    /// excludes the terminating newline and is empty-but-present on a blank
1292    /// line, which reveals nothing but still keys the caches correctly.
1293    ///
1294    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1295    /// there is nothing there to reveal.
1296    pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1297        // A page has no caret, so no line of it is the caret's.
1298        if self.view != View::Wysiwyg || self.screen.is_some() {
1299            return None;
1300        }
1301        let line = source_line_range(&self.source, self.caret);
1302        if self.markup_mode.reveals_caret_line() {
1303            return Some(Reveal::full(line));
1304        }
1305        self.block_cache
1306            .math_meets(&line)
1307            .then_some(Reveal::math(line))
1308    }
1309
1310    /// The current soft-break flow preference (see [`LineFlow`]).
1311    pub fn line_flow(&self) -> LineFlow {
1312        self.line_flow
1313    }
1314
1315    /// Set the soft-break flow preference. The mode changes how every block lays
1316    /// out, so a change drops the cached visual map and the per-block render
1317    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1318    ///
1319    /// [`build_visual`]: Self::build_visual
1320    pub fn set_line_flow(&mut self, mode: LineFlow) {
1321        if self.line_flow == mode {
1322            return;
1323        }
1324        self.line_flow = mode;
1325        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1326        // so invalidate them explicitly, or the next build would reuse rows laid
1327        // out under the old flow.
1328        self.vmap_key = None;
1329        self.block_cache = wysiwyg::BlockCache::default();
1330    }
1331
1332    pub fn view_name(&self) -> &'static str {
1333        match self.view {
1334            View::Source => "source",
1335            View::Wysiwyg => "wysiwyg",
1336        }
1337    }
1338
1339    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1340    /// (called by the renderer each frame it's in the WYSIWYG view).
1341    /// Build the WYSIWYG map, wrapped at `width` display columns.
1342    ///
1343    /// Cheap to call every frame, which is what both frontends do: the map is a
1344    /// pure function of the document and the wrap width, so a call that would
1345    /// rebuild the same map returns the one already built. Only an edit (or a
1346    /// resize) pays.
1347    ///
1348    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1349    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1350    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1351    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1352    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1353    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1354    pub fn build_visual(&mut self, width: usize) {
1355        self.build_map(Some(width));
1356    }
1357
1358    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1359    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1360    /// than a fixed character column.
1361    pub fn build_visual_unwrapped(&mut self) {
1362        self.build_map(None);
1363    }
1364
1365    /// Lay the document out as paper shows it, with no line revealed — or,
1366    /// with `false`, go back to the screen's map.
1367    ///
1368    /// The reveal is core's, folded into the rows before a frontend sees them:
1369    /// under [`MarkupMode::Full`] the caret's line shows its delimiters, and in
1370    /// every mode a formula on it is its TeX rather than its picture. A page
1371    /// has no caret, so a PDF or a printout laid out from the screen's map
1372    /// printed the one line the reader happened to be standing on as source.
1373    /// While this is on, [`build_visual`](Self::build_visual) and its twin
1374    /// build as though nothing were revealed.
1375    ///
1376    /// Kept apart from the screen's build rather than replacing it: turning
1377    /// this on sets the screen's map aside — and the caret, which a build
1378    /// clamps against the map it builds — and turning it off puts both back
1379    /// as they were, so the screen's next build costs nothing it would not
1380    /// have cost anyway. Meant to bracket one read of the map, on and then
1381    /// off; an edit in between is not the paper's to make.
1382    pub fn set_unrevealed(&mut self, on: bool) {
1383        match (on, self.screen.take()) {
1384            (true, None) => {
1385                self.screen = Some(Box::new(ScreenMap {
1386                    vmap: std::mem::take(&mut self.vmap),
1387                    key: self.vmap_key.take(),
1388                    caret: self.caret,
1389                    anchor: self.anchor,
1390                    goal_col: self.goal_col,
1391                }));
1392            }
1393            (false, Some(screen)) => {
1394                let ScreenMap {
1395                    vmap,
1396                    key,
1397                    caret,
1398                    anchor,
1399                    goal_col,
1400                } = *screen;
1401                self.vmap = vmap;
1402                self.vmap_key = key;
1403                self.caret = caret;
1404                self.anchor = anchor;
1405                self.goal_col = goal_col;
1406            }
1407            // Already in the state asked for.
1408            (_, screen) => self.screen = screen,
1409        }
1410    }
1411
1412    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1413    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1414    /// styling for [`View::Wysiwyg`].
1415    ///
1416    /// A frontend calls this before painting raw source. One that doesn't gets
1417    /// an empty map and paints unstyled text, so this is additive: nothing
1418    /// breaks by not calling it.
1419    ///
1420    /// Built at most once per revision, and the revision is the whole key — the
1421    /// map has no width and no caret in it, so it survives every resize, every
1422    /// motion, and every selection change.
1423    ///
1424    /// The builds it does do cost a whole-arena marshal, which is precisely what
1425    /// the WYSIWYG path works to avoid, so this has no incremental path where
1426    /// that one has two. From `cargo run --release -p leaf-core --example
1427    /// bench`, per keystroke, against the WYSIWYG build the source view is
1428    /// *not* doing:
1429    ///
1430    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1431    /// |------:|-------:|--------:|----------------:|-------------------:|
1432    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1433    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1434    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1435    ///
1436    /// Linear, two thirds of it the marshal, and the build itself five to seven
1437    /// times cheaper than the one it stands in for at every size. Comfortable
1438    /// well past any document a person edits in a terminal — a megabyte is where
1439    /// it would want [`Editor::dirty_range`] and the same splice treatment
1440    /// `build_spliced` gives the other map. The door is open; nothing has needed
1441    /// it yet.
1442    pub fn build_source(&mut self) {
1443        if self.smap_key == Some(self.revision) {
1444            return;
1445        }
1446        let nodes = self.nodes();
1447        self.smap = source::build(&nodes, &self.source);
1448        self.smap_key = Some(self.revision);
1449    }
1450
1451    /// Tell the model how many visual rows each block image should reserve, keyed
1452    /// by the image's destination. A terminal frontend calls this once it has
1453    /// decoded and measured its pictures — core does no image I/O, so this is the
1454    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1455    /// placeholder out that tall (the label row plus blank filler rows the
1456    /// frontend paints the raster over). A destination left out of the map falls
1457    /// back to the bare one-row placeholder, which is also what a frontend that
1458    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1459    /// by never calling this.
1460    ///
1461    /// Cheap to call every frame with the same map: only a *change* invalidates
1462    /// the built map (and the block-row cache, since a height isn't part of a
1463    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1464    /// no-op, so a frontend can just hand over its current measurements each frame.
1465    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1466        if self.surface.media_rows == rows {
1467            return;
1468        }
1469        self.surface.media_rows = rows;
1470        self.surface_changed();
1471    }
1472
1473    /// Tell the model how many visual rows each leaf directive should reserve,
1474    /// keyed by its [`DirectiveKey`](wysiwyg::DirectiveKey) — its name, label
1475    /// and attributes, as [`DirectiveInfo::key`](wysiwyg::DirectiveInfo::key)
1476    /// hands it over. The peer of [`set_media_rows`](Self::set_media_rows) for
1477    /// a terminal host that draws a directive as lines of its own: it asks its
1478    /// renderer for the lines, counts them, and reports back, and the next
1479    /// build lays the placeholder out that tall — the label row plus blank
1480    /// filler rows the host's lines are drawn over, holding no caret. A
1481    /// directive left out of the map is the one-row placeholder, which is what
1482    /// a host with no drawing for it, or a frontend laying directives out in
1483    /// its own units, gets by never calling this.
1484    ///
1485    /// Keyed by what the directive says rather than where it stands, as a
1486    /// picture is keyed by its destination, so the height holds across an
1487    /// edit above it and two directives spelled alike share one entry. Cheap
1488    /// to call every frame with the same map: only a change drops the caches.
1489    pub fn set_directive_rows(&mut self, rows: HashMap<wysiwyg::DirectiveKey, usize>) {
1490        if self.surface.directive_rows == rows {
1491            return;
1492        }
1493        self.surface.directive_rows = rows;
1494        self.surface_changed();
1495    }
1496
1497    /// Tell the model how many visual rows each display formula should
1498    /// reserve, keyed by the formula's TeX exactly as the map's
1499    /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1500    /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1501    /// typesets the picture, measures it in cells, and reports back; a
1502    /// frontend that lays formulas out in pixels never calls this and gets
1503    /// the one-row placeholder to paint over.
1504    pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1505        if self.surface.math_rows == rows {
1506            return;
1507        }
1508        self.surface.math_rows = rows;
1509        self.surface_changed();
1510    }
1511
1512    /// Tell the model whether the frontend can paint a picture *inside* a line
1513    /// of text. When it can, an inline formula renders to one atom glyph the
1514    /// frontend draws its typeset picture over — see
1515    /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1516    /// the code-styled TeX it always showed. Off until a frontend says
1517    /// otherwise, so a host that has not caught up sees what it saw.
1518    pub fn set_inline_pictures(&mut self, on: bool) {
1519        if self.surface.inline_pictures == on {
1520            return;
1521        }
1522        self.surface.inline_pictures = on;
1523        self.surface_changed();
1524    }
1525
1526    /// A height or a capability lives outside a block's source bytes, so the
1527    /// content-keyed block cache would hand back the old rows on a hit. Drop
1528    /// it (and the splice layout it carries) so the next build re-renders
1529    /// every block against the new surface, and force that build by clearing
1530    /// the map key.
1531    fn surface_changed(&mut self) {
1532        self.block_cache = wysiwyg::BlockCache::default();
1533        self.vmap_key = None;
1534    }
1535
1536    /// The revision the document's text is at — bumped by every edit, undo,
1537    /// redo, and reload, and by nothing else. A frontend caches against this to
1538    /// tell a repaint that needs new work from one that doesn't.
1539    ///
1540    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1541    /// lands on the same text two revisions later. Work is only ever rebuilt
1542    /// needlessly, never wrongly reused.
1543    pub fn revision(&self) -> u64 {
1544        self.revision
1545    }
1546
1547    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1548    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1549    /// unbuilt map before the first one.
1550    ///
1551    /// This is *not* [`revision`](Self::revision). The revision says where the
1552    /// text is; this says where the map is, and the two part company the moment
1553    /// an edit lands, until something rebuilds. A frontend that keeps its own
1554    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1555    /// under an oversized heading — compares this against the value it held when
1556    /// it took the copy, and learns whether `vmap` is still the map it stashed
1557    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1558    /// map would paint a stale document; restoring nothing hands core's
1559    /// incremental rebuild a map it never built.
1560    ///
1561    /// "Somebody else" includes another document. The key names the `Doc`
1562    /// as well as the build, so a frontend that draws two documents through
1563    /// one stash — a host with several buffers, or one that opens the next
1564    /// document where the last one stood — never has the copy it took of one
1565    /// accepted by the other, however alike their builds are.
1566    pub fn visual_key(&self) -> VisualKey {
1567        VisualKey(self.identity, self.vmap_key.clone())
1568    }
1569
1570    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1571    /// runs on every call: the caret moves without the document changing, and
1572    /// keeping it on a legal stop is this function's job either way.
1573    fn build_map(&mut self, wrap: Option<usize>) {
1574        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1575        // as well as the text, so the line joins the key: moving within a line
1576        // still reuses the map, and crossing into another one rebuilds it. In
1577        // every other mode `reveal_line` is `None` and the key is what it was,
1578        // so caret motion goes on costing nothing.
1579        let reveal = self.reveal_line();
1580        let key = (self.revision, wrap, reveal.clone());
1581        if self.vmap_key.as_ref() != Some(&key) {
1582            self.build_map_with(wrap, reveal);
1583            self.vmap_key = Some(key);
1584            // In a hidden mode the reveal line was decided from the *previous*
1585            // build's layout, whose spans are stale across an edit: the
1586            // keystroke that closes a new `$…$` on the caret's line asked "is
1587            // there math here?" of a layout that had none, and the formula
1588            // would snap to its picture under the caret until the next
1589            // motion. Ask again of the layout just built, and go once more if
1590            // the answer moved. Between edits the first answer is exact and
1591            // this is one comparison.
1592            let again = self.reveal_line();
1593            if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1594                self.build_map_with(wrap, again.clone());
1595                self.vmap_key = Some((self.revision, wrap, again));
1596            }
1597        }
1598        self.clamp_caret();
1599    }
1600
1601    /// One build of the map at `wrap` under `reveal`, incremental where it can
1602    /// be — the body of [`build_map`](Self::build_map), which decides whether
1603    /// to call it.
1604    fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1605        {
1606            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1607            // A subtree is pulled only for the block(s) that actually changed, so
1608            // the FFI marshal shrinks from O(document) to O(edited block).
1609            let top = self.top_blocks();
1610
1611            // Fast path: when twig reports a dirty byte range, try to patch the
1612            // previous map in place — a single-block edit moves the prefix,
1613            // shifts the suffix, and re-renders only one block. `build_spliced`
1614            // returns `None` (and we fall back to the always-correct full rebuild)
1615            // whenever the edit reshaped the block structure, hit a table, or
1616            // there's no previous map to patch.
1617            // Preserve soft breaks as written when the flow preference asks for
1618            // it — the builder renders each as its own visual row instead of
1619            // folding it into the reflowed paragraph.
1620            let preserve_soft = self.line_flow == LineFlow::Preserve;
1621            let spliced = match self.editor.dirty_range() {
1622                Some(dirty) => {
1623                    let prev = std::mem::take(&mut self.vmap);
1624                    let source = &self.source;
1625                    let cache = &mut self.block_cache;
1626                    let surface = &self.surface;
1627                    let editor = &mut self.editor;
1628                    wysiwyg::build_spliced(
1629                        prev,
1630                        source,
1631                        wrap,
1632                        preserve_soft,
1633                        &top,
1634                        dirty,
1635                        surface,
1636                        reveal.clone(),
1637                        cache,
1638                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1639                    )
1640                }
1641                None => None,
1642            };
1643            self.vmap = spliced.unwrap_or_else(|| {
1644                let source = &self.source;
1645                let cache = &mut self.block_cache;
1646                let surface = &self.surface;
1647                let editor = &mut self.editor;
1648                wysiwyg::build_cached(
1649                    &top,
1650                    source,
1651                    wrap,
1652                    preserve_soft,
1653                    surface,
1654                    reveal,
1655                    cache,
1656                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1657                )
1658            });
1659            // Acknowledge the dirty range so the next edit's range starts fresh.
1660            self.editor.clear_dirty();
1661        }
1662    }
1663
1664    fn nodes(&mut self) -> Vec<FlatNode> {
1665        self.editor.nodes().unwrap_or_default()
1666    }
1667
1668    /// The document's top-level blocks for the incremental render. See
1669    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1670    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1671        wysiwyg::top_blocks(&mut self.editor)
1672    }
1673
1674    pub fn format_name(&self) -> &'static str {
1675        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1676        // required. It also covers `Asciidoc`, which twig parses but cannot
1677        // serialize — leaf never opens a document in it (see `Doc::open`).
1678        match self.format {
1679            Format::Djot => "djot",
1680            Format::Markdown => "markdown",
1681            Format::Xml => "xml",
1682            Format::Html => "html",
1683            _ => "unknown",
1684        }
1685    }
1686
1687    /// Whether this document's format offers *any* door in — `false` only for a
1688    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1689    /// a frontend may as well open the file read-only.
1690    ///
1691    /// This is a much weaker claim than the name suggests, and driving per-button
1692    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1693    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1694    /// while a heading, a quote, a list, a task box, a link and a code fence all
1695    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1696    /// [`supports`](Self::supports) — per control.
1697    pub fn authorable(&self) -> bool {
1698        self.format.is_authorable()
1699    }
1700
1701    /// Whether this document can spell `gesture`, which is twig's own answer
1702    /// rather than a copy of it: `Format::supports_with` reads the same
1703    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1704    /// the very [`parse_extensions`] this document's editor reparses with — so
1705    /// what the toolbar offers and what the splice will accept are one table.
1706    ///
1707    /// It is a fact about the *document*, not about the caret. `true` does not
1708    /// promise the gesture succeeds where it is standing — a link over a table
1709    /// border still fails — only that it will not fail with
1710    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1711    /// will work here".
1712    pub fn supports(&self, gesture: Gesture) -> bool {
1713        self.format.supports_with(parse_extensions(), gesture)
1714    }
1715
1716    /// Every control's enabled state in one read — what a toolbar builds itself
1717    /// from when a document opens or its format changes. See [`Capabilities`].
1718    pub fn capabilities(&self) -> Capabilities {
1719        Capabilities::of(self.format)
1720    }
1721
1722    /// Refuse a gesture this document's format cannot spell, saying so in the
1723    /// status line. `true` means the caller must return without calling twig.
1724    ///
1725    /// Most of these refusals duplicate one twig would make anyway, and they are
1726    /// made here regardless because a message naming the *document's* format
1727    /// reads better than one naming twig's internals. Two of them are not
1728    /// duplicates and are the reason this is a guard rather than an error
1729    /// translation:
1730    ///
1731    /// - The table family (see [`table_op`](Self::table_op)) consults no
1732    ///   `Syntax` table, so twig does not refuse it at all.
1733    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1734    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1735    ///   could not keep.
1736    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1737        self.refuse_unless(what, self.supports(gesture))
1738    }
1739
1740    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1741    /// answers itself — today only [`spells_pipe_tables`].
1742    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1743        if supported {
1744            return false;
1745        }
1746        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1747        true
1748    }
1749
1750    /// The name to show for this document. An untitled one has no file to name
1751    /// it, and both frontends put this straight on screen — an empty path
1752    /// renders as an empty header, so it says so instead.
1753    pub fn file_name(&self) -> String {
1754        if self.is_untitled() {
1755            return "untitled".into();
1756        }
1757        self.path
1758            .file_name()
1759            .map(|s| s.to_string_lossy().into_owned())
1760            .unwrap_or_else(|| self.path.display().to_string())
1761    }
1762
1763    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1764    /// caret and anchor coincide (an empty selection is no selection).
1765    pub fn selection(&self) -> Option<(usize, usize)> {
1766        self.anchor
1767            .map(|a| (a.min(self.caret), a.max(self.caret)))
1768            .filter(|(s, e)| s != e)
1769    }
1770
1771    /// The selected text, or `None` when there's no selection — the source
1772    /// slice a copy/cut hands to the system clipboard.
1773    pub fn selected_text(&self) -> Option<&str> {
1774        self.selection().map(|(s, e)| &self.source[s..e])
1775    }
1776
1777    /// The selection as a quote with a little of what surrounds it — the shape
1778    /// a host that cites, annotates, or searches for a passage wants, cut from
1779    /// the **source** rather than from anything rendered, so the quote is
1780    /// findable in the document again by plain string search.
1781    ///
1782    /// `context` is a count of characters (not bytes) on each side, clipped at
1783    /// the document's edges; the slices land on char boundaries by
1784    /// construction. `None` when nothing is selected.
1785    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1786        let (start, end) = self.selection()?;
1787        let mut before = start;
1788        for _ in 0..context {
1789            match self.source[..before].chars().next_back() {
1790                Some(c) => before -= c.len_utf8(),
1791                None => break,
1792            }
1793        }
1794        let mut after = end;
1795        for _ in 0..context {
1796            match self.source[after..].chars().next() {
1797                Some(c) => after += c.len_utf8(),
1798                None => break,
1799            }
1800        }
1801        Some(Quote {
1802            exact: self.source[start..end].to_string(),
1803            prefix: self.source[before..start].to_string(),
1804            suffix: self.source[end..after].to_string(),
1805            start,
1806            end,
1807        })
1808    }
1809
1810    /// Words, characters, and paragraphs over the whole document — the numbers
1811    /// a status bar or an inspector puts next to a piece of writing.
1812    ///
1813    /// Counted over the text a **reader** sees, not the markup that spells it:
1814    /// `**bold**` is one word and four characters, a link is its label and not
1815    /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1816    /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1817    /// not render at all — is not writing. [`crate::counts`] states the rules
1818    /// in full; [`TextCounts`] states them per field.
1819    ///
1820    /// The same numbers in both views. They have to be: a word count that fell
1821    /// when you pressed ⌘E would be telling you the view had changed, which
1822    /// you knew already. So this reads neither [`Doc::view`] nor the map the
1823    /// frontend last built — it renders the source afresh, unwrapped, with
1824    /// soft breaks folded and no line revealed, and counts that. A narrower
1825    /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1826    /// source view all give the identical answer, because none of them is an
1827    /// input.
1828    ///
1829    /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1830    /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1831    /// settle and wrong in a paint loop, so a frontend should ask when the
1832    /// typing stops rather than once a keystroke. Caching it against
1833    /// [`revision`](Self::revision) is the obvious next move if that is ever
1834    /// not enough; nothing has needed it yet.
1835    pub fn counts(&self) -> TextCounts {
1836        self.count_over(None)
1837    }
1838
1839    /// The same statistics over the selection alone — `None` when nothing is
1840    /// selected, since an empty selection is no selection.
1841    ///
1842    /// Same rules, over the same rendering, narrowed to the glyphs whose
1843    /// source byte falls inside [`selection`](Self::selection)'s range. A
1844    /// block the selection only clips still counts as one paragraph, and one
1845    /// it enters without catching a visible character counts as none — a
1846    /// selection that starts on a hidden `**` gains no paragraph from it.
1847    pub fn selection_counts(&self) -> Option<TextCounts> {
1848        let (start, end) = self.selection()?;
1849        Some(self.count_over(Some(start..end)))
1850    }
1851
1852    /// The rendering both counters tally, and the tally itself.
1853    ///
1854    /// A fresh parse rather than `self.editor`, because these take `&self` and
1855    /// twig's arena is reached through `&mut`. A document that will not
1856    /// reparse is a "cannot happen" — the source came out of an editor that
1857    /// had already accepted it — and answers zero rather than panicking in
1858    /// what is very likely a paint path.
1859    fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1860        let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1861            return TextCounts::default();
1862        };
1863        let Ok(nodes) = editor.nodes() else {
1864            return TextCounts::default();
1865        };
1866        // A surface that paints pictures in a line, so an inline formula is
1867        // an atom here and never its TeX: a formula is a picture to a reader
1868        // whichever way it is written, and the count says so consistently.
1869        let surface = wysiwyg::Surface {
1870            inline_pictures: true,
1871            ..Default::default()
1872        };
1873        let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1874        counts::tally(&map, range)
1875    }
1876
1877    /// Whether the document refuses to change — see the field.
1878    pub fn read_only(&self) -> bool {
1879        self.read_only
1880    }
1881
1882    /// Turn the read-only gate on or off. A frontend preference like
1883    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1884    /// itself changes, only what may be done to it from here on.
1885    pub fn set_read_only(&mut self, on: bool) {
1886        self.read_only = on;
1887    }
1888
1889    /// The host-painted ranges, sorted by start — see [`Highlight`].
1890    pub fn highlights(&self) -> &[Highlight] {
1891        &self.highlights
1892    }
1893
1894    /// Replace the host-painted ranges wholesale. The whole set each time,
1895    /// rather than add/remove verbs: the host owns the list (it derives it
1896    /// from its own state — annotations, search hits), and a replace can
1897    /// never leave the two disagreeing about what should be on screen.
1898    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1899        highlights.retain(|h| h.start < h.end);
1900        highlights.sort_by_key(|h| (h.start, h.end));
1901        self.highlights = highlights;
1902    }
1903
1904    /// The highlight covering source `offset`, if one does — first by start
1905    /// when several overlap, which makes overlapping washes resolvable rather
1906    /// than undefined. What a frontend asks when the reader activates a spot.
1907    ///
1908    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1909    /// asking the same question per glyph, against a slice they were handed
1910    /// rather than against a `Doc`, and one answer for both is what keeps a
1911    /// wash and an activation agreeing about which range a spot is in.
1912    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1913        Highlight::covering(&self.highlights, offset)
1914    }
1915
1916    /// The AST breadcrumb at the caret (root → deepest), e.g.
1917    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1918    pub fn breadcrumb(&mut self) -> String {
1919        match self.editor.ancestors_at(self.caret) {
1920            Ok(chain) => chain
1921                .iter()
1922                .map(|m| m.kind.as_str())
1923                .collect::<Vec<_>>()
1924                .join(" › "),
1925            Err(_) => String::new(),
1926        }
1927    }
1928
1929    // ── editing ──────────────────────────────────────────────────────────────
1930
1931    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1932    /// after it. The public form of the internal splice — a pixel frontend that
1933    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1934    /// edits through this, the same twig `edit_range` the caret ops use.
1935    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1936        self.splice(start, end, text, EditKind::Other);
1937    }
1938
1939    /// Replace `[start, end)` with `text` in place, behind the caret — an
1940    /// automatic substitution a host makes as the user types: a misspelling
1941    /// corrected, a text replacement expanded, a straight quote curled, a `--`
1942    /// made a dash. Whether it did: `false` for a read-only document, a range
1943    /// that is not one, or an edit twig refused.
1944    ///
1945    /// Unlike [`edit`](Self::edit), the caret and the selection stay where they
1946    /// were, moved along by the edit — a word corrected behind the caret does
1947    /// not pull the caret back to it — and so does a sticky mark armed at the
1948    /// caret. It is an undo step of its own, folded into neither the typing
1949    /// before it nor the typing after, so one undo puts back exactly what was
1950    /// typed, with the caret where it stood. The bytes are replaced exactly,
1951    /// snapping neither end, so a word at the edge of `**bold**` keeps its
1952    /// delimiters. `text` is written the way typing writes it: literally, with
1953    /// anything that would open markup escaped, where typing is literal
1954    /// ([`MarkupMode::None`] in the rendered view).
1955    pub fn substitute(&mut self, start: usize, end: usize, text: &str) -> bool {
1956        if self.read_only
1957            || start > end
1958            || end > self.source.len()
1959            || !self.source.is_char_boundary(start)
1960            || !self.source.is_char_boundary(end)
1961        {
1962            return false;
1963        }
1964        let before = self.source.len();
1965        let (caret, anchor) = (self.caret, self.anchor);
1966        let (pending_marks, pending_at) = (self.pending_marks, self.pending_at);
1967        let group_had_step = self.undo_group.map(|g| g.has_step);
1968        self.last_edit_kind = None;
1969        let literal = !self.markup_mode.authors()
1970            && self.view == View::Wysiwyg
1971            && !text.is_empty()
1972            && self.supports(Gesture::InsertLiteral);
1973        let landed = if literal {
1974            let deleted = self.source[start..end].to_owned();
1975            if start != end && !self.splice_exact(start, end, "", EditKind::Other) {
1976                false
1977            } else if self.insert_literal_at(start, text, EditKind::Other, start != end) {
1978                true
1979            } else {
1980                // The deletion landed and the text did not: take the deletion
1981                // back rather than leave half a substitution.
1982                if start != end {
1983                    self.restore_deleted(start, &deleted, group_had_step);
1984                }
1985                false
1986            }
1987        } else {
1988            self.splice_exact(start, end, text, EditKind::Other)
1989        };
1990        if !landed {
1991            self.caret = caret;
1992            self.anchor = anchor;
1993            self.record_caret();
1994            return false;
1995        }
1996        self.carry_selection(
1997            caret,
1998            anchor,
1999            (pending_marks, pending_at),
2000            start,
2001            end,
2002            before,
2003        );
2004        true
2005    }
2006
2007    /// Bring the source to `text` in one step that leaves the caret where it
2008    /// was — what a host does when the file changed under an open editor, as
2009    /// another device's edit arriving does.
2010    ///
2011    /// Only the span that differs is replaced: the longest prefix and suffix
2012    /// the two share are kept, so a caret or selection outside the change
2013    /// stays on the characters it was on, and one inside it goes to the end
2014    /// of what replaced it, as [`substitute`](Self::substitute) moves them.
2015    /// Unlike a substitution, `text` is source and written exactly — nothing
2016    /// in it is escaped — since it is the document as it now stands, not
2017    /// something typed. An undo step of its own. `true` with nothing done
2018    /// when the source is already `text`; `false` when the document is
2019    /// read-only or the edit was refused.
2020    pub fn replace_source(&mut self, text: &str) -> bool {
2021        if self.source == text {
2022            return true;
2023        }
2024        if self.read_only {
2025            return false;
2026        }
2027        let (start, end, with) = differing_span(&self.source, text);
2028        let with = with.to_owned();
2029        let before = self.source.len();
2030        let (caret, anchor) = (self.caret, self.anchor);
2031        let pending = (self.pending_marks, self.pending_at);
2032        self.last_edit_kind = None;
2033        if !self.splice_exact(start, end, &with, EditKind::Other) {
2034            self.caret = caret;
2035            self.anchor = anchor;
2036            self.record_caret();
2037            return false;
2038        }
2039        self.carry_selection(caret, anchor, pending, start, end, before);
2040        true
2041    }
2042
2043    /// Put the caret and selection back after an edit of `[start, end)` that
2044    /// left them wherever the edit did, moved along by it — see
2045    /// [`substitute`](Self::substitute). `before` is the source's length
2046    /// before the edit; `pending` the sticky marks armed at the old caret.
2047    fn carry_selection(
2048        &mut self,
2049        caret: usize,
2050        anchor: Option<usize>,
2051        pending: (InlineMarks, Option<usize>),
2052        start: usize,
2053        end: usize,
2054        before: usize,
2055    ) {
2056        let (pending_marks, pending_at) = pending;
2057        let delta = self.source.len() as isize - before as isize;
2058        let new_end = (end as isize + delta).max(start as isize) as usize;
2059        // A place after the range moves with it, one inside it goes to the end
2060        // of what replaced it, and one before it stays.
2061        fn shift(p: usize, start: usize, end: usize, new_end: usize, delta: isize) -> usize {
2062            if p >= end {
2063                (p as isize + delta).max(0) as usize
2064            } else if p > start {
2065                new_end
2066            } else {
2067                p
2068            }
2069        }
2070        self.caret = shift(caret, start, end, new_end, delta);
2071        self.anchor = anchor
2072            .map(|a| shift(a, start, end, new_end, delta))
2073            .filter(|&a| a != self.caret);
2074        if pending_at == Some(caret) {
2075            self.pending_marks = pending_marks;
2076            self.pending_at = Some(self.caret);
2077        }
2078        self.goal_col = None;
2079        self.last_edit_kind = None;
2080        self.clamp_caret();
2081        // The caret the step leaves, so a redo puts it back here too.
2082        self.record_caret();
2083    }
2084
2085    /// Put back `deleted`, the bytes a half-made [`substitute`](Self::substitute)
2086    /// took out at `at`, and leave nothing of the pair on the history.
2087    ///
2088    /// Not [`undo`](Self::undo): that closes an open group and takes the whole
2089    /// of it back — every substitution of the keystroke made before this one
2090    /// — and leaves a redo step that would delete the bytes again. Instead the
2091    /// bytes go back as an edit of their own, and the two edits, which change
2092    /// nothing together, are folded away: inside a group that already had a
2093    /// step they fold into it as they land; as a group's first step, or
2094    /// outside a group, into the step before. With no step before it there is
2095    /// nothing to fold into, and one step that changes nothing is left.
2096    /// `group_had_step` is what the group said before the deletion — `None`
2097    /// outside one.
2098    fn restore_deleted(&mut self, at: usize, deleted: &str, group_had_step: Option<bool>) {
2099        if !self.splice_exact(at, at, deleted, EditKind::Other) {
2100            // twig will not take its own bytes back: the history's way, which
2101            // at least leaves the document as it was.
2102            self.undo();
2103            return;
2104        }
2105        match group_had_step {
2106            Some(true) => {}
2107            Some(false) => {
2108                let steps = self.undo_steps;
2109                self.fold_last_undo();
2110                if self.undo_steps < steps
2111                    && let Some(g) = &mut self.undo_group
2112                {
2113                    g.has_step = false;
2114                }
2115            }
2116            None => {
2117                self.fold_last_undo();
2118                self.fold_last_undo();
2119            }
2120        }
2121        self.last_edit_kind = None;
2122    }
2123
2124    /// Insert typed `text` at the caret, replacing the selection if there is one.
2125    /// A single typed character coalesces with the run of typing before it; a
2126    /// newline or a multi-character insert is its own undo step.
2127    ///
2128    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
2129    pub fn insert(&mut self, text: &str) {
2130        // The read-only gate, up front: the paths below reach twig by several
2131        // verbs, not all of them through the splice — see the field.
2132        if self.read_only {
2133            return;
2134        }
2135        // Typing against a block picture would dissolve it, and typing past a
2136        // table would grow it a row — see `open_paragraph_at_block_edge`. Give
2137        // the text a paragraph first, so what the caret was standing beside
2138        // stays what it was.
2139        self.open_paragraph_at_block_edge(text);
2140        // Armed sticky marks (⌘b with no selection) turn the next typed text
2141        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
2142        // the exception: it takes no mark of its own and keeps the delta armed
2143        // for the character behind it — see `insert_space_with_marks`.
2144        let pending = self.pending_here();
2145        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
2146            if text.trim().is_empty() {
2147                self.insert_space_with_marks(self.caret, text, pending);
2148            } else {
2149                self.insert_with_marks(self.caret, text, pending);
2150            }
2151            return;
2152        }
2153        // `MarkupMode::None`: typed syntax stays literal — twig escapes
2154        // anything that would open markup, so a Diaryx user never mints
2155        // formatting by keyboard (it comes from commands instead). The other two
2156        // rungs of the ladder author markup from what you type, which is the
2157        // whole difference between them and this one. Only in the rendered view
2158        // (source view is for typing raw markup) and only where the format has a
2159        // literal spelling at all: escaping is a backslash before a byte from the
2160        // format's own alphabet, and a format with no such alphabet (HTML escapes
2161        // with entities, XML spells nothing) would have `\&` written into it,
2162        // which is two literal characters and not an escape. Marks (⌘b) still
2163        // format — that path returned above; and leaf's own structural inserts go
2164        // through `insert_raw`, never here, so a list marker or quote gutter is
2165        // written as the markup it is.
2166        if !self.markup_mode.authors()
2167            && self.view == View::Wysiwyg
2168            && !text.is_empty()
2169            && self.supports(Gesture::InsertLiteral)
2170        {
2171            self.insert_literal_typed(text);
2172            return;
2173        }
2174        self.insert_raw(text);
2175    }
2176
2177    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
2178    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
2179    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
2180    /// markup by design and must not be escaped.
2181    fn insert_raw(&mut self, text: &str) {
2182        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2183        self.splice(s, e, text, typed_edit_kind(text));
2184    }
2185
2186    /// Open a paragraph for text about to be inserted at one of a block media's
2187    /// two caret stops, or at a table's trailing stop, and leave the caret
2188    /// standing in it.
2189    ///
2190    /// A block image is a paragraph whose entire content is the picture, and the
2191    /// caret's only homes on it are in front of it and just past it (see
2192    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
2193    /// *that* paragraph — and a paragraph holding anything besides the image is
2194    /// no longer a block image but a line of text with an inline one in it. The
2195    /// frontend that was painting a photo there paints a text run instead; the
2196    /// picture is still in the file, and nothing said a word. Those two offsets
2197    /// are also exactly where a click on the picture lands, so the whole accident
2198    /// is one tap and one keystroke.
2199    ///
2200    /// So the break goes in first and the text lands in the new empty paragraph —
2201    /// what pressing Return before typing would have done, which is a habit no
2202    /// one should have to learn from losing a photo. A no-op everywhere else, and
2203    /// over a selection (which is replaced, not joined into).
2204    ///
2205    /// A picture inside a quote or a list leaves its container, because `\n\n`
2206    /// ends the block. The alternative is worse: the `\n> ` / next-item
2207    /// continuation [`newline`](Self::newline) writes stays in the same
2208    /// *paragraph*, which is the thing being prevented.
2209    ///
2210    /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
2211    /// accident from the other side of a different block: the stop sits at the
2212    /// end of the table's last source line, and a line glued under a table is
2213    /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
2214    /// break goes in there too, and the text lands under the table.
2215    ///
2216    /// A block leaf directive (`::embed{src=x}`, `::page-break`, djot's empty
2217    /// `::: name` fence) is drawn on the picture's recipe and dissolves the
2218    /// same way — `X::embed{src=x}` and `::embed{src=x}X` are paragraphs of
2219    /// raw source — so its two stops ([`VisualMap::block_directive_stop`])
2220    /// open a paragraph too. Two directives in a row with nothing between
2221    /// them get one between them, from either side.
2222    ///
2223    /// Only in the rendered view. Source view is for typing raw markup, where
2224    /// putting a character against an image is exactly what it looks like.
2225    fn open_paragraph_at_block_edge(&mut self, text: &str) {
2226        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
2227            return;
2228        }
2229        if self.selection().is_some() {
2230            return;
2231        }
2232        // The map may be a revision behind (nothing has drawn since the last
2233        // edit), and this asks it about offsets — a stale answer would splice a
2234        // break into the wrong place. Free when it is already current, which it
2235        // is whenever a frontend drew a frame between keystrokes.
2236        self.rebuild_map();
2237        let at = self.caret;
2238        let (side, span) = match self.block_atom_stop(at) {
2239            Some(stop) => stop,
2240            None if self.vmap.table_end_stop(at) => (MediaStop::After, at..at),
2241            None => return,
2242        };
2243        // In front of the block, the break goes in front of all of it — a
2244        // djot `{…}` line above it included, or the typed text would take
2245        // the block's attributes.
2246        let at = match side {
2247            MediaStop::Before => span.start,
2248            MediaStop::After => at,
2249        };
2250        if !self.splice(at, at, "\n\n", EditKind::Other) {
2251            return;
2252        }
2253        // The break is part of the keystroke, not an edit of its own: leave the
2254        // run marked as typing so the character about to arrive folds into it and
2255        // one undo puts the document back the way it was found. (A paste, or a
2256        // multi-character insert, is `EditKind::Other` and stays its own step —
2257        // as it would have been anywhere else in the document.)
2258        self.last_edit_kind = Some(EditKind::Insert);
2259        if side == MediaStop::Before {
2260            // The break went in above the picture and the caret rode to the end
2261            // of it — which is still hard against the picture. Step back onto the
2262            // blank line it opened, so the text lands above rather than in front.
2263            self.caret = at;
2264        }
2265    }
2266
2267    /// Which of a block atom's two caret stops `at` is — a block picture's
2268    /// ([`VisualMap::block_media_stop`]) or a block leaf directive's
2269    /// ([`VisualMap::block_directive_stop`]) — with the span that is the atom
2270    /// whole. In djot that span reaches back over a `{…}` attribute line
2271    /// written above the block: the line is the block's markup, drawn as
2272    /// nothing, and a paragraph opened between it and the block would take
2273    /// the attributes, as a delete that left it would hand them to the block
2274    /// below.
2275    ///
2276    /// Reads the map, so the caller rebuilds it first.
2277    fn block_atom_stop(&mut self, at: usize) -> Option<(MediaStop, Range<usize>)> {
2278        let (side, mut span) = self
2279            .vmap
2280            .block_media_stop(at)
2281            .or_else(|| self.vmap.block_directive_stop(at))?;
2282        if self.format == Format::Djot {
2283            let starts_here: Vec<NodeId> = self
2284                .nodes()
2285                .into_iter()
2286                .filter(|n| n.span.start == span.start)
2287                .map(|n| n.id)
2288                .collect();
2289            let attrs = self.editor.document().ok().and_then(|mut d| {
2290                starts_here
2291                    .into_iter()
2292                    .find_map(|id| d.attrs_span(id).ok().flatten())
2293            });
2294            if let Some(a) = attrs.filter(|a| a.end <= span.start) {
2295                span.start = a.start;
2296            }
2297        }
2298        Some((side, span))
2299    }
2300
2301    /// A delete key pressed at one of a block picture's or a block leaf
2302    /// directive's two caret stops, handled as the block being an *atom* rather
2303    /// than a run of bytes. Returns whether the key was consumed.
2304    ///
2305    /// The caret rests in front of a block image and just past it, never inside
2306    /// its markup — which the rendered view doesn't show. So the byte a delete
2307    /// key nominally takes there is one the writer cannot see, and taking it
2308    /// leaves the picture as broken markup rather than as anything anyone asked
2309    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
2310    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
2311    /// missing from a document with nobody having touched it — the same
2312    /// dissolution [`open_paragraph_at_block_edge`](Self::open_paragraph_at_block_edge)
2313    /// prevents from the typing side, and it cost this repository's own test vault
2314    /// a photo before it was found.
2315    ///
2316    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
2317    /// when it is behind the caret, Delete when it is in front — which is what
2318    /// every editor does with an embed, and one undo away. The key aimed *away*
2319    /// from it would otherwise delete the paragraph break and merge a neighbour
2320    /// into the picture's own paragraph, which dissolves it just as surely; it
2321    /// steps the caret over the boundary instead and leaves the
2322    /// next press to delete in the block it has reached — the same "first press
2323    /// steps out of the atom, second press deletes" every delete key here gets,
2324    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
2325    /// above, and reaches it on the second press rather than taking the break and
2326    /// the picture with it on the first).
2327    fn delete_around_block_atom(&mut self, forward: bool) -> bool {
2328        // The map answers about offsets, so it has to be this revision's — see
2329        // the same call in `open_paragraph_at_block_edge`.
2330        self.rebuild_map();
2331        let Some((side, span)) = self.block_atom_stop(self.caret) else {
2332            return false;
2333        };
2334        let aimed_at_it = side
2335            == if forward {
2336                MediaStop::Before
2337            } else {
2338                MediaStop::After
2339            };
2340        if !aimed_at_it {
2341            let over = if forward {
2342                self.vmap.stop_after(self.caret)
2343            } else {
2344                self.vmap.stop_before(self.caret)
2345            };
2346            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
2347                self.caret = off;
2348                self.anchor = None;
2349                self.goal_col = None;
2350            }
2351            return true;
2352        }
2353        // Take the break that held the picture apart from its neighbour with it,
2354        // so the delete doesn't leave a blank paragraph standing where the
2355        // picture was. The last arm is a picture that is the whole document.
2356        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
2357            (span.start - 2, span.end)
2358        } else if self.source[span.end..].starts_with("\n\n") {
2359            (span.start, span.end + 2)
2360        } else {
2361            (span.start, span.end)
2362        };
2363        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
2364        true
2365    }
2366
2367    /// The Hidden-mode typing path: replace any selection, then insert `text`
2368    /// escaped so it stays literal. When it replaces a selection the two edits
2369    /// fold into one undo step, so an overwrite undoes atomically (and restores
2370    /// the selection) exactly as a plain one does.
2371    fn insert_literal_typed(&mut self, text: &str) {
2372        let kind = typed_edit_kind(text);
2373        match self.selection() {
2374            Some((s, e)) => {
2375                if !self.splice(s, e, "", EditKind::Other) {
2376                    return;
2377                }
2378                // Typing over a whole marked run takes its delimiters with it
2379                // (the empty content couldn't hold them — see
2380                // `repair_mark_edges`) and leaves its marks armed at the caret.
2381                // The text taking the run's place inherits them, exactly as it
2382                // would have by landing inside a run that survived.
2383                let pending = self.pending_here();
2384                if !pending.is_empty() && !text.trim().is_empty() {
2385                    self.insert_with_marks(self.caret, text, pending);
2386                    return;
2387                }
2388                self.insert_literal_at(self.caret, text, kind, true);
2389            }
2390            None => {
2391                self.insert_literal_at(self.caret, text, kind, false);
2392            }
2393        }
2394    }
2395
2396    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2397    /// at a collapsed caret, but only while the caret still stands where they
2398    /// were armed and nothing is selected. Empty otherwise, so a stale delta
2399    /// never styles text it wasn't meant for.
2400    fn pending_here(&self) -> InlineMarks {
2401        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2402            self.pending_marks
2403        } else {
2404            InlineMarks::empty()
2405        }
2406    }
2407
2408    /// Drop the armed sticky marks — any caret motion, selection, or edit does
2409    /// this, so "start bold here" only ever applies at the exact spot it was
2410    /// asked for.
2411    fn clear_pending(&mut self) {
2412        self.pending_marks = InlineMarks::empty();
2413        self.pending_at = None;
2414    }
2415
2416    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2417    /// force is wrapped around the freshly typed text; a mark the caret already
2418    /// stands inside is *shed* — the text is inserted past the run's end so it
2419    /// lands unmarked ("type normally again"). The caret comes to rest inside any
2420    /// added runs, so continued typing inherits the marks with no re-wrapping,
2421    /// and the delta is cleared: the marks now live in the document, not here.
2422    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2423        let base = self.mark_spans_at(at);
2424        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2425        // Nothing to shed, and a run of exactly these marks standing just behind
2426        // the caret: carry on writing *that* run rather than opening a second
2427        // one beside it.
2428        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2429            return;
2430        }
2431        // Shed the marks we're turning off: step the insertion point past the
2432        // end of each run the caret sits in, so the new text falls outside it.
2433        let mut ins_at = at;
2434        for (kind, span) in &base {
2435            if marks.contains(*kind) {
2436                ins_at = ins_at.max(span.end);
2437            }
2438        }
2439        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2440            return;
2441        }
2442        // The plain splice inserted exactly `text` at `ins_at`; that byte range
2443        // is the content every added mark wraps.
2444        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2445        for kind in marks.iter() {
2446            if !base_set.contains(kind) {
2447                let (ncs, nce) = self.wrap_span(cs, ce, kind);
2448                cs = ncs;
2449                ce = nce;
2450            }
2451        }
2452        self.caret = ce.min(self.source.len());
2453        self.anchor = None;
2454        self.last_edit_kind = None;
2455        // Realised: the marks are in the document now, and the caret sits inside
2456        // them, so there is no delta left to carry. Arm nothing, but remember the
2457        // spot so a *further* toggle before typing starts a clean delta here.
2458        self.pending_marks = InlineMarks::empty();
2459        self.pending_at = Some(self.caret);
2460        self.clamp_caret();
2461        self.record_caret();
2462    }
2463
2464    /// Carry on the marked run just behind `at` — moving its closing delimiters
2465    /// out past the new text — instead of opening a second run of the same marks
2466    /// beside it. Returns whether it did.
2467    ///
2468    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2469    /// A space typed after a bold word steps the caret out of the run, because
2470    /// `**bold **` is not bold; the next character has to step back *in*, or the
2471    /// writer who typed one bold phrase is left with `**bold** **and**` — two
2472    /// runs that read the same to a reader but spell the file in a way nobody
2473    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2474    /// words it isn't marking), and the marks behind it must be exactly the ones
2475    /// armed — a run of *some* other kind is a neighbour, not this phrase.
2476    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2477        if text.is_empty() || text.trim() != text {
2478            return false;
2479        }
2480        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2481        // Walk in through the delimiters stacked at that point, innermost last:
2482        // `***both*** ` closes two runs with one `***`, and rejoining means
2483        // getting behind all of them.
2484        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2485        while let Some((kind, content_end)) = self
2486            .editor
2487            .ancestors_at(prev_boundary(&self.source, cut))
2488            .unwrap_or_default()
2489            .into_iter()
2490            .filter(|m| m.span.end == cut)
2491            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2492        {
2493            if content_end >= cut {
2494                break; // a mark with no closing delimiter to step behind
2495            }
2496            kinds.insert(kind);
2497            cut = content_end;
2498        }
2499        if cut == gap_at || kinds != marks {
2500            return false;
2501        }
2502        // Re-spell the tail: the gap, then the new text, then the delimiters that
2503        // used to close in front of them — read out of the document rather than
2504        // written from a table, so whatever twig spells them with is what moves.
2505        let tail = format!(
2506            "{}{text}{}",
2507            &self.source[gap_at..at],
2508            &self.source[cut..gap_at]
2509        );
2510        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2511            return false;
2512        }
2513        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2514        self.anchor = None;
2515        self.last_edit_kind = None;
2516        self.pending_marks = InlineMarks::empty();
2517        self.pending_at = Some(self.caret);
2518        self.clamp_caret();
2519        self.record_caret();
2520        true
2521    }
2522
2523    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2524    /// never itself wrapped: a mark around a space draws nothing a reader can
2525    /// see, and in Markdown and Djot it draws its own delimiters instead
2526    /// (`** **`). So the space goes in unmarked — outside any run the armed
2527    /// marks are shedding — and the marks stay armed for the character after it,
2528    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2529    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2530        let base = self.mark_spans_at(at);
2531        // What the *next* character carries: the armed delta resolved against the
2532        // marks in force here, which the space must not quietly drop.
2533        let want = base
2534            .iter()
2535            .map(|(k, _)| *k)
2536            .collect::<InlineMarks>()
2537            .xor(marks);
2538        let mut ins_at = at;
2539        for (kind, span) in &base {
2540            if marks.contains(*kind) {
2541                ins_at = ins_at.max(span.end);
2542            }
2543        }
2544        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2545            return;
2546        }
2547        self.rearm(want);
2548        self.record_caret();
2549    }
2550
2551    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2552    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2553    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2554    /// added split evenly around the content — half the growth on each side.
2555    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2556        // The read-only gate — this door reaches twig without the splice.
2557        if self.read_only {
2558            return (s, e);
2559        }
2560        match self.editor.toggle_inline(s, e, kind) {
2561            Ok(change) => {
2562                self.last_edit_kind = None;
2563                self.refresh();
2564                self.dirty = self.source != self.clean_source;
2565                let added = (change.new.end - change.new.start).saturating_sub(e - s);
2566                let half = added / 2;
2567                (change.new.start + half, change.new.end - half)
2568            }
2569            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2570            // rather than lose the keystroke.
2571            Err(e2) => {
2572                self.status = Some(format!("{kind:?}: {e2}"));
2573                (s, e)
2574            }
2575        }
2576    }
2577
2578    /// The safe offset to splice a block-level break at, given a caret that may
2579    /// sit exactly between an inline mark's content and its own closing
2580    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2581    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2582    /// with nothing following it on the line: the closing `**` renders no
2583    /// glyph of its own, so the caret's "end of line" offset lands right
2584    /// before it). Splicing a paragraph/list/quote break at `off` itself would
2585    /// sever the delimiter from its content, stranding it alone on the new
2586    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2587    /// nested marks closing at the same point (`**_x_**`) all clear together.
2588    /// A no-op everywhere else — mid-run, or past real trailing content, no
2589    /// mark's `content_span` ends exactly at `off`.
2590    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2591        let off = off.min(self.source.len());
2592        let runs = self.run_span_ids();
2593        self.editor
2594            .ancestors_at(off)
2595            .unwrap_or_default()
2596            .into_iter()
2597            .filter(|m| hides_delims(m, &runs))
2598            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2599            .map(|m| m.span.end)
2600            .max()
2601            .unwrap_or(off)
2602    }
2603
2604    /// The offset a *delete* aimed at the character before `off` should stop at,
2605    /// when `off` is the start of a run's text and the bytes behind it are that
2606    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2607    /// byte behind the caret at the start of a bold word is not a character the
2608    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2609    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2610    /// delete steps over the whole delimiter to the visible character in front of
2611    /// it instead. Walks out to the *outermost* mark opening there, so
2612    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2613    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2614        let off = off.min(self.source.len());
2615        let runs = self.run_span_ids();
2616        self.editor
2617            .ancestors_at(off)
2618            .unwrap_or_default()
2619            .into_iter()
2620            .filter(|m| hides_delims(m, &runs))
2621            .filter(|m| {
2622                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2623            })
2624            .map(|m| m.span.start)
2625            .min()
2626            .unwrap_or(off)
2627    }
2628
2629    /// `off` moved *inside* the run whose closing delimiters end there — the
2630    /// other offset the rich view draws in the same place, since a `**` renders
2631    /// no glyph of its own. `**bold**` has a caret home on each side of its
2632    /// closing delimiter, one column apart on screen and eight bytes and a whole
2633    /// run apart in the file, and a plain ← lands on the outer one whenever a
2634    /// space follows the phrase. The inner one is what the writer is pointing at
2635    /// there: the end of their bold word. Walks in through every mark closing at
2636    /// that point, innermost last, so `***both***` lands inside both. A no-op
2637    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2638    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2639        let mut off = off.min(self.source.len());
2640        let runs = self.run_span_ids();
2641        loop {
2642            let inner = self
2643                .editor
2644                .ancestors_at(prev_boundary(&self.source, off))
2645                .unwrap_or_default()
2646                .into_iter()
2647                .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2648                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2649                .filter(|&end| end < off)
2650                .max();
2651            match inner {
2652                Some(end) => off = end,
2653                None => return off,
2654            }
2655        }
2656    }
2657
2658    /// The mirror at the opening edge: `off` moved inside the run whose
2659    /// delimiters *start* there, onto the first character of its text. See
2660    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2661    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2662        let mut off = off.min(self.source.len());
2663        let runs = self.run_span_ids();
2664        loop {
2665            let inner = self
2666                .editor
2667                .ancestors_at(off)
2668                .unwrap_or_default()
2669                .into_iter()
2670                .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2671                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2672                .filter(|&start| start > off)
2673                .min();
2674            match inner {
2675                Some(start) => off = start,
2676                None => return off,
2677            }
2678        }
2679    }
2680
2681    /// The ids of the document's attributed run spans — the inline
2682    /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2683    /// which sees an ancestor chain and so only a kind. Read once per gesture,
2684    /// not once per step of a walk.
2685    fn run_span_ids(&mut self) -> Vec<NodeId> {
2686        self.nodes()
2687            .iter()
2688            .filter(|n| wysiwyg::is_run_span(n))
2689            .map(|n| n.id)
2690            .collect()
2691    }
2692
2693    /// The attributed span whose text is exactly `content` — the whole of
2694    /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2695    /// and sits between the tags of `<span …></span>` — as the whole range
2696    /// spelling the span: the node's span, widened to its attribute block
2697    /// where the format writes that outside the node, as djot's
2698    /// `[i]{data-size="large"}` does. `None` for any other range, including
2699    /// part of a span's text.
2700    ///
2701    /// An empty span has an interior of no bytes, or no known interior at
2702    /// all: twig gives Markdown's `<span …></span>` the first and djot's
2703    /// `[]{…}` the second, and the chain already says the offset is inside.
2704    fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2705        let runs = self.run_span_ids();
2706        let m = self
2707            .editor
2708            .ancestors_at(content.start)
2709            .unwrap_or_default()
2710            .into_iter()
2711            .filter(|m| runs.contains(&NodeId(m.node_id)))
2712            .find(|m| match &m.content_span {
2713                Some(c) => *c == content,
2714                None => content.is_empty(),
2715            })?;
2716        let mut range = m.span;
2717        if let Some(attrs) = self
2718            .editor
2719            .document()
2720            .ok()
2721            .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2722        {
2723            range.start = range.start.min(attrs.start);
2724            range.end = range.end.max(attrs.end);
2725        }
2726        Some(range)
2727    }
2728
2729    /// The attributed block whose whole text is exactly `content` — the `T`
2730    /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2731    /// `{.center}\nT` — as the range a delete that takes that text takes with
2732    /// it: the whole `<div>` when the block is all the div holds, or the
2733    /// `{…}` line down to the end of the text. The block version of
2734    /// [`run_span_of_content`](Self::run_span_of_content), for the same
2735    /// reason: a paragraph with no text is no block, so the div would stand
2736    /// around nothing and the `{…}` line above nothing, and a from-scratch
2737    /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2738    /// `None` for a block with more text, a div holding more, a heading (an
2739    /// empty `# ` is still a heading), and a format whose attributes are the
2740    /// block's own tag (HTML's `<p class="center"></p>` is still a
2741    /// paragraph).
2742    fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2743        if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2744            return None;
2745        }
2746        let nodes = self.nodes();
2747        let block = nodes
2748            .iter()
2749            .filter(|n| n.kind == Kind::Para)
2750            .find(|n| n.content_span.as_ref() == Some(&content))?;
2751        match self.format {
2752            Format::Djot => {
2753                let attrs = self
2754                    .editor
2755                    .document()
2756                    .ok()
2757                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2758                (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2759            }
2760            _ => {
2761                let div = block
2762                    .parent
2763                    .and_then(|p| nodes.iter().find(|n| n.id == p))
2764                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2765                let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2766                alone.then(|| div.span.clone())
2767            }
2768        }
2769    }
2770
2771    /// The inline mark kinds whose span covers `off`, each with that span — the
2772    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2773    /// ids instead. Used to shed a mark by stepping past the end of its run.
2774    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2775        let off = off.min(self.source.len());
2776        self.editor
2777            .ancestors_at(off)
2778            .unwrap_or_default()
2779            .into_iter()
2780            .filter(|m| off < m.span.end)
2781            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2782            .collect()
2783    }
2784
2785    /// Insert clipboard `text` at the caret, replacing the selection if there is
2786    /// one — always its own undo step, whatever its length.
2787    ///
2788    /// Provenance is the whole point, and only the caller has it. `insert` reads
2789    /// a lone character as a keystroke and folds it into the run around it,
2790    /// which is right for typing and wrong for a one-character paste: that paste
2791    /// would vanish mid-run on an undo it was never part of, and the characters
2792    /// the user actually typed would go with it. Length can't tell the two
2793    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2794    /// caller comes through is what says which happened.
2795    pub fn paste(&mut self, text: &str) {
2796        // Pasting against a block picture or a table's end joins the block
2797        // exactly as typing does, and for the same reason — see
2798        // `open_paragraph_at_block_edge`.
2799        self.open_paragraph_at_block_edge(text);
2800        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2801        self.splice(s, e, text, EditKind::Other);
2802    }
2803
2804    /// Replace `[start, end)` with `text` as one step of an IME composition —
2805    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2806    /// folds into a single undo.
2807    ///
2808    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2809    /// dozen calls here, each replacing the last one's provisional bytes, and an
2810    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2811    /// unspools backwards through kana — the intermediate states were never text
2812    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2813    /// look like any other edit), so the door the caller comes through is what
2814    /// says so, exactly as it is for [`paste`](Self::paste) versus
2815    /// [`insert`](Self::insert).
2816    ///
2817    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2818    /// composition folds into this one.
2819    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2820        self.splice(start, end, text, EditKind::Compose);
2821    }
2822
2823    /// Close the open composition run, so the next one is its own undo step.
2824    /// Call when the IME commits or withdraws a composition.
2825    ///
2826    /// Only clears a *composition* run: a frontend that reports an end it never
2827    /// began (some IMEs unmark unprompted) would otherwise split the run of
2828    /// typing around it into two undo steps for no reason the user can see.
2829    pub fn end_composition(&mut self) {
2830        if self.last_edit_kind == Some(EditKind::Compose) {
2831            self.last_edit_kind = None;
2832        }
2833    }
2834
2835    // ── the clipboard's rich flavor ──────────────────────────────────────────
2836
2837    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2838    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2839    /// nothing is selected, or when the selection doesn't render (the caller
2840    /// still has [`selected_text`](Self::selected_text), which is what to publish
2841    /// as `text/plain` either way).
2842    ///
2843    /// **The fragment is a source substring, and that is the honest limit here.**
2844    /// It's parsed standalone, so a selection whose meaning depends on its
2845    /// surroundings converts as what it literally says rather than what it looks
2846    /// like on screen: half a list item is a paragraph, a row torn out of a table
2847    /// is the text of a row, the `**` of a bold run selected without its closing
2848    /// `**` is two asterisks. Every one of those still *renders* — there's no
2849    /// error to report — it just renders as the fragment and not as the document.
2850    /// Widening the range to whole blocks would publish text the user didn't
2851    /// select, which is a worse lie than a fragment being a fragment; the plain
2852    /// flavor has the same substring, so the two flavors at least agree.
2853    pub fn selection_html(&mut self) -> Option<String> {
2854        let (start, end) = self.selection()?;
2855        let inline = self.selection_is_inline(start, end);
2856        let html = html::render_fragment(&self.source[start..end], self.format)?;
2857        Some(match inline {
2858            true => html::strip_sole_paragraph(html),
2859            false => html,
2860        })
2861    }
2862
2863    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2864    /// format first. Its own undo step, like any [`paste`](Self::paste).
2865    ///
2866    /// Returns whether it landed. `false` means the HTML didn't convert to
2867    /// anything worth pasting — the caller should fall back to the plain flavor
2868    /// rather than treat it as an error. The `html` module has the full list of
2869    /// what that covers: a table twig won't build, markup it doesn't recognise,
2870    /// an empty result.
2871    pub fn paste_html(&mut self, html: &str) -> bool {
2872        match html::parse_fragment(html, self.format) {
2873            Some(source) => {
2874                self.paste(&source);
2875                true
2876            }
2877            None => false,
2878        }
2879    }
2880
2881    /// Does the selection live *inside* a single top-level block?
2882    ///
2883    /// The question [`selection_html`](Self::selection_html) needs and the
2884    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2885    /// whether the user selected one word of a sentence or a whole paragraph, and
2886    /// only the document knows which. Selecting a word and pasting into Docs
2887    /// should extend the line you paste into; selecting the paragraph should make
2888    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2889    /// is an artifact of standalone parsing), and one that covers a whole block —
2890    /// or spans two — keeps its structure.
2891    ///
2892    /// Reads the block from twig rather than guessing from the bytes:
2893    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2894    /// block containing an offset, and two ends inside the same one cannot have
2895    /// crossed a block boundary.
2896    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2897        // The last *character*, not `end - 1`: the selection's end is exclusive
2898        // and may sit mid-codepoint's-worth of bytes past the last char.
2899        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2900            return false;
2901        };
2902        let (Some(head), Some(tail)) =
2903            (self.top_block_span(start), self.top_block_span(start + off))
2904        else {
2905            return false;
2906        };
2907        head == tail && !(start <= head.start && end >= head.end)
2908    }
2909
2910    /// The byte span of the top-level block containing `offset`, or `None` at an
2911    /// offset that belongs to no block (the blank line between two of them).
2912    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2913        self.editor
2914            .ancestors_at(offset)
2915            .ok()?
2916            .get(1)
2917            .map(|m| m.span.clone())
2918    }
2919
2920    // ── indentation ──────────────────────────────────────────────────────────
2921
2922    /// One indent level.
2923    ///
2924    /// Two spaces, not the four both frontends type for Tab today, because in a
2925    /// markdown document four columns isn't a width — it's a *meaning*. Four
2926    /// spaces at the head of a line is markdown's indented-code-block marker, so
2927    /// one Tab on a paragraph would reparse it into code and style it as such;
2928    /// two cannot, and the line stays the prose it was. Two is also exactly
2929    /// where a `- ` bullet's content starts, so an indented line lands under its
2930    /// parent item's text instead of beside it — the column a list-aware indent
2931    /// has to hit anyway, which keeps this width from being relitigated later.
2932    const INDENT: &'static str = "  ";
2933
2934    /// Indent the selected lines — or the caret's line, with no selection — by
2935    /// one level (Tab).
2936    pub fn indent(&mut self) {
2937        self.reindent(true);
2938        // Nesting changes an ordered list's numbering (the nested item restarts,
2939        // its old siblings resume) — keep the source markers in step.
2940        self.renumber_here();
2941        // Nesting an empty `-` item under a text line reparses that text as a
2942        // setext heading; swap the dash for a `*` before it can (a no-op unless
2943        // the collapse actually happened).
2944        self.avoid_setext_collapse();
2945    }
2946
2947    /// Take one indent level back off the selected lines, or the caret's line
2948    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2949    ///
2950    /// A line with *less* than a full level gives back what it has rather than
2951    /// refusing: outdent's job is to walk a line left, and real documents — hand
2952    /// written, or reflowed by some other editor — are full of indentation that
2953    /// was never a clean multiple of anything. Refusing there would strand the
2954    /// line at a depth Shift+Tab couldn't undo.
2955    pub fn outdent(&mut self) {
2956        self.reindent(false);
2957        self.renumber_here();
2958    }
2959
2960    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2961    ///
2962    /// One splice across the whole line range, never one per line: a Tab is one
2963    /// thing the user did, so it has to be one undo step and one reparse. Per
2964    /// line, twig would reparse the document once per line and leave a stack of
2965    /// steps that Shift+⌘Z walks back one line at a time.
2966    fn reindent(&mut self, add: bool) {
2967        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2968        let start = source_line_range(&self.source, sel_start).start;
2969        let end = source_line_range(&self.source, sel_end).end;
2970        let region = self.source[start..end].to_string();
2971        let lines: Vec<&str> = region.split('\n').collect();
2972        // A blank line has no text to move, and padding it would leave nothing
2973        // but trailing whitespace — but Tab on a blank line *is* a request for
2974        // indentation to type into, so the skip only applies where the op has
2975        // other lines to do real work on.
2976        let skip_blank = add && lines.len() > 1;
2977
2978        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2979        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2980        let mut line_off = start;
2981        for (i, full) in lines.iter().enumerate() {
2982            if i > 0 {
2983                out.push('\n');
2984            }
2985            // A list item moves by having its whole leading prefix *replaced*,
2986            // never by having spaces pushed in front of the line. twig spells
2987            // both prefixes, so the quote markers, the parent's indent and an
2988            // ordered marker's extra column all come out right without leaf
2989            // measuring any of them — and a line that only looks like an item
2990            // (a Djot continuation) reports no marker and is left to the plain
2991            // path, where a Tab is just a Tab.
2992            let marker = self.list_marker_on_line(line_off);
2993            let own = marker
2994                .as_ref()
2995                .map(|m| m.marker_start - m.line_start)
2996                .unwrap_or(0);
2997            let delta = if add {
2998                if skip_blank && full.trim().is_empty() {
2999                    out.push_str(full);
3000                    0
3001                } else if marker.is_some() && self.first_item_of_list(line_off) {
3002                    // The first item of a list has no preceding sibling to nest
3003                    // under, so a Tab here can't spell a sub-list — twig would
3004                    // reparse the shoved-over marker as the same list, only
3005                    // indented, which Shift+Tab then can't cleanly undo. Leave the
3006                    // item where it is, the way every list editor refuses to
3007                    // over-indent a list's first line.
3008                    out.push_str(full);
3009                    0
3010                } else if marker.is_some() {
3011                    // Nesting means standing where a *continuation* of this line
3012                    // would stand: past the parent's marker, inside its content
3013                    // column. That is `continuation_prefix`, less a checkbox.
3014                    let new = self.nesting_prefix_at(line_off);
3015                    let delta = new.len() as isize - own as isize;
3016                    out.push_str(&new);
3017                    out.push_str(&full[own..]);
3018                    delta
3019                } else {
3020                    out.push_str(Self::INDENT);
3021                    out.push_str(full);
3022                    Self::INDENT.len() as isize
3023                }
3024            } else if marker.is_some() {
3025                // Unnesting is the mirror: stand where the parent item's own
3026                // line starts, which drops exactly the level it contributed.
3027                let new = self.outdent_prefix_at(line_off);
3028                let delta = new.len() as isize - own as isize;
3029                out.push_str(&new);
3030                out.push_str(&full[own..]);
3031                delta
3032            } else {
3033                // A plain line gives back the ordinary step.
3034                let strip = outdent_width(full, Self::INDENT.len());
3035                out.push_str(&full[strip..]);
3036                -(strip as isize)
3037            };
3038            deltas.push(delta);
3039            line_off += full.len() + 1;
3040        }
3041        // Nothing to give back. Returning before the splice keeps an outdent at
3042        // column zero from spending an undo step on a document it never changed.
3043        if deltas.iter().all(|d| *d == 0) {
3044            return;
3045        }
3046
3047        // Every line's text keeps its offset *within the line*, so the caret is
3048        // remapped by its column, not by its byte offset — which the prefixes on
3049        // the lines above it have already invalidated.
3050        let remap = |off: usize| -> usize {
3051            let (mut old_ls, mut new_ls) = (start, start);
3052            for (line, delta) in lines.iter().zip(&deltas) {
3053                let old_le = old_ls + line.len();
3054                let new_len = (line.len() as isize + delta) as usize;
3055                if off <= old_le {
3056                    let col = (off - old_ls) as isize;
3057                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
3058                }
3059                old_ls = old_le + 1;
3060                new_ls += new_len + 1;
3061            }
3062            start + out.len()
3063        };
3064        let placed = match self.selection() {
3065            // Keep the rewritten region selected, the way a container toggle
3066            // keeps its own: it leaves a second Tab aimed at the same lines
3067            // rather than at whatever the shifted offsets now happen to cover.
3068            Some(_) => (start + out.len(), Some(start)),
3069            None => (remap(self.caret), None),
3070        };
3071
3072        // A rolled-back splice leaves the old source in place, where every offset
3073        // computed above addresses text that was never written.
3074        if !self.splice(start, end, &out, EditKind::Other) {
3075            return;
3076        }
3077        // `splice` re-anchors to the end of the `Change`, which for a whole-region
3078        // rewrite is the last line's end — nowhere the caret was. Place it, then
3079        // re-record the caret so this is the state redo restores, not the one
3080        // `splice` left behind from the `Change`.
3081        self.caret = placed.0.min(self.source.len());
3082        self.anchor = placed.1;
3083        self.clamp_caret();
3084        self.record_caret();
3085    }
3086
3087    /// The Enter key.
3088    ///
3089    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
3090    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
3091    /// caret is in decides what actually gets written.
3092    ///
3093    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
3094    ///                            block at the caret and reopens its container
3095    ///   - list item            → likewise: the next item, its indent, quote
3096    ///                            prefix and `[ ]` box all reproduced by twig —
3097    ///                            except an *empty* item, which exits the list
3098    ///   - block quote          → likewise: a new paragraph inside the quote
3099    ///   - heading              → a new *paragraph*, not another heading
3100    ///   - code block           → a literal newline (stay in the block)
3101    ///   - thematic break       → the line under the rule, opened if it has
3102    ///                            none ([`stand_under_block`](Self::stand_under_block))
3103    ///   - blank line           → a literal newline (one Backspace undoes it)
3104    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
3105    ///                            visible line
3106    ///
3107    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
3108    /// and it is better at it: it drops the whitespace the caret was sitting in
3109    /// front of instead of stranding it at the head of the second half, and it
3110    /// knows continuations leaf's marker scan never covered — a checklist item
3111    /// continues as an *unchecked* checklist item rather than a plain bullet.
3112    ///
3113    /// The exceptions above are exceptions because `split_block` is either wrong
3114    /// there or refuses: parting a fence yields two fences with the code split
3115    /// between them, parting a heading yields a second heading where every editor
3116    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
3117    /// table all report an error rather than a split.
3118    pub fn newline(&mut self) {
3119        if self.view == View::Source {
3120            self.insert_raw("\n");
3121            return;
3122        }
3123        // Enter over a selection replaces it with a paragraph break.
3124        if let Some((s, e)) = self.selection() {
3125            self.splice(s, e, "\n\n", EditKind::Other);
3126            return;
3127        }
3128        // On a blank page there is no block to push down, and a blank line
3129        // with nothing under it is not something Fold flow draws. Enter wrote
3130        // one anyway: an edit, and an undo step, that showed nothing.
3131        if self.line_flow == LineFlow::Fold && self.source[self.caret_floor()..].trim().is_empty() {
3132            return;
3133        }
3134        // A caret resting exactly between an inline mark's content and its own
3135        // closing delimiter (`**bold**` with nothing after it on the line —
3136        // the WYSIWYG caret's natural end-of-line position) must not splice a
3137        // block break there: every path below eventually does via
3138        // `insert_raw`/`self.caret`, and splicing before the hidden closing
3139        // delimiter would strand it alone on the new line.
3140        self.caret = self.skip_trailing_close_delims(self.caret);
3141        // The block the caret is in. `block_offset_for_caret` nudges off a line
3142        // end (where the caret sits at the doc level); on a bare line (e.g. an
3143        // empty list item) fall back to the caret so the enclosing list/quote is
3144        // still visible in the ancestors.
3145        let off = self.block_offset_for_caret().unwrap_or(self.caret);
3146        let kinds: Vec<Kind> = self
3147            .editor
3148            .ancestors_at(off)
3149            .map(|c| c.into_iter().map(|m| m.kind).collect())
3150            .unwrap_or_default();
3151        let has = |k: Kind| kinds.contains(&k);
3152
3153        if has(Kind::CodeBlock) {
3154            self.insert_raw("\n");
3155            return;
3156        }
3157        // An *empty* list item exits the list — the standard double-Enter — which
3158        // `split_block` reports as an error rather than a split (there is no
3159        // content to part), so it stays leaf's. `list_marker_on_line` is itself
3160        // the AST gate — it answers from the tree, so a `- ` that reads as a
3161        // marker byte-for-byte but opens no item (a setext underline, a Djot
3162        // continuation line) never reaches here.
3163        if let Some(marker) = self.list_marker_on_line(self.caret)
3164            && self.item_is_empty(&marker)
3165        {
3166            self.exit_list(&marker);
3167            return;
3168        }
3169        // Beside a rule, at the home past it, the caret's source line is the
3170        // blank one under the rule, which the branch below would take for an
3171        // empty paragraph. Mid-document its lone newline left the caret on the
3172        // next block's first line, and what was typed there joined that block.
3173        if let Some(end) = self.rule_above_caret() {
3174            self.stand_under_block(end);
3175            return;
3176        }
3177        // On an *empty* paragraph line, a lone Enter should add a single blank line,
3178        // not another full paragraph break — so it moves down one line and one
3179        // Backspace undoes it, not two. (`split_block` errors here too.)
3180        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3181        let line_end = self.source[self.caret..]
3182            .find('\n')
3183            .map_or(self.source.len(), |i| self.caret + i);
3184        if self.source[line_start..line_end].trim().is_empty() {
3185            self.insert_raw("\n");
3186            return;
3187        }
3188        // At the start of a paragraph with a block above it, twig's split
3189        // writes a lone newline ahead of the paragraph, which Fold draws as one
3190        // more gap: the first Enter showed nothing. A heading's branch below
3191        // parted it after its `#`, leaving an empty heading over a paragraph
3192        // that had been the heading's text. Both open an empty paragraph above
3193        // instead, the caret staying with the text. In Preserve flow too: its
3194        // lone newline went inside a div, where nothing drew it, and between
3195        // a mark's delimiters, where it cut the mark.
3196        if let Some((at, text)) = self.paragraph_opening_at_caret() {
3197            let caret = self.caret;
3198            if self.splice(at, at, text, EditKind::Other) {
3199                self.caret = caret + text.len();
3200                self.record_caret();
3201            }
3202            return;
3203        }
3204        // In `Preserve` flow a soft break is a *visible* line the author means to
3205        // make, so Enter writes a single `\n` and typing continues the same
3206        // paragraph on the next line — the behaviour of an ordinary text editor.
3207        // A second Enter then lands on the blank line above and takes the
3208        // empty-line branch, so double-Enter still promotes to a full paragraph
3209        // break; and Backspace, which deletes a lone `\n` over a soft break,
3210        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
3211        // render as an invisible space, so Enter keeps making the paragraph break
3212        // that actually shows.
3213        //
3214        // Only in running prose. A list or a quote has a continuation of its own
3215        // to write, and a `\n` there is not a soft line but a lost container.
3216        // Nor in a heading, which a newline ends in Markdown and runs on
3217        // unseen in djot: it takes the heading's own branch below.
3218        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
3219        if self.line_flow == LineFlow::Preserve && !in_container && !has(Kind::Heading) {
3220            self.insert_raw("\n");
3221            return;
3222        }
3223        // A heading gets a *paragraph*, never a second heading: Enter at the end
3224        // of a title is how every editor is asked for the body under it, and
3225        // `split_block` would repeat the `#` instead. Whitespace at the split
3226        // point goes with the break rather than opening the new paragraph, which
3227        // is what `split_block` does everywhere else.
3228        if has(Kind::Heading) {
3229            let mut end = self.caret;
3230            while self.source.as_bytes().get(end) == Some(&b' ') {
3231                end += 1;
3232            }
3233            self.splice(self.caret, end, "\n\n", EditKind::Other);
3234            return;
3235        }
3236        self.split_block_here();
3237    }
3238
3239    /// Where Enter writes to open an empty paragraph above the paragraph or
3240    /// heading the caret starts, and what — `None` when the caret does not
3241    /// start one.
3242    ///
3243    /// The caret starts a paragraph that is in no list item, quote, fence or
3244    /// table when no text of the paragraph stands before it: at its first
3245    /// byte, or past only the opening delimiters of marks that begin with it
3246    /// (`**`, a colour's `<span …>`), which is where a tap before the first
3247    /// letter lands. A heading's `#` marker is such a delimiter. Parted there, twig's split cut the mark in two. The break
3248    /// goes in front of those delimiters, and in front of any `<div>` or
3249    /// fenced div the paragraph opens — the view draws no row for a blank line
3250    /// above a container's first block, so a centred paragraph's empty line
3251    /// has to open outside its div.
3252    ///
3253    /// Inside a list twig's split already writes a marked line, which draws,
3254    /// and so it does in a quote, except above the quote's first line.
3255    ///
3256    /// What is written is what it takes to draw one more empty row. Between
3257    /// two blocks the first and last blank lines are the gap, so a paragraph
3258    /// break goes where there are fewer than two and a newline where the gap
3259    /// is already there. Above the first block only the last blank line is
3260    /// the gap, so a newline goes where there is one already. Preserve flow
3261    /// draws every blank line, so a newline is always enough there, but under
3262    /// frontmatter, whose own blank line draws nothing.
3263    fn paragraph_opening_at_caret(&mut self) -> Option<(usize, &'static str)> {
3264        let caret = self.caret.min(self.source.len());
3265        let chain = self.editor.ancestors_at(caret).ok()?;
3266        if chain.iter().any(|m| {
3267            matches!(
3268                m.kind,
3269                Kind::ListItem | Kind::TaskListItem | Kind::CodeBlock | Kind::Table
3270            )
3271        }) {
3272            return None;
3273        }
3274        let para = chain
3275            .iter()
3276            .find(|m| matches!(m.kind, Kind::Para | Kind::Heading))?;
3277        // An empty heading has nothing to push down: Enter there is still
3278        // the body under it.
3279        let end = para.span.end.min(self.source.len());
3280        if self.source[caret.min(end)..end].trim().is_empty() {
3281            return None;
3282        }
3283        let mut at = para.span.start;
3284        if at != caret {
3285            let text_before = self
3286                .editor
3287                .subtree(twig::NodeId(para.node_id))
3288                .ok()?
3289                .iter()
3290                .any(|n| {
3291                    n.span.start < caret
3292                        && (n.kind == Kind::Image
3293                            || n.text.as_deref().is_some_and(|t| !t.trim().is_empty()))
3294                });
3295            if text_before {
3296                return None;
3297            }
3298        }
3299        // Out past each container this paragraph opens, innermost first: one
3300        // whose source before it is its opening line and blank lines. A quote
3301        // opens on its first paragraph's own line, and draws no line above
3302        // that paragraph: the break goes above the quote. Further down a
3303        // quote twig's split writes a quoted line, which draws.
3304        let mut outer: Vec<_> = chain
3305            .iter()
3306            .filter(|m| matches!(m.kind, Kind::Container | Kind::BlockQuote) && m.span.start < at)
3307            .collect();
3308        outer.sort_by_key(|m| std::cmp::Reverse(m.span.start));
3309        for c in outer {
3310            let before = &self.source[c.span.start..at];
3311            let opens = match c.kind {
3312                Kind::BlockQuote => !before.contains('\n'),
3313                _ => before.lines().skip(1).all(|l| l.trim().is_empty()),
3314            };
3315            if !opens {
3316                break;
3317            }
3318            at = c.span.start;
3319        }
3320        if chain
3321            .iter()
3322            .any(|m| m.kind == Kind::BlockQuote && m.span.start < at)
3323        {
3324            return None;
3325        }
3326        let content = self.source[..at].trim_end().len();
3327        let newlines = self.source[content..at].matches('\n').count();
3328        // Every newline starts a blank line but one that ends the line of
3329        // what stands above, a block or hidden frontmatter.
3330        let blank = if content == 0 {
3331            newlines
3332        } else {
3333            newlines.saturating_sub(1)
3334        };
3335        let first = self
3336            .top_blocks()
3337            .iter()
3338            .all(|m| m.kind == Kind::Metadata || m.span.start >= at);
3339        // Preserve flow draws every blank line, but the one under frontmatter.
3340        let gap = match (self.line_flow, first) {
3341            (LineFlow::Preserve, true) => usize::from(content > 0),
3342            (LineFlow::Preserve, false) => 0,
3343            (LineFlow::Fold, true) => 1,
3344            (LineFlow::Fold, false) => 2,
3345        };
3346        Some((at, if blank >= gap { "\n" } else { "\n\n" }))
3347    }
3348
3349    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
3350    /// the caret in the second half.
3351    ///
3352    /// twig reopens whatever the first half was inside of — the bullet with its
3353    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
3354    /// reason this replaced the markup leaf used to spell from the line's bytes.
3355    /// It renumbers nothing, though: a new item mid-list is written with its
3356    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
3357    /// behind it, folded into the same undo step.
3358    ///
3359    /// Falls back to a plain paragraph break if twig declines, so an unhandled
3360    /// shape still moves the caret down rather than swallowing the keystroke.
3361    fn split_block_here(&mut self) {
3362        // The read-only gate — this door reaches twig without the splice.
3363        if self.read_only {
3364            return;
3365        }
3366        match self.editor.split_block(self.caret) {
3367            Ok(change) => {
3368                self.last_edit_kind = None;
3369                self.refresh();
3370                self.anchor = None;
3371                self.caret = change.new.end;
3372                self.dirty = self.source != self.clean_source;
3373                self.status = None;
3374                self.clamp_caret();
3375                self.record_caret();
3376                // Aimed at the new block's *start*: the caret twig leaves is one
3377                // past the marker it wrote, where there is no list in reach.
3378                self.renumber_at(change.new.start);
3379            }
3380            Err(_) => self.insert_raw("\n\n"),
3381        }
3382    }
3383
3384    /// Whether the item on the marker's line carries no content — the shape
3385    /// double-Enter reads as "I'm done with this list."
3386    fn item_is_empty(&self, line: &ListMarker) -> bool {
3387        let content_start = line.content_start().min(self.source.len());
3388        let line_end = self.source[self.caret..]
3389            .find('\n')
3390            .map(|i| self.caret + i)
3391            .unwrap_or(self.source.len());
3392        self.source[content_start..line_end.max(content_start)]
3393            .trim()
3394            .is_empty()
3395    }
3396
3397    /// Leave the list: replace the empty item's marker with a blank line, so the
3398    /// caret lands in a fresh paragraph below it.
3399    ///
3400    /// Inside a quote the blank line has to stay quoted (a bare one would end the
3401    /// quote), and the caret's new line keeps the `> ` it was already behind —
3402    /// leaving the list without also leaving the quote.
3403    fn exit_list(&mut self, line: &ListMarker) {
3404        let prefix = self.quote_prefix_at(line.marker_start);
3405        let blank = prefix.trim_end();
3406        self.splice(
3407            line.line_start,
3408            self.caret,
3409            &format!("{blank}\n{prefix}"),
3410            EditKind::Other,
3411        );
3412    }
3413
3414    /// What a line continuing the containers at `off` has to open with — the
3415    /// quote markers reproduced, each enclosing item's marker as its width in
3416    /// spaces. Also the column a nested item's marker stands in, which is what
3417    /// makes it Tab's answer.
3418    fn continuation_prefix_at(&mut self, off: usize) -> String {
3419        self.editor
3420            .document()
3421            .and_then(|mut d| d.continuation_prefix(off))
3422            .map(|p| p.text)
3423            .unwrap_or_default()
3424    }
3425
3426    /// The column a *nested list* may open at inside the item at `off` — which
3427    /// is not always where the item's own text continues.
3428    ///
3429    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
3430    /// it is markup a rich view hides, and the item's own wrapped text does
3431    /// stand past it. But a nested list may only open at the *list* marker's
3432    /// column, and four columns further in is an indented continuation of the
3433    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
3434    /// So the box's own width goes back.
3435    ///
3436    /// The one place leaf still reads a checkbox's spelling. It goes when twig
3437    /// reports the list marker's column apart from the box; `checked` is what
3438    /// says a box is there at all, so only its width is being measured here.
3439    fn nesting_prefix_at(&mut self, off: usize) -> String {
3440        let cont = self.continuation_prefix_at(off);
3441        let Some(item) = self.innermost_list_item(off) else {
3442            return cont;
3443        };
3444        if item.checked.is_none() {
3445            return cont;
3446        }
3447        let box_width = item
3448            .marker_span
3449            .and_then(|m| self.source.get(m))
3450            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
3451            .unwrap_or(0);
3452        // The trailing columns are the ones the item's own marker contributed,
3453        // so trimming from the end leaves any quote prefix standing.
3454        cont[..cont.len().saturating_sub(box_width)].to_string()
3455    }
3456
3457    /// Where the line of the item *containing* the item at `off` begins — the
3458    /// prefix Shift+Tab moves back to, which gives up exactly the level the
3459    /// parent contributed. The quote prefix alone for a top-level item, which
3460    /// has no level left to give.
3461    fn outdent_prefix_at(&mut self, off: usize) -> String {
3462        let items: Vec<usize> = self
3463            .editor
3464            .document()
3465            .and_then(|mut d| d.ancestors_at_caret(off))
3466            .map(|c| {
3467                c.into_iter()
3468                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3469                    .map(|m| m.span.start)
3470                    .collect()
3471            })
3472            .unwrap_or_default();
3473        // The second-innermost item is the parent; its own line's indent is the
3474        // target. `list_marker_on_line` gives that line's prefix directly.
3475        let parent = items.len().checked_sub(2).map(|i| items[i]);
3476        match parent.and_then(|p| self.list_marker_on_line(p)) {
3477            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
3478            None => self.quote_prefix_at(off),
3479        }
3480    }
3481
3482    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
3483    /// inside one, `"> > "` inside two.
3484    ///
3485    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
3486    /// the `>` and the space after it are twig's spelling rather than leaf's.
3487    /// The whole line prefix can't answer this: it also carries the indent of
3488    /// whatever the quote holds, which a blank separator line must *not* repeat.
3489    fn quote_prefix_at(&mut self, off: usize) -> String {
3490        let Ok(chain) = self
3491            .editor
3492            .document()
3493            .and_then(|mut d| d.ancestors_at_caret(off))
3494        else {
3495            return String::new();
3496        };
3497        let quotes: Vec<usize> = chain
3498            .iter()
3499            .filter(|m| m.kind == Kind::BlockQuote)
3500            .map(|m| m.node_id as usize)
3501            .collect();
3502        let Ok(nodes) = self.editor.nodes() else {
3503            return String::new();
3504        };
3505        quotes
3506            .iter()
3507            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
3508            .filter_map(|s| self.source.get(s))
3509            .collect()
3510    }
3511
3512    /// Whether the item at `off` sits inside another one — the test Backspace
3513    /// uses to choose between outdenting and dropping the marker.
3514    ///
3515    /// Counted from the AST rather than from the line's leading whitespace,
3516    /// which is indentation in Markdown and, in Djot, may be nothing at all.
3517    fn item_is_nested(&mut self, off: usize) -> bool {
3518        self.editor
3519            .document()
3520            .and_then(|mut d| d.ancestors_at_caret(off))
3521            .map(|c| {
3522                c.into_iter()
3523                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3524                    .count()
3525                    > 1
3526            })
3527            .unwrap_or(false)
3528    }
3529
3530    /// The innermost list item containing `probe`, under twig's **caret**
3531    /// containment rule — a block's end is inside it.
3532    ///
3533    /// Half-open containment can't answer this. An empty item's span is exactly
3534    /// its marker, so the caret sitting after `- ` is one past the end and the
3535    /// item it is plainly in tests as out of reach; that is the shape
3536    /// double-Enter has to recognise to leave the list.
3537    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3538        let chain = self
3539            .editor
3540            .document()
3541            .and_then(|mut d| d.ancestors_at_caret(probe))
3542            .ok()?;
3543        let id = chain
3544            .iter()
3545            .rev()
3546            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3547            .node_id as usize;
3548        self.editor.nodes().ok()?.get(id).cloned()
3549    }
3550
3551    /// The list marker opening `off`'s line, per twig — `None` when that line
3552    /// opens no list item.
3553    ///
3554    /// [`Document::line_prefix`] is the whole hidden run from the line start:
3555    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
3556    /// and it is `None` on a *continuation* line, which opens nothing. That last
3557    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
3558    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3559    /// a paragraph and `  - b` is literal text — identical bytes, and only the
3560    /// parser knows which document it is looking at.
3561    ///
3562    /// The item's own marker is separated out via its
3563    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3564    /// the containers around it contribute and what the item does.
3565    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3566        let off = off.min(self.source.len());
3567        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3568        // The prefix belongs to a list only when an item's marker closes it —
3569        // a heading's `# ` or a bare quote's `> ` is a prefix too.
3570        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3571        let marker = item.marker_span.clone()?;
3572        if marker.end != prefix.end {
3573            return None;
3574        }
3575        Some(ListMarker {
3576            line_start: prefix.start,
3577            marker_start: marker.start,
3578            text: self.source.get(prefix)?.to_string(),
3579        })
3580    }
3581
3582    /// Whether the list item on `line_start`'s line is the **first item** of its
3583    /// list — the one Tab must not nest, because nesting needs a preceding
3584    /// sibling to become the new parent and a first item has none. `false` for a
3585    /// line that isn't a list item, and for an item with a sibling above it (the
3586    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3587    /// the same in a setext underline that opens no list at all.
3588    fn first_item_of_list(&mut self, line_start: usize) -> bool {
3589        let Some(marker) = self.list_marker_on_line(line_start) else {
3590            return false;
3591        };
3592        // Probe just inside the marker, where the item's own node is in reach —
3593        // the marker offset itself can resolve to the enclosing list, not the
3594        // `list_item`, whose span starts at the marker.
3595        let probe = marker.content_start().min(self.source.len());
3596        let Some(item) = self.innermost_list_item(probe) else {
3597            return false;
3598        };
3599        let Ok(nodes) = self.editor.nodes() else {
3600            return false;
3601        };
3602        match item.parent {
3603            // First when the parent list opens with this very item.
3604            Some(pid) => nodes
3605                .get(pid.0 as usize)
3606                .is_some_and(|p| p.first_child == Some(item.id)),
3607            // A parentless item is trivially the first (and only) one.
3608            None => true,
3609        }
3610    }
3611
3612    pub fn backspace(&mut self) {
3613        if let Some((s, e)) = self.selection() {
3614            self.splice(s, e, "", EditKind::Other);
3615            return;
3616        }
3617        // WYSIWYG: Backspace at the very start of a list item's content is a
3618        // structural key, not a character delete — it walks the "un-indent, then
3619        // un-list" ladder every list editor gives that keystroke (outdent a
3620        // nested item, strip a top-level one's marker to a paragraph). In source
3621        // view the `- ` is visible text the user is deleting a byte of, so it
3622        // keeps its literal meaning there, like Enter does.
3623        if self.view != View::Source && self.backspace_list_start() {
3624            return;
3625        }
3626        // WYSIWYG: and the same at the start of a heading's content — the `# `
3627        // there is markup the rich view hides, not text the user typed.
3628        if self.view != View::Source && self.backspace_heading_start() {
3629            return;
3630        }
3631        // WYSIWYG: and at the start of a block whose presentation is spelled
3632        // as hidden markup before it — djot's `{.center}` line, Markdown's
3633        // `<div class="center">` — Backspace takes that markup, the way it
3634        // takes a heading's `#`, rather than a byte out of it.
3635        if self.view != View::Source && self.backspace_attributed_block_start() {
3636            return;
3637        }
3638        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3639        // the markup apart under a caret that cannot see it — see
3640        // `delete_around_block_atom`.
3641        if self.view != View::Source && self.delete_around_block_atom(false) {
3642            return;
3643        }
3644        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3645        // cell rather than taking the byte behind the caret — the row's closing
3646        // `|`, which the rich view never drew, so the key would have looked like
3647        // it did nothing. The stop before is the last cell's end.
3648        if self.view != View::Source && self.backspace_at_table_end() {
3649            return;
3650        }
3651        // WYSIWYG: a table cell's start is a wall. The bytes behind it are the
3652        // padding and the `|` that make the grid, and taking them merges two
3653        // cells — a structural edit nobody asked for, and one no table editor
3654        // gives the key. Tab and Shift+Tab are how the caret leaves a cell.
3655        if self.view != View::Source && self.at_cell_wall(BreakEdge::Backward) {
3656            return;
3657        }
3658        // WYSIWYG: at the start of a block's content, the byte behind the caret
3659        // is a block boundary, and Backspace over one is a join — twig's, so
3660        // that what a join is in each format is not this file's to know. After
3661        // the picture and table cases, which are block starts with their own
3662        // answers.
3663        if self.view != View::Source && self.backspace_joins_block() {
3664            return;
3665        }
3666        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3667        // stop, not a single newline. On a line with no text of its own, the byte
3668        // before the caret is a `\n` that spells part of a block boundary — the gap
3669        // between two blocks, drawn but never a caret home. Removing just it strands
3670        // the caret in that gap and leaves an odd blank line the eye reads as one
3671        // separator but the caret can't land on: the "extra newline" left behind
3672        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3673        // Deleting to the previous stop instead collapses the whole break at once,
3674        // landing the caret at the end of the block above. Two blank lines in a row
3675        // are one stop apart, so this still removes exactly one — the lone-Enter /
3676        // lone-Backspace symmetry the empty-line case is built on is untouched.
3677        if self.view != View::Source
3678            && self.caret > self.caret_floor()
3679            && self.caret_on_blank_line()
3680            && let Some(stop) = self.vmap.stop_before(self.caret)
3681        {
3682            let stop = stop.max(self.caret_floor());
3683            if stop < self.caret {
3684                if self.source[stop..self.caret].trim().is_empty() {
3685                    self.splice(stop, self.caret, "", EditKind::Delete);
3686                } else {
3687                    // Hidden markup stands between the stop and the caret — a
3688                    // `</div>`, a comment, a link reference definition — and
3689                    // collapsing to the stop would delete it. Take the blank
3690                    // line alone, with the newline that opened it, and land
3691                    // the caret where the collapse would have.
3692                    self.delete_blank_line_to(stop);
3693                }
3694                return;
3695            }
3696        }
3697        if self.caret > self.caret_floor() {
3698            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3699            // takes the whole tag — a single-byte step would leave a broken `<br`
3700            // showing in the cell. Rich view only (source view edits the literal).
3701            if self.view != View::Source
3702                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3703            {
3704                let start = start.max(self.caret_floor());
3705                if start < end {
3706                    self.splice(start, end, "", EditKind::Delete);
3707                    return;
3708                }
3709            }
3710            // Aim the delete at the character the writer can *see* behind the
3711            // caret, never at a delimiter the rich view drew nothing for. Two
3712            // steps, and either can apply: from the far side of a run's closing
3713            // `**` step back into the run (the caret is drawn at the end of its
3714            // word), and at the start of a run's text step out past its opening
3715            // `**` to the character in front of it, leaving the run standing.
3716            // Without them a plain Backspace unspells the phrase it is editing
3717            // and leaves a literal asterisk on screen.
3718            let end = if self.view == View::Source {
3719                self.caret
3720            } else {
3721                let inside = self.step_inside_close_delims(self.caret);
3722                // An attributed span with no text — `<span …></span>` as the
3723                // file was written — is hidden markup around nothing, and a
3724                // byte-step here would take its `>`. Backspace takes the span
3725                // whole, with the character before it: the character the key
3726                // looks aimed at, since the span draws nothing.
3727                if let Some(span) = self.run_span_of_content(inside..inside) {
3728                    let from = if self.source[..span.start].ends_with('\n') {
3729                        span.start
3730                    } else {
3731                        prev_boundary(&self.source, span.start)
3732                    };
3733                    let from = from.max(self.caret_floor());
3734                    self.splice(from, span.end, "", EditKind::Delete);
3735                    return;
3736                }
3737                self.skip_leading_open_delims(inside)
3738                    .max(self.caret_floor())
3739            };
3740            // Never delete back across the floor — that would eat hidden
3741            // frontmatter the WYSIWYG caret can't even see.
3742            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3743            // Take a hidden escape backslash with the char it escapes: the rich
3744            // view draws `\*` as a single `*`, so Backspace over it must delete
3745            // both bytes, never strand the `\` as a lone visible backslash (the
3746            // mirror of the Hidden-mode typing that wrote the escape). Source view
3747            // shows the `\`, so there it is an ordinary character.
3748            if self.view != View::Source
3749                && prev > self.caret_floor()
3750                && self.is_hidden_escape(prev - 1)
3751            {
3752                prev -= 1;
3753            }
3754            // The delete that takes the last of a span's text takes the span
3755            // with it, in the same edit: `<span …>i</span>` losing its `i`
3756            // would leave an empty span the map has no stop inside, so the
3757            // caret would draw at the next stop — a line away — until a
3758            // further key removed the span. Landing on the span's start is
3759            // where the letter was.
3760            if self.view != View::Source
3761                && let Some(span) = self.run_span_of_content(prev..end)
3762            {
3763                self.splice(span.start, span.end, "", EditKind::Delete);
3764                return;
3765            }
3766            // And the same for a block: the letter that was all of a centred
3767            // paragraph's text goes with the `<div>` around it, or the `{…}`
3768            // line above it, leaving a plain blank line where the letter was.
3769            // A paragraph with no text is no block, so the markup would stand
3770            // around nothing, the map would give it no caret home, and the
3771            // next key would take the tag apart.
3772            if self.view != View::Source
3773                && let Some(block) = self.attributed_block_of_content(prev..end)
3774            {
3775                self.splice(block.start, block.end, "", EditKind::Delete);
3776                return;
3777            }
3778            if prev < end {
3779                self.splice(prev, end, "", EditKind::Delete);
3780            }
3781        }
3782    }
3783
3784    /// Remove the blank line the caret is on — its own newline and the one
3785    /// that ended the line before it — and put the caret on `stop`, the caret
3786    /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3787    /// blank line that hidden markup separates from the block above: the
3788    /// navigable blank row after a `</div>` is always one of at least three
3789    /// newlines under the tag (the drawn separators either side of it), so
3790    /// taking two leaves the blank line the tag needs under it.
3791    fn delete_blank_line_to(&mut self, stop: usize) {
3792        let caret = self.caret;
3793        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3794        let line_end = self.source[caret..]
3795            .find('\n')
3796            .map_or(self.source.len(), |i| caret + i);
3797        let from = line_start.saturating_sub(1).max(stop);
3798        let to = (line_end + 1).min(self.source.len());
3799        self.splice(from, to, "", EditKind::Delete);
3800        self.caret = stop;
3801        self.anchor = None;
3802        self.goal_col = None;
3803        self.record_caret();
3804    }
3805
3806    /// Move the caret to the stop before it and consume the key — what
3807    /// Backspace does where the byte behind the caret is hidden markup it
3808    /// has no structural answer for, rather than take that markup apart.
3809    fn step_back_to_stop(&mut self) {
3810        // The map answers about offsets, so it has to be this revision's — see
3811        // `open_paragraph_at_block_edge`.
3812        self.rebuild_map();
3813        if let Some(off) = self
3814            .vmap
3815            .stop_before(self.caret)
3816            .filter(|&o| o >= self.caret_floor())
3817        {
3818            self.caret = off;
3819            self.anchor = None;
3820            self.goal_col = None;
3821        }
3822    }
3823
3824    /// Backspace's presentation behaviour: with the caret exactly at the start
3825    /// of a block's content, and that block's attributes spelled as hidden
3826    /// markup before it, strip the attributes. The peer of
3827    /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3828    /// reasoning: the `{.center}` line above a djot block and the
3829    /// `<div class="center">` around a Markdown one are what the byte behind
3830    /// the caret belongs to, and the rich view draws neither. The ordinary
3831    /// delete took the newline out of `{.center}\nhello` and left
3832    /// `{.center}hello` — the attribute line fused onto the text as prose —
3833    /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3834    ///
3835    /// The whole attribute set goes, the way the whole `#` marker does — the
3836    /// press is over the line that spells it, not over one key of it — and
3837    /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3838    /// djot line and unwraps the Markdown div. Where the block is the first of
3839    /// several in a div, twig has no sole child to unwrap and answers with a
3840    /// no-op, so the caret steps back to the stop before instead, as it does
3841    /// at a table's end. A later child of the div has an ordinary paragraph
3842    /// above it and is not this rule's.
3843    ///
3844    /// Returns whether it acted; `false` leaves Backspace its character delete.
3845    fn backspace_attributed_block_start(&mut self) -> bool {
3846        if !matches!(self.format, Format::Markdown | Format::Djot) {
3847            return false;
3848        }
3849        let caret = self.caret;
3850        let nodes = self.nodes();
3851        let Some(block) = nodes
3852            .iter()
3853            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3854            .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3855        else {
3856            return false;
3857        };
3858        match self.format {
3859            Format::Djot => {
3860                // twig records where the `{…}` block was written, so this is
3861                // the parser's own answer and not a scan for a `{` above the
3862                // block; `None` (a synthesized or merged set) is not a line
3863                // the caret is standing after.
3864                let spelled = self
3865                    .editor
3866                    .document()
3867                    .ok()
3868                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3869                    .is_some_and(|s| s.end <= caret);
3870                if !spelled {
3871                    return false;
3872                }
3873            }
3874            _ => {
3875                let Some(div) = block
3876                    .parent
3877                    .and_then(|p| nodes.iter().find(|n| n.id == p))
3878                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3879                else {
3880                    return false;
3881                };
3882                let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3883                if kids.clone().any(|k| k.span.start < block.span.start) {
3884                    return false;
3885                }
3886                if kids.nth(1).is_some() {
3887                    self.step_back_to_stop();
3888                    return true;
3889                }
3890            }
3891        }
3892        self.write_block_attrs("block attributes", Vec::new());
3893        true
3894    }
3895
3896    /// Backspace at the start of a block's content: join the block into the
3897    /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3898    /// the split Enter makes, spelled the format's way. Two paragraphs join
3899    /// on a soft break; a paragraph under a marker heading joins onto the
3900    /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3901    /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3902    /// taken as one; a quote's or an item's continuation prefix is written.
3903    /// The joined text takes the block above's presentation and containers,
3904    /// which is the rule every editor with a centred paragraph follows.
3905    ///
3906    /// Leaf used to join by deleting the one newline behind the caret, which
3907    /// is the right bytes for two Markdown paragraphs and nothing else: under
3908    /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3909    /// after a div it took the newline under the hidden `</div>`, which drew
3910    /// nothing different and took the tag apart on the next press. What a
3911    /// join is in each format is twig's to know, and now it does.
3912    ///
3913    /// Where twig refuses — the block above is a code block, a table or a
3914    /// rule with no text to join into, or the caret's block would have to
3915    /// leave a div that holds more after it — the caret steps back to the
3916    /// stop before instead, as it does at a table's end: the key moves the
3917    /// caret and takes no markup apart. Where nothing precedes the block, or
3918    /// the format cannot join at all, Backspace keeps its character delete.
3919    ///
3920    /// Returns whether it acted.
3921    fn backspace_joins_block(&mut self) -> bool {
3922        let caret = self.caret;
3923        if caret <= self.caret_floor() {
3924            return false;
3925        }
3926        let Some(text) = self.text_block_opening_at(caret) else {
3927            return false;
3928        };
3929        match self.join_blocks(caret) {
3930            Ok(change) => {
3931                // The caret keeps its place at the start of the text it stood
3932                // on, wherever the join put that text — after a soft break,
3933                // a space, or a quote's prefix. Found by the bytes, as
3934                // `block_content_in` finds a re-spelled block.
3935                let region = &self.source[change.new.clone()];
3936                let at = region
3937                    .find(&text)
3938                    .map_or(change.new.start, |i| change.new.start + i);
3939                self.land_after_join(at);
3940                true
3941            }
3942            Err(twig::Error::NotEditable) => {
3943                self.step_back_to_stop();
3944                true
3945            }
3946            Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3947            Err(e) => {
3948                self.status = Some(format!("join: {e}"));
3949                true
3950            }
3951        }
3952    }
3953
3954    /// Delete at the end of a block's content: join the block after it into
3955    /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3956    /// mirror, and the same twig gesture aimed at the next block. The caret
3957    /// stays where it was, which is where the joined text now begins after
3958    /// the separator. Where twig refuses, the caret steps forward to the next
3959    /// stop instead; where no block follows, Delete keeps its character
3960    /// delete.
3961    fn delete_forward_joins_block(&mut self) -> bool {
3962        let caret = self.caret;
3963        let at_end = self
3964            .nodes()
3965            .iter()
3966            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3967            .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3968        if !at_end {
3969            return false;
3970        }
3971        // The next stop, across a line end, in a block: what Delete at a
3972        // block's end points at. On the same line it is a hidden delimiter's
3973        // far side, which the ordinary delete handles; on a blank line it is
3974        // the empty paragraph the byte delete has always closed.
3975        self.rebuild_map();
3976        let Some(stop) = self.vmap.stop_after(caret) else {
3977            return false;
3978        };
3979        if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3980            return false;
3981        }
3982        match self.join_blocks(stop) {
3983            Ok(change) => {
3984                self.land_after_join(change.old.start);
3985                true
3986            }
3987            Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3988                self.caret = stop;
3989                self.anchor = None;
3990                self.goal_col = None;
3991                true
3992            }
3993            Err(twig::Error::UnsupportedFormat) => false,
3994            Err(e) => {
3995                self.status = Some(format!("join: {e}"));
3996                true
3997            }
3998        }
3999    }
4000
4001    /// The content bytes of the paragraph or heading whose content opens
4002    /// exactly at `off` — the block a Backspace there is at the start of.
4003    fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
4004        self.nodes()
4005            .into_iter()
4006            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4007            .find_map(|n| {
4008                let c = n.content_span?;
4009                (c.start == off).then(|| self.source[c].to_string())
4010            })
4011    }
4012
4013    /// Hand the block at `offset` to twig's `join_blocks`, with the undo
4014    /// plumbing every structural gesture has; the caret is the caller's to
4015    /// place from the change, via [`land_after_join`](Self::land_after_join).
4016    fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
4017        if self.read_only {
4018            return Err(twig::Error::NotEditable);
4019        }
4020        self.record_caret();
4021        let change = self.editor.join_blocks(offset)?;
4022        self.last_edit_kind = None; // structural edit is its own undo step
4023        self.refresh();
4024        Ok(change)
4025    }
4026
4027    /// Finish a join: the caret at `at`, no selection, the map this
4028    /// revision's before the clamp — see `write_block_attrs` for why.
4029    fn land_after_join(&mut self, at: usize) {
4030        self.caret = at;
4031        self.anchor = None;
4032        self.goal_col = None;
4033        self.dirty = self.source != self.clean_source;
4034        self.status = None;
4035        self.rebuild_map();
4036        self.clamp_caret();
4037        self.record_caret();
4038    }
4039
4040    // ── moving a block ─────────────────────────────────────────────────────────
4041
4042    /// The block a move picks up at `offset` — the same one
4043    /// [`select_block_at`](Self::select_block_at) selects (the deepest node
4044    /// that is neither inline nor a multi-block container), widened to the
4045    /// list item when it is the item's first block, because that is what
4046    /// twig's `move_block` moves: a bullet's text is the bullet, and dragging
4047    /// it takes the item and everything under it. A block later in an item's
4048    /// tail moves alone. `None` on a blank line, and past the source.
4049    fn movable_block_at(&mut self, offset: usize) -> Option<FlatNode> {
4050        let off = offset.min(self.source.len());
4051        let chain = self.editor.ancestors_at(off).ok()?;
4052        let block = chain
4053            .iter()
4054            .rev()
4055            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
4056        let nodes = self.nodes();
4057        let block = nodes.get(block.node_id as usize)?.clone();
4058        let item = block
4059            .parent
4060            .and_then(|p| nodes.get(p.0 as usize))
4061            .filter(|p| matches!(p.kind, Kind::ListItem | Kind::TaskListItem))
4062            .filter(|p| p.first_child == Some(block.id));
4063        Some(item.cloned().unwrap_or(block))
4064    }
4065
4066    /// The source range of the block a move would pick up at `offset` — for
4067    /// the outline of the block being carried, without moving the caret. The
4068    /// range [`select_block_at`](Self::select_block_at) would select, except
4069    /// that it is the whole item for a bullet's text, as
4070    /// [`move_block`](Self::move_block) is.
4071    pub fn block_range_at(&mut self, offset: usize) -> Option<Range<usize>> {
4072        self.movable_block_at(offset).map(|b| b.span)
4073    }
4074
4075    /// Move the block at `from` to the boundary `to` — twig's `move_block`,
4076    /// with the undo plumbing every structural gesture has and the caret
4077    /// riding the block to its new place. `from` is any offset inside the
4078    /// block (the one [`block_range_at`](Self::block_range_at) finds); `to`
4079    /// is a position *between* blocks — a block's first byte lands before it,
4080    /// its last after it, a blank line is itself a boundary, and the source's
4081    /// length is the document's end. The block takes the line prefixes of
4082    /// the container the boundary is inside — a `> ` on the way into a quote,
4083    /// none on the way out — and the blank lines a person would have typed
4084    /// are written and removed; twig spells all of that.
4085    ///
4086    /// A move that would move nothing — `to` inside the block, or on the
4087    /// boundary it already sits on — is a quiet no-op rather than an error,
4088    /// since it is what a block dropped back where it was asks for. Any other
4089    /// refusal reaches the status line: a `to` interior to a fence or a table,
4090    /// or a format with no blocks a caret could name.
4091    ///
4092    /// In WYSIWYG the frontmatter is hidden, and a boundary above it is not
4093    /// one a person can see: `to` is raised to the first rendered offset.
4094    ///
4095    /// `true` when the document changed. A move twig accepts that rewrites
4096    /// nothing — a one-item list sent past a paragraph is still a one-item
4097    /// list past that paragraph — is taken back rather than left as an undo
4098    /// step with no difference in it, and is `false` too.
4099    pub fn move_block(&mut self, from: usize, to: usize) -> bool {
4100        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
4101            return false;
4102        }
4103        let len = self.source.len();
4104        let from = from.min(len);
4105        let to = to.clamp(self.caret_floor().min(len), len);
4106        let Some(block) = self.movable_block_at(from) else {
4107            self.status = Some("move block: no block here".into());
4108            return false;
4109        };
4110        // Where the caret stands in the block, as (line within the block,
4111        // bytes back from that line's end) — the shape that survives a
4112        // prefix being added or stripped on the way through a container.
4113        let caret = self.caret.clamp(block.span.start, block.span.end);
4114        let block_line = self.source[block.span.start..caret].matches('\n').count();
4115        let line_end = self.source[caret..block.span.end]
4116            .find('\n')
4117            .map_or(block.span.end, |i| caret + i);
4118        let tail = line_end - caret;
4119        let block_lines = self.source[block.span.start..block.span.end]
4120            .matches('\n')
4121            .count()
4122            + 1;
4123        let upward = to <= block.span.start;
4124        self.record_caret();
4125        match self.editor.move_block(from, to) {
4126            Ok(_) if self.editor.source_str().ok().as_deref() == Some(self.source.as_str()) => {
4127                let _ = self.editor.undo();
4128                self.status = None;
4129                false
4130            }
4131            Ok(change) => {
4132                self.last_edit_kind = None; // structural edit is its own undo step
4133                self.refresh();
4134                let at = self.moved_block_caret(&change.new, upward, block_lines, block_line, tail);
4135                self.caret = at;
4136                self.anchor = None;
4137                self.goal_col = None;
4138                self.dirty = self.source != self.clean_source;
4139                self.status = None;
4140                self.rebuild_map();
4141                self.clamp_caret();
4142                self.record_caret();
4143                true
4144            }
4145            // Nothing to move: the block dropped back onto its own boundary.
4146            Err(twig::Error::InvalidArgument) => {
4147                self.status = None;
4148                false
4149            }
4150            Err(e) => {
4151                self.status = Some(format!("move block: {e}"));
4152                false
4153            }
4154        }
4155    }
4156
4157    /// The caret's place in the rewritten region after a move: the moved
4158    /// block's lines open the region when it went up (below the blank line it
4159    /// was dropped on, when it was) and close it (above the separator twig
4160    /// wrote after it) when it went down, and within them the
4161    /// caret keeps its line and its distance from that line's end. Clamped
4162    /// into the region rather than trusted, since a block can lose a line on
4163    /// the way — a quote it was the only content of goes with it.
4164    fn moved_block_caret(
4165        &self,
4166        new: &Range<usize>,
4167        upward: bool,
4168        block_lines: usize,
4169        block_line: usize,
4170        tail: usize,
4171    ) -> usize {
4172        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4173        let mut lines: Vec<(usize, usize)> = Vec::new();
4174        let mut start = 0;
4175        loop {
4176            match region[start..].find('\n') {
4177                Some(i) => {
4178                    lines.push((start, start + i));
4179                    start += i + 1;
4180                }
4181                None => {
4182                    lines.push((start, region.len()));
4183                    break;
4184                }
4185            }
4186        }
4187        // A separator line is blank, or a quote's bare `>`; the region can
4188        // open with one (the blank line the block was dropped on) or close
4189        // with one (the one twig wrote after it).
4190        let separator = |&(s, e): &(usize, usize)| {
4191            region[s..e]
4192                .trim()
4193                .trim_start_matches('>')
4194                .trim()
4195                .is_empty()
4196        };
4197        let first = if upward {
4198            lines.iter().position(|l| !separator(l)).unwrap_or(0)
4199        } else {
4200            let filled = lines
4201                .iter()
4202                .rposition(|l| !separator(l))
4203                .map_or(0, |i| i + 1);
4204            filled.saturating_sub(block_lines)
4205        };
4206        let (s, e) = lines[(first + block_line).min(lines.len() - 1)];
4207        new.start + e.saturating_sub(tail).max(s)
4208    }
4209
4210    /// Move the caret's block one place up — Alt+↑: above the block before it,
4211    /// and out of its container, to just above it, when it is the first block
4212    /// there. Nothing above the document's first block, and nothing to do for
4213    /// a caret on a blank line; both say so in the status line.
4214    pub fn move_block_up(&mut self) {
4215        self.move_block_by(true);
4216    }
4217
4218    /// Move the caret's block one place down — Alt+↓, the mirror of
4219    /// [`move_block_up`](Self::move_block_up): below the block after it, and
4220    /// out of its container when it is the last block there.
4221    pub fn move_block_down(&mut self) {
4222        self.move_block_by(false);
4223    }
4224
4225    fn move_block_by(&mut self, up: bool) {
4226        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
4227            return;
4228        }
4229        let Some(from) = self.block_offset_for_caret() else {
4230            self.status = Some("move block: no block here".into());
4231            return;
4232        };
4233        let Some(block) = self.movable_block_at(from) else {
4234            self.status = Some("move block: no block here".into());
4235            return;
4236        };
4237        let nodes = self.nodes();
4238        let to = if up {
4239            self.boundary_above(&nodes, &block)
4240        } else {
4241            self.boundary_below(&nodes, &block)
4242        };
4243        if to.is_none_or(|to| !self.move_block(from, to)) && self.status.is_none() {
4244            self.status = Some(if up {
4245                "move block: nothing above".into()
4246            } else {
4247                "move block: nothing below".into()
4248            });
4249        }
4250    }
4251
4252    /// The boundary one step above `block`: before its previous sibling, or
4253    /// — for the first block in a container — before the container itself.
4254    /// `None` for the document's first block.
4255    fn boundary_above(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
4256        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
4257        let previous = nodes
4258            .iter()
4259            .filter(|n| n.parent == Some(parent.id))
4260            .take_while(|n| n.id != block.id)
4261            .last();
4262        match previous {
4263            Some(p) => self.before(p),
4264            None if parent.kind == Kind::Doc => None,
4265            // An item leaving a nested list upward goes before the item
4266            // holding that list, as an item of the outer one; the list's own
4267            // first byte is inside the holding item's tail.
4268            None if matches!(
4269                parent.kind,
4270                Kind::BulletList | Kind::OrderedList | Kind::TaskList
4271            ) =>
4272            {
4273                match parent.parent.and_then(|p| nodes.get(p.0 as usize)) {
4274                    Some(item) if matches!(item.kind, Kind::ListItem | Kind::TaskListItem) => {
4275                        self.before(item)
4276                    }
4277                    _ => self.before(parent),
4278                }
4279            }
4280            None => self.before(parent),
4281        }
4282    }
4283
4284    /// The boundary one step below `block`: after its next sibling, or — for
4285    /// the last block in a container — just past the container, at its
4286    /// parent's level ([`exit_below`](Self::exit_below)). `None` for the
4287    /// document's last block.
4288    fn boundary_below(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
4289        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
4290        if let Some(n) = block.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
4291            return Some(self.after(n));
4292        }
4293        match parent.kind {
4294            Kind::Doc => None,
4295            _ => self.exit_below(nodes, parent),
4296        }
4297    }
4298
4299    /// The boundary just past `container` at its parent's level: before its
4300    /// next sibling, or past its parent when it is the last thing there —
4301    /// the document's end at the top. Leaving a list item is the exception
4302    /// that keeps a block in the list: the next item's tail, which is what
4303    /// [`after`](Self::after) an item names.
4304    fn exit_below(&self, nodes: &[FlatNode], container: &FlatNode) -> Option<usize> {
4305        let item = matches!(container.kind, Kind::ListItem | Kind::TaskListItem);
4306        match container.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
4307            Some(n) if item => Some(self.after(n)),
4308            Some(n) => self.before(n),
4309            None => {
4310                let parent = container.parent.and_then(|p| nodes.get(p.0 as usize))?;
4311                match parent.kind {
4312                    Kind::Doc => Some(self.source.len()),
4313                    _ => self.exit_below(nodes, parent),
4314                }
4315            }
4316        }
4317    }
4318
4319    /// The boundary before `node`: its first byte. twig reads a container's
4320    /// opening as before it — a quote's marker, a fence's first byte — so the
4321    /// span's start is the boundary at the parent's level for every kind.
4322    fn before(&self, node: &FlatNode) -> Option<usize> {
4323        Some(node.span.start)
4324    }
4325
4326    /// The boundary after `node`: its own end, which for a container proper
4327    /// (a quote, a list, a fenced or tagged container) is the end of its last
4328    /// line — after its last block, still inside it, where a block arriving
4329    /// from outside joins it. Past the container is the next line's start: a
4330    /// blank line, the next block, or the document's end.
4331    fn after(&self, node: &FlatNode) -> usize {
4332        let container = is_block_container(&node.kind)
4333            && !matches!(node.kind, Kind::ListItem | Kind::TaskListItem);
4334        let end = node.span.end.min(self.source.len());
4335        if container && self.source.as_bytes().get(end) == Some(&b'\n') {
4336            end + 1
4337        } else {
4338            end
4339        }
4340    }
4341
4342    /// Where a block dragged over rendered row `row` would land — the
4343    /// boundary before the row's block when the row is in its upper half, the
4344    /// boundary after it otherwise, and the document's end for a row below
4345    /// everything. `None` for a row that holds no block (a decoration row is
4346    /// resolved to the block under it, so this is a table's bottom rule with
4347    /// nothing after it, or a map with no rows). The frontend draws its
4348    /// indicator above [`DropTarget::row`] and hands
4349    /// [`DropTarget::offset`] to [`move_block`](Self::move_block).
4350    ///
4351    /// The block is the deepest one, not the widened item: a drop on a
4352    /// bullet's text lands before or after that bullet, and its nested
4353    /// children — rows of their own — answer for themselves.
4354    pub fn drop_target_at(&mut self, row: usize) -> Option<DropTarget> {
4355        let rows = self.vmap.rows.len();
4356        if row >= rows {
4357            return Some(DropTarget {
4358                offset: self.source.len(),
4359                row: rows,
4360            });
4361        }
4362        // A gap or a rule is not a block; the block under it is the one meant.
4363        let row = (row..rows).find(|&r| self.vmap.row_is_navigable(r))?;
4364        let start = self.vmap.row_start(row)?;
4365        let chain = self.editor.ancestors_at(start).ok()?;
4366        let block = chain
4367            .iter()
4368            .rev()
4369            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
4370        let span = block.span.clone();
4371        let (first, last) = self.vmap.row_range_for(span.clone());
4372        if row <= usize::midpoint(first, last) {
4373            Some(DropTarget {
4374                offset: span.start,
4375                row: first,
4376            })
4377        } else {
4378            Some(DropTarget {
4379                offset: span.end.min(self.source.len()),
4380                row: last + 1,
4381            })
4382        }
4383    }
4384
4385    /// Backspace at a table's trailing stop: move onto the stop before it (the
4386    /// last cell's end) and consume the key. `false` anywhere else. See
4387    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
4388    /// not one to delete.
4389    fn backspace_at_table_end(&mut self) -> bool {
4390        // The map answers about offsets, so it has to be this revision's — see
4391        // `open_paragraph_at_block_edge`.
4392        self.rebuild_map();
4393        if !self.vmap.table_end_stop(self.caret) {
4394            return false;
4395        }
4396        if let Some(off) = self
4397            .vmap
4398            .stop_before(self.caret)
4399            .filter(|&o| o >= self.caret_floor())
4400        {
4401            self.caret = off;
4402            self.anchor = None;
4403            self.goal_col = None;
4404        }
4405        true
4406    }
4407
4408    /// Whether the caret stands at a table cell's wall on the `edge` side — at
4409    /// or before its first caret stop for Backspace, at or past its last for
4410    /// Delete — where the only bytes between it and the neighbouring cell are
4411    /// the padding and the `|` the rich view draws as the grid. The padding
4412    /// counts as the cell's: a caret placed between `| ` and the text is at the
4413    /// same wall, so the first press cannot eat the space either. An in-cell
4414    /// `<br>` at the edge is not a wall; the delete takes it whole, as ever.
4415    fn at_cell_wall(&mut self, edge: BreakEdge) -> bool {
4416        // The map answers about offsets, so it has to be this revision's.
4417        self.rebuild_map();
4418        if self.cell_break_at(edge).is_some() {
4419            return false;
4420        }
4421        let caret = self.caret;
4422        let src = self.source.as_bytes();
4423        let pad = |b: &u8| *b == b' ' || *b == b'\t';
4424        self.vmap
4425            .tables
4426            .iter()
4427            .flat_map(|t| &t.grid)
4428            .flat_map(|row| &row.cells)
4429            .any(|cell| {
4430                let lo = cell.start
4431                    - src[..cell.start]
4432                        .iter()
4433                        .rev()
4434                        .take_while(|b| pad(b))
4435                        .count();
4436                let hi = cell.end + src[cell.end..].iter().take_while(|b| pad(b)).count();
4437                (lo..=hi).contains(&caret)
4438                    && match edge {
4439                        BreakEdge::Backward => {
4440                            self.vmap.stop_before(caret).is_none_or(|s| s < cell.start)
4441                        }
4442                        BreakEdge::Forward => {
4443                            self.vmap.stop_after(caret).is_none_or(|s| s > cell.end)
4444                        }
4445                    }
4446            })
4447    }
4448
4449    /// Whether the caret's own source line holds nothing but whitespace — an
4450    /// empty paragraph, or the blank line a block boundary is spelled with. The
4451    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
4452    /// no text of its own, so the newline before the caret belongs to the gap
4453    /// between blocks rather than to any word the caret is editing.
4454    fn caret_on_blank_line(&self) -> bool {
4455        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
4456        let line_end = self.source[self.caret..]
4457            .find('\n')
4458            .map_or(self.source.len(), |i| self.caret + i);
4459        self.source[line_start..line_end].trim().is_empty()
4460    }
4461
4462    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
4463    /// `edge` side — the byte range to delete whole. A table row is one source
4464    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
4465    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
4466    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
4467    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
4468    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
4469    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
4470    /// apart — no ancestor walk needed. Rich view only; source view shows the
4471    /// literal tag and deletes it a byte at a time.
4472    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
4473        let caret = self.caret;
4474        let nodes = self.nodes();
4475        let src = self.source.as_bytes();
4476        nodes
4477            .iter()
4478            .find(|n| {
4479                n.kind == Kind::HardBreak
4480                    && n.span.start < n.span.end
4481                    && src.get(n.span.start) == Some(&b'<')
4482                    && match edge {
4483                        BreakEdge::Backward => n.span.end == caret,
4484                        BreakEdge::Forward => n.span.start == caret,
4485                    }
4486            })
4487            .map(|n| (n.span.start, n.span.end))
4488    }
4489
4490    /// Whether the source byte at `off` is a backslash twig consumed as an escape
4491    /// (hidden in the rich view), as against a literal backslash (drawn). A
4492    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
4493    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
4494    /// round-trip needed.
4495    fn is_hidden_escape(&self, off: usize) -> bool {
4496        let b = self.source.as_bytes();
4497        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
4498    }
4499
4500    /// Backspace's list behaviour: when the caret sits exactly at the start of a
4501    /// list item's content (right after its marker), outdent the item if it's
4502    /// nested, else strip the marker so it becomes a paragraph. Returns whether
4503    /// it acted — `false` leaves Backspace its ordinary character delete.
4504    fn backspace_list_start(&mut self) -> bool {
4505        let Some(marker) = self.list_marker_on_line(self.caret) else {
4506            return false;
4507        };
4508        // Only right after the marker. That the line opens a real item is
4509        // already settled: `list_marker_on_line` answers from the tree.
4510        if self.caret != marker.content_start() {
4511            return false;
4512        }
4513        if self.item_is_nested(marker.marker_start) {
4514            // Nested: give back one level, keeping the marker and carrying the
4515            // caret with it.
4516            self.outdent();
4517        } else {
4518            // Top level: drop the marker, leaving a paragraph, then renumber the
4519            // siblings the removed item was counted among. Only the marker goes —
4520            // a quote prefix in front of it still has a quote to hold up.
4521            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
4522            self.renumber_here();
4523        }
4524        true
4525    }
4526
4527    /// Backspace's heading behaviour: with the caret exactly at the start of an
4528    /// ATX heading's content — right after the `#` marker the rich view hides —
4529    /// strip the marker so the line becomes a paragraph. The peer of
4530    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
4531    /// reasoning: hidden block markup is structure, so the keystroke over it is
4532    /// structural.
4533    ///
4534    /// Without this the ordinary delete takes the space out of `# Title` and
4535    /// leaves `#Title`, which is no longer a heading at all — the hash the view
4536    /// had been hiding surfaces as literal text the user has to delete a second
4537    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
4538    /// other end) goes with the marker for the same reason.
4539    ///
4540    /// Returns whether it acted; `false` leaves Backspace its character delete.
4541    fn backspace_heading_start(&mut self) -> bool {
4542        let caret = self.caret;
4543        // The heading whose content opens exactly at the caret. A bare `#` has no
4544        // content span at all — its content starts (and ends) where the line does.
4545        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
4546            let (start, end) = match &n.content_span {
4547                Some(c) => (c.start, c.end),
4548                None => (n.span.end, n.span.end),
4549            };
4550            (n.kind == Kind::Heading && start == caret)
4551                .then(|| (n.span.clone(), end, n.marker_span.clone()))
4552        }) else {
4553            return false;
4554        };
4555        // twig reports the marker's own extent, so there is nothing to walk back
4556        // over and no `#` in this file. A setext heading has no marker — its
4557        // content opens the line — so it falls through to the ordinary delete,
4558        // as does anything else sitting at a content start.
4559        // `m.end == caret` is what excludes a setext heading, whose marker is the
4560        // underline *after* the content rather than a prefix before it.
4561        let Some(marker) = marker.filter(|m| m.end == caret) else {
4562            return false;
4563        };
4564        let start = marker.start;
4565        // A closing `#` sequence is hidden too, so it can't be left behind. Only
4566        // when the tail really is one: trailing spaces alone are nothing to strip.
4567        let tail = &self.source[content_end..span.end];
4568        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
4569            let kept = self.source[caret..content_end].to_string();
4570            self.splice(start, span.end, &kept, EditKind::Other);
4571            // The splice leaves the caret past the text it re-wrote; the caret
4572            // belongs where the content now starts, which is where it already was.
4573            self.caret = start;
4574            self.record_caret();
4575        } else {
4576            self.splice(start, caret, "", EditKind::Other);
4577        }
4578        true
4579    }
4580
4581    pub fn delete_forward(&mut self) {
4582        if let Some((s, e)) = self.selection() {
4583            self.splice(s, e, "", EditKind::Other);
4584        } else if self.caret < self.source.len() {
4585            // The mirror of Backspace's: forward-delete in front of a picture
4586            // would eat the `!` off its markup and leave a link where a photo was.
4587            if self.view != View::Source && self.delete_around_block_atom(true) {
4588                return;
4589            }
4590            // And of Backspace's cell wall: Delete at a cell's end would take
4591            // the padding and the `|` after it.
4592            if self.view != View::Source && self.at_cell_wall(BreakEdge::Forward) {
4593                return;
4594            }
4595            // And of Backspace's join: at the end of a block's content, Delete
4596            // joins the next block into this one.
4597            if self.view != View::Source && self.delete_forward_joins_block() {
4598                return;
4599            }
4600            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
4601            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
4602            // strands a broken `<br` in the cell.
4603            if self.view != View::Source
4604                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
4605            {
4606                self.splice(start, end, "", EditKind::Delete);
4607                return;
4608            }
4609            // The mirror of Backspace's two steps: from in front of a run's
4610            // opening `**` step into it, onto the first letter of its text, and
4611            // at the end of a run's text step out past its closing `**` to the
4612            // character beyond. Either way Delete takes the character it looks
4613            // like it is pointing at, and never a delimiter drawn as nothing.
4614            // The caret then settles back inside the run it was standing in —
4615            // see `settle_inside_close_delims`.
4616            let from = if self.view == View::Source {
4617                self.caret
4618            } else {
4619                let inside = self.step_inside_open_delims(self.caret);
4620                // The mirror of Backspace's empty-span rule: an attributed
4621                // span with no text goes whole, with the character after it.
4622                if let Some(span) = self.run_span_of_content(inside..inside) {
4623                    let to = if self.source[span.end..].starts_with('\n') {
4624                        span.end
4625                    } else {
4626                        next_boundary(&self.source, span.end)
4627                    };
4628                    self.splice(span.start, to, "", EditKind::Delete);
4629                    return;
4630                }
4631                self.skip_trailing_close_delims(inside)
4632            };
4633            let next = next_boundary(&self.source, from);
4634            // And of its emptying rules: the span goes with its last letter,
4635            // and so does the block's div or `{…}` line.
4636            if self.view != View::Source
4637                && let Some(span) = self.run_span_of_content(from..next)
4638            {
4639                self.splice(span.start, span.end, "", EditKind::Delete);
4640                return;
4641            }
4642            if self.view != View::Source
4643                && let Some(block) = self.attributed_block_of_content(from..next)
4644            {
4645                self.splice(block.start, block.end, "", EditKind::Delete);
4646                return;
4647            }
4648            if from < next {
4649                self.splice(from, next, "", EditKind::Delete);
4650            }
4651        }
4652    }
4653
4654    /// Delete from the caret back to the start of the previous word (⌥⌫ /
4655    /// Ctrl+⌫). Deletes the selection instead when one is active.
4656    pub fn delete_word_back(&mut self) {
4657        if let Some((s, e)) = self.selection() {
4658            self.splice(s, e, "", EditKind::Other);
4659        } else {
4660            // A word back from just past a picture is a word *of its markup*, and
4661            // a word back from in front of one runs through the paragraph break
4662            // into the prose above — dissolving the picture either way. See
4663            // `delete_around_block_atom`.
4664            if self.view != View::Source && self.delete_around_block_atom(false) {
4665                return;
4666            }
4667            let start = self.word_left_from(self.caret).max(self.caret_floor());
4668            if start < self.caret {
4669                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4670                self.splice(s, e, "", EditKind::Delete);
4671            }
4672        }
4673    }
4674
4675    /// Delete from the caret forward to the end of the next word (⌥⌦ /
4676    /// Ctrl+Del). Deletes the selection instead when one is active.
4677    pub fn delete_word_forward(&mut self) {
4678        if let Some((s, e)) = self.selection() {
4679            self.splice(s, e, "", EditKind::Other);
4680        } else {
4681            // The mirror: a word forward from in front of a picture is its markup.
4682            if self.view != View::Source && self.delete_around_block_atom(true) {
4683                return;
4684            }
4685            let end = self.word_right_from(self.caret);
4686            if end > self.caret {
4687                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4688                self.splice(s, e, "", EditKind::Delete);
4689            }
4690        }
4691    }
4692
4693    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
4694    /// selection instead when one is active, as every other delete here does.
4695    ///
4696    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
4697    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
4698    /// stops at the first character and this takes the indentation with it, the
4699    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
4700    /// leave an indent behind that nothing can then ask to delete, where a caret
4701    /// left at column 0 is one press of Home away from either.
4702    pub fn delete_to_line_start(&mut self) {
4703        if let Some((s, e)) = self.selection() {
4704            self.splice(s, e, "", EditKind::Other);
4705            return;
4706        }
4707        // Never back across the floor: hidden frontmatter isn't on this line, or
4708        // on any line the WYSIWYG caret can see.
4709        let (start, _) = self.line_span();
4710        let start = start.max(self.caret_floor());
4711        if start < self.caret {
4712            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4713            self.splice(s, e, "", EditKind::Delete);
4714        }
4715    }
4716
4717    /// Kill from the caret to the end of its line (^K). Deletes the selection
4718    /// instead when one is active.
4719    ///
4720    /// At the end of the line it does nothing, rather than pulling the line
4721    /// below up into this one. Joining has no meaning to give it in both views
4722    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
4723    /// there is nothing there to delete, while the newline a *source* line ends
4724    /// with is only half of the blank line that separates two paragraphs —
4725    /// deleting one leaves a soft break, which is not the join it looks like.
4726    /// The views agreeing is worth more than emacs' second press, and Delete is
4727    /// already the key that joins.
4728    pub fn delete_to_line_end(&mut self) {
4729        if let Some((s, e)) = self.selection() {
4730            self.splice(s, e, "", EditKind::Other);
4731            return;
4732        }
4733        let (_, end) = self.line_span();
4734        if end > self.caret {
4735            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4736            self.splice(s, e, "", EditKind::Delete);
4737        }
4738    }
4739
4740    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
4741    ///
4742    /// A glyph-space range covers what the user can see, which for `**bold**` is
4743    /// the word and never the delimiters around it — so deleting the word on its
4744    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
4745    /// word, and the styling was the word's; the two go together. Only the
4746    /// node's delimiters are taken, and those are hidden here anyway, so nothing
4747    /// visible outside the range is lost.
4748    ///
4749    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
4750    /// the strong, and only then is the strong empty too.
4751    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
4752        if self.view == View::Source {
4753            return (start, end);
4754        }
4755        let nodes = self.nodes();
4756        let (mut s, mut e) = (start, end);
4757        loop {
4758            let mut grew = false;
4759            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
4760                let Some(text) = inline_content_span(n, &self.source) else {
4761                    continue;
4762                };
4763                // Some of its text survives, so the node still has a job.
4764                if text.start < s || text.end > e {
4765                    continue;
4766                }
4767                if n.span.start < s || n.span.end > e {
4768                    s = s.min(n.span.start);
4769                    e = e.max(n.span.end);
4770                    grew = true;
4771                }
4772            }
4773            if !grew {
4774                return (s, e);
4775            }
4776        }
4777    }
4778
4779    /// One splice of document text, keeping the **mark-edge rule**: an inline
4780    /// mark's content never begins or ends with whitespace. In Markdown and Djot
4781    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
4782    /// is four literal asterisks around a word, and a rich view drawing the
4783    /// document faithfully has no choice but to show them. That is correct
4784    /// rendering of what the file says, and nobody typing a space after a bold
4785    /// word meant to say it.
4786    ///
4787    /// So the space goes *outside* the run instead — `**bold** ` — which is the
4788    /// same document to a reader and a live one to a parser. The caret follows it
4789    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
4790    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
4791    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
4792    ///
4793    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
4794    /// through here, so the rule holds however the whitespace arrives at the
4795    /// edge. The repair is decided *after* the plain edit, by asking whether the
4796    /// mark actually died: a code span's backticks aren't whitespace-sensitive
4797    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
4798    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4799        let fix = self.mark_edge_fix(start, end, text);
4800        if !self.splice_exact(start, end, text, kind) {
4801            return false;
4802        }
4803        if let Some(fix) = fix {
4804            self.repair_mark_edges(fix);
4805        }
4806        if text.is_empty() && end > start {
4807            self.settle_inside_close_delims();
4808        }
4809        true
4810    }
4811
4812    /// After a delete, take a caret left standing past a run's closing delimiters
4813    /// back inside the run.
4814    ///
4815    /// A delete leaves the caret where the deleted bytes began, and when those
4816    /// bytes were the last thing after a marked phrase — the space the mark-edge
4817    /// rule pushed out of `**bold** `, say — that spot is the far side of the
4818    /// closing `**`. The rich view has nothing to draw there: the delimiters are
4819    /// hidden, so the caret shows at the end of the word either way, and the two
4820    /// offsets are one place on screen with two different meanings. Typing at the
4821    /// outer one lands past the run, so the writer who backspaced a space out of
4822    /// their bold phrase watches the next character come out plain, and the
4823    /// toolbar button go dark, with the caret never appearing to move.
4824    ///
4825    /// The end of the run's text is the caret's home there — a delete that took
4826    /// away everything after a phrase leaves the caret at the end of that phrase,
4827    /// which is inside it — so it settles onto that
4828    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
4829    /// walk, through every mark closing at the point): the word stays bold, the
4830    /// button stays lit, and the next character carries on the phrase.
4831    ///
4832    /// Rich view only, and only where a mark really closes at the caret — mid-run
4833    /// or in plain prose no span ends there and the caret stays put. The opening
4834    /// edge is left alone on purpose: a caret in front of a run inherits from the
4835    /// text on its left, which is the plain text outside.
4836    fn settle_inside_close_delims(&mut self) {
4837        if self.view != View::Wysiwyg {
4838            return;
4839        }
4840        let at = self.step_inside_close_delims(self.caret);
4841        if at != self.caret {
4842            self.caret = at;
4843            self.clear_pending();
4844            self.record_caret();
4845        }
4846    }
4847
4848    /// The splice exactly as asked, with no mark-edge repair — for the callers
4849    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
4850    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
4851    /// the bytes they inserted.
4852    ///
4853    /// One `edit_range` through twig, then re-anchor the caret from the returned
4854    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
4855    /// Markdown/Djot) leaves the document untouched and reports.
4856    ///
4857    /// Returns whether the edit landed — for a caller that has offsets of its
4858    /// own to place afterwards, which a rolled-back splice would leave pointing
4859    /// into text that never came to exist.
4860    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4861        // The read-only gate, for every edit at once — see the field.
4862        if self.read_only {
4863            return false;
4864        }
4865        // twig records an undo step for every edit; when this one continues a
4866        // run of the same kind (typing, deleting), tell twig to fold it into the
4867        // step before it so the whole run undoes at once.
4868        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
4869        // Hand twig the pre-edit caret before the splice, so the undo step it
4870        // retires carries where the caret was standing.
4871        self.record_caret();
4872        match self.editor.edit_range(start, end, text) {
4873            Ok(change) => {
4874                self.last_edit_kind = Some(kind);
4875                // Counted first, so the fold has the step it folds.
4876                self.refresh();
4877                if coalesce {
4878                    self.coalesce_last_undo();
4879                }
4880                self.caret = change.new.end;
4881                self.anchor = None;
4882                self.goal_col = None;
4883                self.clear_pending();
4884                self.dirty = self.source != self.clean_source;
4885                self.status = None;
4886                // And the post-edit caret, so a later redo restores it.
4887                self.record_caret();
4888                true
4889            }
4890            // The edit was rolled back, so twig's history did not move and
4891            // neither may ours: pushing here would leave a step with no edit
4892            // under it and shift every later undo onto the wrong caret.
4893            Err(e) => {
4894                self.status = Some(format!("edit: {e}"));
4895                false
4896            }
4897        }
4898    }
4899
4900    /// The re-spelling that would keep the mark-edge rule for the edit
4901    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
4902    /// against a delimiter and the plain splice is already right. Computed
4903    /// *before* the edit, while the run's spans and delimiters can still be read
4904    /// off the document; applied afterwards, and only if the mark really died —
4905    /// see [`repair_mark_edges`](Self::repair_mark_edges).
4906    ///
4907    /// Rich view only. Source view is for typing raw markup, where a space put
4908    /// against a `**` is exactly the character it looks like.
4909    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
4910        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
4911            return None;
4912        }
4913        // Every inline mark standing over the edit, outermost first, with the
4914        // content span that says where its delimiters are.
4915        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
4916            .editor
4917            .ancestors_at(start)
4918            .unwrap_or_default()
4919            .into_iter()
4920            .filter_map(|m| {
4921                let kind = inline_kind(&m.kind)?;
4922                let content = m.content_span.clone()?;
4923                Some((kind, m.span.clone(), content))
4924            })
4925            .collect();
4926        // The innermost run whose *content* holds the whole edit: the one whose
4927        // text is being changed, rather than one the edit merely sits under.
4928        let (kind, span, content) = chain
4929            .iter()
4930            .rev()
4931            .find(|(_, _, c)| c.start <= start && end <= c.end)?
4932            .clone();
4933        // What that content becomes. Whitespace at either end of it is what
4934        // would put out the mark.
4935        let body = format!(
4936            "{}{text}{}",
4937            &self.source[content.start..start],
4938            &self.source[end..content.end]
4939        );
4940        let (lead, trail) = if body.trim().is_empty() {
4941            // Nothing but whitespace left: there is no content to mark at all,
4942            // and the delimiters go with it rather than closing on a space.
4943            (body.len(), 0)
4944        } else {
4945            (
4946                body.len() - body.trim_start().len(),
4947                body.len() - body.trim_end().len(),
4948            )
4949        };
4950        // Nothing against a delimiter, and something still between them: the
4951        // plain edit stands. An emptied run is broken just as surely (`**b**`
4952        // with the `b` deleted is the literal `****`) and is re-spelt as the
4953        // nothing it now says.
4954        if lead == 0 && trail == 0 && !body.is_empty() {
4955            return None;
4956        }
4957        // Marks that open or close exactly where this one does — `***both***` is
4958        // two runs sharing an edge — spell their delimiters as one run of bytes,
4959        // so the whitespace has to clear all of them together.
4960        let (mut open_at, mut close_at) = (span.start, span.end);
4961        for _ in 0..chain.len() {
4962            match chain.iter().find(|(_, _, c)| c.start == open_at) {
4963                Some((_, s, _)) => open_at = s.start,
4964                None => break,
4965            }
4966        }
4967        for _ in 0..chain.len() {
4968            match chain.iter().find(|(_, _, c)| c.end == close_at) {
4969                Some((_, s, _)) => close_at = s.end,
4970                None => break,
4971            }
4972        }
4973        let open = &self.source[open_at..content.start];
4974        let close = &self.source[content.end..close_at];
4975        let core = &body[lead..body.len() - trail];
4976        let respelt = if core.is_empty() {
4977            body.clone()
4978        } else {
4979            format!(
4980                "{}{open}{core}{close}{}",
4981                &body[..lead],
4982                &body[body.len() - trail..]
4983            )
4984        };
4985        // The caret sits just past the inserted text within the new content —
4986        // which, when that lands in the whitespace, is now outside the delimiters.
4987        let pos = (start - content.start) + text.len();
4988        let caret = if core.is_empty() || pos <= lead {
4989            open_at + pos
4990        } else if pos >= lead + core.len() {
4991            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4992        } else {
4993            open_at + lead + open.len() + (pos - lead)
4994        };
4995        Some(MarkEdgeFix {
4996            kind,
4997            probe: content.start,
4998            start: open_at,
4999            end: close_at + text.len() - (end - start),
5000            text: respelt,
5001            caret,
5002            // The marks in force here, resolved against any armed sticky delta —
5003            // what the writer is typing in, and so what has to still be true on
5004            // the far side of the delimiter the caret just stepped over.
5005            want: chain
5006                .iter()
5007                .filter(|(_, s, _)| start < s.end)
5008                .map(|(k, _, _)| *k)
5009                .collect::<InlineMarks>()
5010                .xor(self.pending_here()),
5011        })
5012    }
5013
5014    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
5015    /// did break the mark. Whether whitespace at a delimiter is fatal is the
5016    /// format's business, not leaf's: `**bold **` is no longer strong, while
5017    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
5018    /// don't care either. Asking the parser afterwards settles it for every kind
5019    /// and format at once, and costs a re-spelling only where one is due.
5020    ///
5021    /// The repair rides along with the edit that caused it — one undo step puts
5022    /// back what the writer typed, not a delimiter shuffle they never saw.
5023    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
5024        if fix.end > self.source.len() {
5025            return;
5026        }
5027        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
5028            return; // still a mark: these delimiters don't mind the whitespace
5029        }
5030        let resumed = self.last_edit_kind;
5031        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
5032            return;
5033        }
5034        self.coalesce_last_undo();
5035        // The keystroke owns the undo step, so the run of typing it belongs to
5036        // keeps coalescing over the repair rather than breaking in two here.
5037        self.last_edit_kind = resumed;
5038        self.caret = fix.caret.min(self.source.len());
5039        self.anchor = None;
5040        self.goal_col = None;
5041        self.rearm(fix.want);
5042        self.clamp_caret();
5043        self.record_caret();
5044    }
5045
5046    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
5047    /// writer is typing in, carried across an edit that moved the caret out of
5048    /// the run holding them. Arms nothing when the caret already stands in
5049    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
5050    /// a clean delta here (see [`toggle`](Self::toggle)).
5051    fn rearm(&mut self, want: InlineMarks) {
5052        let here: InlineMarks = self
5053            .marks_at(self.caret)
5054            .into_iter()
5055            .map(|(k, _)| k)
5056            .collect();
5057        self.pending_marks = want.xor(here);
5058        self.pending_at = Some(self.caret);
5059    }
5060
5061    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
5062    /// which backslash-escapes any character that would otherwise open markup in
5063    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
5064    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
5065    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
5066    /// a collapsed point — a selection is deleted by the caller first, since
5067    /// `insert_literal` inserts rather than replaces.
5068    fn insert_literal_at(
5069        &mut self,
5070        at: usize,
5071        text: &str,
5072        kind: EditKind,
5073        force_coalesce: bool,
5074    ) -> bool {
5075        // The read-only gate: this door goes to twig directly, not through
5076        // `splice_exact`, so it guards itself — see the field.
5077        if self.read_only {
5078            return false;
5079        }
5080        // `force_coalesce` folds this into the immediately preceding edit (the
5081        // selection-delete of an overwrite) so the pair is one undo step; else it
5082        // coalesces only when it continues a run of the same-kind typing.
5083        let coalesce =
5084            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
5085        // The mark-edge rule holds for typed text however it is spelled — see
5086        // `splice`. Only an insert twig passed through unchanged can use it,
5087        // since a fix is measured in the bytes that actually land, and an escape
5088        // adds bytes this couldn't have counted.
5089        let fix = self.mark_edge_fix(at, at, text);
5090        #[cfg(test)]
5091        if std::mem::take(&mut self.refuse_next_literal) {
5092            self.status = Some("edit: refused".into());
5093            return false;
5094        }
5095        self.record_caret();
5096        match self.editor.insert_literal(at, text) {
5097            Ok(change) => {
5098                self.last_edit_kind = Some(kind);
5099                // Counted first, so the fold has the step it folds.
5100                self.refresh();
5101                if coalesce {
5102                    self.coalesce_last_undo();
5103                }
5104                self.caret = change.new.end;
5105                self.anchor = None;
5106                self.goal_col = None;
5107                self.clear_pending();
5108                self.dirty = self.source != self.clean_source;
5109                self.status = None;
5110                self.record_caret();
5111                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
5112                    self.repair_mark_edges(fix);
5113                }
5114                true
5115            }
5116            Err(e) => {
5117                self.status = Some(format!("edit: {e}"));
5118                false
5119            }
5120        }
5121    }
5122
5123    /// After a structural list edit (a new item, a nest/unnest), renumber the
5124    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
5125    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
5126    /// renumber as its own edit; fold it into the edit that triggered it so the
5127    /// two undo as one, and only when it actually changed the source (a no-op or
5128    /// a caret outside any ordered list must not coalesce the real edit into the
5129    /// step before it).
5130    fn renumber_here(&mut self) {
5131        self.renumber_at(self.caret);
5132    }
5133
5134    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
5135    /// caret — for an edit that leaves the caret one past the item it just wrote,
5136    /// where twig resolves no list to renumber.
5137    fn renumber_at(&mut self, off: usize) {
5138        // The read-only gate — this door reaches twig without the splice.
5139        if self.read_only {
5140            return;
5141        }
5142        let before = self.source.clone();
5143        if self.editor.renumber_ordered_lists(off).is_err() {
5144            return; // not inside an ordered list — nothing to renumber
5145        }
5146        self.refresh();
5147        if self.source != before {
5148            self.coalesce_last_undo();
5149            self.dirty = self.source != self.clean_source;
5150            self.clamp_caret();
5151            self.record_caret();
5152        }
5153    }
5154
5155    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
5156    /// sub-item written directly beneath a text line reparses that text as a
5157    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
5158    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
5159    /// bullets can't underline anything, so swap the dash for a `*`: the item
5160    /// stays an empty nested bullet, the parent stays prose, and the source
5161    /// round-trips instead of hiding a heading the user never asked for. Folded
5162    /// into the triggering edit's undo step, the way renumbering is.
5163    ///
5164    /// Gated on the collapse having actually happened (the swapped dash was
5165    /// swallowed into a `heading`), so a real setext heading the author wrote —
5166    /// or a `- x` with content, which can't underline anything — is never
5167    /// touched. This has to live in the *edit*, not the renderer: leaving the
5168    /// hazardous bytes on disk and only painting over them would ship a file
5169    /// every other CommonMark tool reads as a heading.
5170    ///
5171    /// This one keeps its own byte scan, and has to: the hazard is precisely
5172    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
5173    /// which asks twig which lines open an item — reports nothing here. There is
5174    /// no node to ask about. It is also the last Markdown spelling leaf writes on
5175    /// purpose rather than for want of an answer; once twig spells continuations
5176    /// itself, avoiding the trap becomes twig's, and this goes.
5177    ///
5178    /// [`list_marker_on_line`]: Self::list_marker_on_line
5179    fn avoid_setext_collapse(&mut self) {
5180        let caret = self.caret.min(self.source.len());
5181        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
5182        let bytes = self.source.as_bytes();
5183        let mut dash = line_start;
5184        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
5185            dash += 1;
5186        }
5187        // A dash bullet is the only marker that doubles as a setext underline.
5188        if bytes.get(dash) != Some(&b'-') {
5189            return;
5190        }
5191        // Only an *empty* item is a bare underline; `- x` carries content and
5192        // can't fold the line above into a heading.
5193        let line_end = self.source[dash..]
5194            .find('\n')
5195            .map_or(self.source.len(), |i| dash + i);
5196        if !self.source[dash + 1..line_end].trim().is_empty() {
5197            return;
5198        }
5199        // The tell: that dash was swallowed into a `heading`. A properly nested
5200        // empty item sits under a `list_item`, with no heading in reach. Probe
5201        // the dash byte itself (well inside the heading), not the caret, whose
5202        // end-of-line offset can fall on the half-open span boundary.
5203        let collapsed = self
5204            .editor
5205            .ancestors_at(dash)
5206            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
5207            .unwrap_or(false);
5208        if !collapsed {
5209            return;
5210        }
5211        let caret = self.caret;
5212        if self.splice(dash, dash + 1, "*", EditKind::Other) {
5213            // Same width, so the caret keeps its column; fold into the edit that
5214            // triggered this so Tab stays one undo step.
5215            self.coalesce_last_undo();
5216            self.caret = caret.min(self.source.len());
5217            self.clamp_caret();
5218            self.record_caret();
5219        }
5220    }
5221
5222    fn snapshot(&self) -> CaretState {
5223        CaretState {
5224            caret: self.caret,
5225            anchor: self.anchor,
5226        }
5227    }
5228
5229    /// Hand twig the current caret and selection as the blob for the live
5230    /// document state. Called before an edit — so the step twig retires records
5231    /// where the caret was, and undo can restore it — and again once the op has
5232    /// placed the caret, so redo restores where the edit left it.
5233    ///
5234    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
5235    /// caret through its own history, so coalescing falls out for free (folding
5236    /// two twig steps into one drops the intermediate blob, keeping the run's
5237    /// first) and the parallel stacks that had to march in lockstep — and could
5238    /// silently drift out of it — are gone.
5239    fn record_caret(&mut self) {
5240        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
5241    }
5242
5243    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
5244    /// the toggled region selected so a second press cleanly reverses it.
5245    pub fn toggle(&mut self, kind: InlineKind) {
5246        // The read-only gate — this door reaches twig without the splice.
5247        if self.read_only {
5248            return;
5249        }
5250        // Ahead of the no-selection branch below: arming a mark for text not yet
5251        // typed is a promise `insert` cannot keep in a format with no delimiters
5252        // to spell it with. Per *kind*, not per format — Markdown spells five
5253        // of the eight marks (highlight among them, under the `highlight`
5254        // extension leaf parses with), djot all eight, HTML seven.
5255        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
5256            return;
5257        }
5258        let Some((s, e)) = self.selection() else {
5259            // No selection: arm the mark for the next text typed here, the way a
5260            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
5261            // in the flow of typing without ever selecting anything — the delta
5262            // is realised onto the freshly typed text by `insert`. A fresh caret
5263            // position starts the delta over from the marks actually in force.
5264            if self.pending_at != Some(self.caret) {
5265                self.pending_marks = InlineMarks::empty();
5266                self.pending_at = Some(self.caret);
5267            }
5268            self.pending_marks.flip(kind);
5269            self.status = None;
5270            return;
5271        };
5272        // Whitespace at the edge of a selection is not part of what was chosen —
5273        // a double-click takes the space after the word with it — and a mark
5274        // cannot close against one anyway: `**word **` is four literal asterisks
5275        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
5276        let picked = &self.source[s..e];
5277        let (s, e) = (
5278            s + (picked.len() - picked.trim_start().len()),
5279            e - (picked.len() - picked.trim_end().len()),
5280        );
5281        if s >= e {
5282            self.status = Some(format!("{kind:?}: nothing selected to mark"));
5283            return;
5284        }
5285        // Styling a selection is a one-shot act, not a sticky mode.
5286        self.clear_pending();
5287        self.record_caret();
5288        match self.editor.toggle_inline(s, e, kind) {
5289            Ok(change) => {
5290                self.last_edit_kind = None; // structural edit is its own undo step
5291                self.refresh();
5292                self.anchor = Some(change.new.start);
5293                self.caret = change.new.end;
5294                self.dirty = self.source != self.clean_source;
5295                self.status = None;
5296                self.record_caret();
5297            }
5298            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5299        }
5300    }
5301
5302    /// Whether the caret stands in a highlight — what a frontend asks to enable
5303    /// or disable its highlight-colour controls, the way
5304    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
5305    ///
5306    /// A fact about the *caret*, and the other half of
5307    /// [`Capabilities::mark_color`], which is the fact about the format. A
5308    /// frontend needs both: djot spells a highlight and no colour for it, so a
5309    /// caret standing in `{=word=}` answers `true` here and still has no palette
5310    /// to offer.
5311    ///
5312    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
5313    /// the palette appears exactly where the Highlight button is lit — with one
5314    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
5315    /// into lights the button and answers `false` here, because there is no node
5316    /// to colour until the text exists.
5317    pub fn caret_in_mark(&mut self) -> bool {
5318        self.mark_offset().is_some()
5319    }
5320
5321    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
5322    /// standing in the highlight the gesture means — or `None` when neither end
5323    /// of what is selected is in one.
5324    ///
5325    /// The caret first, and the selection's *start* after it, because of what
5326    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
5327    /// whole, with the caret at its far edge, one past the closing `==` and so
5328    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
5329    /// and colour it — the two presses a coloured highlight is made of — would
5330    /// otherwise refuse on the second, having just written the highlight the
5331    /// author is pointing at.
5332    fn mark_offset(&mut self) -> Option<usize> {
5333        let in_mark = |d: &mut Self, off: usize| {
5334            d.marks_at(off)
5335                .into_iter()
5336                .any(|(k, _)| k == InlineKind::Mark)
5337                .then_some(off)
5338        };
5339        let caret = self.caret.min(self.source.len());
5340        in_mark(self, caret).or_else(|| {
5341            let start = self.selection()?.0;
5342            in_mark(self, start)
5343        })
5344    }
5345
5346    /// The colour of the highlight at the caret — `None` both when the caret is
5347    /// in no highlight and when the highlight it is in names no colour, which
5348    /// are the same answer to "which swatch is lit".
5349    ///
5350    /// The innermost mark, by span, for the same reason
5351    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
5352    /// what the caret is *in* is the deepest node containing it. A `data-color`
5353    /// naming a colour this build has no variant for reads as `None` — the
5354    /// renderer already draws that as a plain highlight rather than guessing,
5355    /// and the toolbar agrees with the renderer.
5356    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
5357        let at = self.mark_offset()?;
5358        self.mark_color_at(at)
5359    }
5360
5361    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
5362    /// the innermost `mark` covering it, and the colour it names.
5363    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
5364        self.nodes()
5365            .into_iter()
5366            .filter(|n| n.kind == Kind::Mark)
5367            .filter(|n| n.span.start <= off && off < n.span.end)
5368            .min_by_key(|n| n.span.end - n.span.start)
5369            .and_then(|n| MarkColor::from_attrs(&n.attrs))
5370    }
5371
5372    /// Colour the highlight at the caret, or clear its colour with `None` — the
5373    /// palette behind a toolbar's Highlight button.
5374    ///
5375    /// Markdown only, and the one gesture whose availability is a fact about the
5376    /// *parse extensions* rather than about the format alone: the colour is
5377    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
5378    /// records as the mark's `data-color`, and only an editor parsing with
5379    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
5380    /// document) reads it back that way. Djot spells the highlight and no colour
5381    /// for it, so this refuses there — see [`Capabilities::mark_color`].
5382    ///
5383    /// **A colour is a property of a highlight that already exists.** There is
5384    /// no "highlight this in red" here, because that is two splices and would be
5385    /// two undo steps under one press; a frontend that wants it calls
5386    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
5387    /// order the two buttons already sit in. With no highlight at the caret this
5388    /// says so in the status line and writes nothing.
5389    ///
5390    /// The caret keeps its place in the *text*: the splice is entirely in the
5391    /// prefix between the opening `==` and the first word, so an offset past it
5392    /// rides the emoji's width, and one standing on the prefix itself lands
5393    /// where the prefix now ends.
5394    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
5395        // The read-only gate — this door reaches twig without the splice.
5396        if self.read_only {
5397            return;
5398        }
5399        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
5400            return;
5401        }
5402        let Some(at) = self.mark_offset() else {
5403            self.status = Some("highlight colour: no highlight at the caret".into());
5404            return;
5405        };
5406        // Clearing a colour a highlight hasn't got is twig's one *successful*
5407        // no-op, and the `Change` it hands back then describes whatever edit came
5408        // before it — a stale span that would drag the caret somewhere it never
5409        // was. Answer it here, where the question is cheap, rather than trusting
5410        // a change that isn't one.
5411        if color.is_none() && self.mark_color_at(at).is_none() {
5412            self.status = None;
5413            return;
5414        }
5415        self.record_caret();
5416        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
5417            Ok(change) => {
5418                // Re-anchored from the offsets as they were, *before* `refresh`
5419                // sees the new bytes: the caret it clamps is one standing inside
5420                // a prefix that didn't exist a moment ago, and walking it back to
5421                // a char boundary of the emoji loses the place this is restoring.
5422                let caret = reanchor(self.caret, &change);
5423                let anchor = self.anchor.map(|a| reanchor(a, &change));
5424                self.last_edit_kind = None; // structural edit is its own undo step
5425                self.refresh();
5426                self.caret = caret;
5427                self.anchor = anchor;
5428                self.dirty = self.source != self.clean_source;
5429                self.status = None;
5430                self.clamp_caret();
5431                self.record_caret();
5432            }
5433            Err(e) => self.status = Some(format!("highlight colour: {e}")),
5434        }
5435    }
5436
5437    /// One press of a colour swatch: colour the highlight at the caret, or —
5438    /// over a selection that isn't highlighted yet — highlight it and colour it,
5439    /// as **one** undo step.
5440    ///
5441    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
5442    /// one splice; this is the compound every toolbar actually presses, and it
5443    /// lives here rather than in each frontend because the rule it encodes —
5444    /// what a swatch means when there is no highlight under it yet — is one
5445    /// answer, not one per frontend. The two splices are folded into a single
5446    /// history step, so the press that made a red highlight is taken back by a
5447    /// single undo rather than leaving an uncoloured one behind.
5448    ///
5449    /// `None` clears the colour, and over an unhighlighted selection means
5450    /// simply "highlight this" — the same thing the Highlight button does.
5451    /// A bare caret in no highlight is left alone with a status line, because
5452    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
5453    /// colour cannot be armed with it.
5454    pub fn highlight(&mut self, color: Option<MarkColor>) {
5455        if self.caret_in_mark() || self.selection().is_none() {
5456            self.set_mark_color(color);
5457            return;
5458        }
5459        self.toggle(InlineKind::Mark);
5460        // The format may not spell a highlight at all (`toggle` said so), and
5461        // there is nothing to colour if it doesn't.
5462        if self.status.is_some() {
5463            return;
5464        }
5465        let before = self.revision;
5466        self.set_mark_color(color);
5467        // Only fold when the colour really spliced. `highlight(None)` over a
5468        // fresh highlight is a no-op by design, and coalescing there would eat
5469        // the *previous* edit into the toggle instead.
5470        if self.revision != before {
5471            self.coalesce_last_undo();
5472        }
5473    }
5474
5475    // ── the presentation vocabulary ─────────────────────────────────────────
5476    //
5477    // Six gestures and five queries over twig's two attribute ops. Each gesture
5478    // edits **one key and keeps the rest**: it reads the node's attributes,
5479    // removes its own key (and, for alignment, its own tokens out of `class`),
5480    // adds the new value or nothing, and passes the list back whole — twig's
5481    // contract is replace-not-merge, so the read is the caller's job. A
5482    // paragraph that came in as `class="lead center" id="intro"
5483    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
5484    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
5485    // touched, which is what lets a document from elsewhere pass through the
5486    // editor unharmed.
5487    //
5488    // Clearing is the same gesture with `None`: the key goes, and an empty list
5489    // at the end unwraps the span or the Markdown div, which twig does.
5490
5491    /// Set — or with `None` clear — the alignment of the block the caret is in.
5492    ///
5493    /// A block property, so the gesture is `set_block_attrs` on the caret's
5494    /// block **whatever is selected**: a line is a block's, and "centre this"
5495    /// with three words selected means the paragraph, not the words. The
5496    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
5497    /// same `class` are kept.
5498    ///
5499    /// In Markdown the attributes live on a `<div>` around the block — twig has
5500    /// no paragraph attribute syntax to write — and this reads them back off
5501    /// that div when the block is its sole child, so a second press rewrites
5502    /// the div rather than nesting a second one.
5503    pub fn set_alignment(&mut self, align: Option<Align>) {
5504        let attrs = self.block_attrs_at_caret();
5505        if align.is_none()
5506            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
5507        {
5508            return;
5509        }
5510        let attrs = with_class_token(
5511            &attrs,
5512            |t| Align::from_token(t).is_some(),
5513            align.map(Align::name),
5514        );
5515        self.write_block_attrs("alignment", attrs);
5516    }
5517
5518    /// Set — or with `None` clear — the line spacing of the block the caret is
5519    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
5520    /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
5521    /// three names or an exact ratio, written in its canonical spelling.
5522    pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
5523        let attrs = self.block_attrs_at_caret();
5524        if spacing.is_none()
5525            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
5526                LineHeight::from_attrs(a).is_some()
5527            })
5528        {
5529            return;
5530        }
5531        let spelling = spacing.map(LineHeight::name);
5532        let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
5533        self.write_block_attrs("line spacing", attrs);
5534    }
5535
5536    /// Set — or with `None` clear — the size of the selected run, or of the
5537    /// caret's whole block when nothing is selected.
5538    ///
5539    /// Size, face and colour are the *run's*, and the block's when no run is
5540    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
5541    /// the range in an attributed span or re-styles the span it already lies in
5542    /// (never nesting a second, and unwrapping it when the last key goes). With
5543    /// no selection it is `set_block_attrs` on the caret's block, so that "make
5544    /// this paragraph larger" is a click with the caret in it rather than a
5545    /// select-all first.
5546    ///
5547    /// The walker reads the key at both levels with the nearer winning, so a
5548    /// span's `data-size` inside a block carrying its own applies to the span.
5549    ///
5550    /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
5551    /// what a menu offers first because a step reads as a step up under every
5552    /// theme, or the point size an author asked for, which is exact and is all
5553    /// it is. Either is written in its canonical spelling, so a size set twice
5554    /// from the same field writes the same bytes both times.
5555    pub fn set_font_size(&mut self, size: Option<FontSize>) {
5556        let spelling = size.map(FontSize::name);
5557        self.set_run_attr("size", "data-size", spelling.as_deref());
5558    }
5559
5560    /// Set — or with `None` clear — the face of the selected run, or of the
5561    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
5562    /// [`FontFace`] under `data-font` — one of the four generics, or the family
5563    /// the author named, which the frontends resolve through the platform's
5564    /// font registry and fall back to the body face without.
5565    pub fn set_font_family(&mut self, font: Option<FontFace>) {
5566        let spelling = font.as_ref().map(FontFace::name);
5567        self.set_run_attr("font", "data-font", spelling.as_deref());
5568    }
5569
5570    /// Set — or with `None` clear — the *text* colour of the selected run, or of
5571    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
5572    /// with [`TextColor`] under `data-color` — one of the seven names, whose
5573    /// two inks the theme owns, or the triple the author picked, which is
5574    /// painted as written in both appearances.
5575    ///
5576    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
5577    /// writes, and a different thing: that one colours a highlight's
5578    /// *background* and rides the `mark` node twig owns the spelling of, this
5579    /// one colours the letters and rides an attributed span. The two never
5580    /// collide, because a `mark` is a `mark` and a span is a span — and they
5581    /// share a vocabulary on purpose, so that a frontend with a red for a
5582    /// highlight has a red for text and both are *that* red.
5583    pub fn set_text_color(&mut self, color: Option<TextColor>) {
5584        let spelling = color.map(TextColor::name);
5585        self.set_run_attr("text colour", "data-color", spelling.as_deref());
5586    }
5587
5588    /// Insert a page break at the caret — `::page-break`, a leaf directive with
5589    /// no label and no attributes, which twig spells in every format that names
5590    /// a leaf container (Markdown under the `directives` extension
5591    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
5592    /// page-break` fence), and HTML and AsciiDoc in spellings of their own that
5593    /// the walker reads back for this one name.
5594    ///
5595    /// [`insert_directive`](Self::insert_directive) with [`PAGE_BREAK`], gated
5596    /// on [`Capabilities::page_break`] rather than
5597    /// [`Capabilities::directives`], since the walker reads HTML's and
5598    /// AsciiDoc's page break back and no other name of theirs.
5599    ///
5600    /// The frontends that paginate read the row's
5601    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
5602    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
5603    /// directive gets.
5604    pub fn insert_page_break(&mut self) {
5605        let supported = self.capabilities().page_break;
5606        self.write_directive("page break", supported, PAGE_BREAK, None, &[]);
5607    }
5608
5609    /// Insert the leaf directive `name` at the caret — `::name[label]{attrs}`
5610    /// in Markdown, an empty `::: name` fence in djot — for a host whose
5611    /// vocabulary it is. leaf draws it as the `⧉` placeholder row and publishes
5612    /// it in [`VisualMap::directives`](crate::wysiwyg::VisualMap::directives),
5613    /// where a host that knows the name paints the real thing.
5614    ///
5615    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
5616    /// places a rule, and for the same reason: a directive is a block, so twig
5617    /// alone has nowhere to put one mid-paragraph and lands it after the
5618    /// caret's whole block. A bare paragraph is therefore parted at the caret
5619    /// first and the directive aimed at the *first* half, and the caret lands
5620    /// on a line under it. A selection is replaced by it. See that method for
5621    /// the whole of the rule, including why a code block, a list item, a table
5622    /// and a setext heading are left unsplit.
5623    ///
5624    /// `attrs` are `(key, value)` pairs in the order they are written, a `None`
5625    /// value a bare attribute — the shape
5626    /// [`DirectiveMark::attrs`](crate::wysiwyg::DirectiveMark::attrs) reads
5627    /// back.
5628    ///
5629    /// Refused, with a status and nothing written, where
5630    /// [`Capabilities::directives`] is `false`, and for a `name` or `label`
5631    /// twig will not write: a name is an ASCII letter followed by letters,
5632    /// digits, `-` and `_`, and a label may hold no line end and no square
5633    /// bracket. twig is the judge of both, asked before anything is parted.
5634    ///
5635    /// djot needs two things of its own, since its spelling is a fence under
5636    /// an attribute line. A bare attribute is written `key=""`: twig writes
5637    /// `{wide}`, which djot does not read as an attribute at all, and
5638    /// Markdown reads its own `{wide}` back as `wide=""` regardless. And a
5639    /// label is refused, because djot has nowhere to put one: twig writes it
5640    /// as the fence's body, which reads back as a container holding a
5641    /// paragraph and not as a directive.
5642    pub fn insert_directive(
5643        &mut self,
5644        name: &str,
5645        label: Option<&str>,
5646        attrs: &[(String, Option<String>)],
5647    ) {
5648        let supported = self.capabilities().directives;
5649        if self.format != Format::Djot {
5650            self.write_directive("directive", supported, name, label, attrs);
5651            return;
5652        }
5653        if label.is_some_and(|l| !l.is_empty()) {
5654            if !self.read_only {
5655                self.status = Some("directive: djot has no label for a leaf directive".into());
5656            }
5657            return;
5658        }
5659        let attrs: Attrs = attrs
5660            .iter()
5661            .map(|(k, v)| (k.clone(), Some(v.clone().unwrap_or_default())))
5662            .collect();
5663        self.write_directive("directive", supported, name, None, &attrs);
5664    }
5665
5666    /// The gesture behind [`insert_directive`](Self::insert_directive) and
5667    /// [`insert_page_break`](Self::insert_page_break), which differ in the
5668    /// word a refusal uses and in the capability that gates them.
5669    fn write_directive(
5670        &mut self,
5671        what: &str,
5672        supported: bool,
5673        name: &str,
5674        label: Option<&str>,
5675        attrs: &[(String, Option<String>)],
5676    ) {
5677        if self.read_only || self.refuse_unless(what, supported) {
5678            return;
5679        }
5680        let attrs: Vec<(&str, Option<&str>)> = attrs
5681            .iter()
5682            .map(|(k, v)| (k.as_str(), v.as_deref()))
5683            .collect();
5684        // Ask twig first, on an empty document of the same format, so that a
5685        // name or label it refuses is refused before the selection is cut or
5686        // the paragraph parted: the refusal writes nothing at all. An empty
5687        // document is a fine place for any directive the format spells, so
5688        // the only thing this can refuse is the name, the label or the
5689        // attributes.
5690        if let Err(e) = new_editor(b"", self.format)
5691            .map_err(|e| e.to_string())
5692            .and_then(|mut scratch| {
5693                scratch
5694                    .insert_directive(0, name, label, &attrs)
5695                    .map_err(|e| e.to_string())
5696            })
5697        {
5698            self.status = Some(format!("{what}: {e}"));
5699            return;
5700        }
5701        self.caret = self.skip_trailing_close_delims(self.caret);
5702        // A selection is replaced by the directive, as a rule replaces one.
5703        if let Some((s, e)) = self.selection() {
5704            self.splice(s, e, "", EditKind::Other);
5705        }
5706        self.anchor = None;
5707        self.record_caret();
5708        let at = self.caret;
5709        // A failure here is not fatal: the directive still lands after the
5710        // block, which is what this call was trying to improve on.
5711        let parted = self.part_for_block(at);
5712        match self.editor.insert_directive(at, name, label, &attrs) {
5713            Ok(change) => {
5714                self.last_edit_kind = None;
5715                self.refresh();
5716                if parted {
5717                    self.coalesce_last_undo();
5718                }
5719                self.anchor = None;
5720                self.caret = change.new.end;
5721                self.dirty = self.source != self.clean_source;
5722                self.status = None;
5723                self.clamp_caret();
5724                self.record_caret();
5725                self.caret_under_written_block(change.new, parted);
5726            }
5727            Err(e) => self.status = Some(format!("{what}: {e}")),
5728        }
5729    }
5730
5731    /// The alignment in force at the caret, or `None` for the theme's default —
5732    /// which swatch of an alignment control is lit.
5733    ///
5734    /// Read off the nearest node that names one: the block the caret is in, and
5735    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
5736    /// shape, one property along.
5737    pub fn alignment_at_caret(&mut self) -> Option<Align> {
5738        self.presentation_chain()
5739            .iter()
5740            .find_map(|attrs| Align::from_attrs(attrs))
5741    }
5742
5743    /// The line spacing in force at the caret, or `None` for the theme's own.
5744    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
5745    pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
5746        self.presentation_chain()
5747            .iter()
5748            .find_map(|attrs| LineHeight::from_attrs(attrs))
5749    }
5750
5751    /// The size in force at the caret, or `None` for the theme's own — the
5752    /// entry a size menu shows ticked.
5753    ///
5754    /// Run-level, so the chain starts one node deeper: the attributed span the
5755    /// caret stands in, then its block, then the `div`s around it. The nearest
5756    /// wins, which is the rule the walker draws by.
5757    ///
5758    /// A name or a value, whichever the nearest node wrote. A `data-size` the
5759    /// grammar does not cover — a `huge` from elsewhere — is not a size this
5760    /// can answer, so the answer is `None` and the menu ticks *Default*, the
5761    /// same thing it did before the vocabulary opened.
5762    pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
5763        self.presentation_chain()
5764            .iter()
5765            .find_map(|attrs| FontSize::from_attrs(attrs))
5766    }
5767
5768    /// The face in force at the caret, or `None` for the theme's body face.
5769    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
5770    pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
5771        self.presentation_chain()
5772            .iter()
5773            .find_map(|attrs| FontFace::from_attrs(attrs))
5774    }
5775
5776    /// The *text* colour in force at the caret, or `None` for the theme's.
5777    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
5778    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
5779    /// highlight's background off a `mark`, and a `mark` is never in this chain.
5780    pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
5781        self.presentation_chain()
5782            .iter()
5783            .find_map(|attrs| TextColor::from_attrs(attrs))
5784    }
5785
5786    /// The selection-or-caret half of the three run-level gestures: a span over
5787    /// a real selection, the caret's block over none.
5788    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
5789        match self.selection() {
5790            Some((start, end)) => {
5791                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
5792                self.write_run_attrs(what, start, end, attrs);
5793            }
5794            None => {
5795                let own = self.block_attrs_at_caret();
5796                if value.is_none()
5797                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
5798                {
5799                    return;
5800                }
5801                let attrs = with_attr(&own, key, value);
5802                self.write_block_attrs(what, attrs);
5803            }
5804        }
5805    }
5806
5807    /// A clear this gesture cannot carry out, said out loud instead of written:
5808    /// the node it rewrites — the caret's block, or the `<div>` around it that
5809    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
5810    /// — does not name the property at all, and a `div` further out does.
5811    ///
5812    /// Handing twig the block's attributes with the key already absent changes
5813    /// no byte, and the query goes on answering `Some` off the div: the menu
5814    /// entry the author pressed stays unticked, and nothing says why. Twig's
5815    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
5816    /// write on a node it is not rewriting — the honest answer is the status
5817    /// line, in the voice the other refusals use.
5818    ///
5819    /// `names` is the property's own reading of an attribute list, because
5820    /// alignment lives in a `class` token rather than a key of its own. Spans
5821    /// are skipped: one inside the block is not what a *block* gesture writes
5822    /// either, but neither is it "the div around the block", and the run-level
5823    /// gestures reach it through a selection.
5824    fn refuse_clear_from_div(
5825        &mut self,
5826        what: &str,
5827        own: &Attrs,
5828        names: impl Fn(&Attrs) -> bool,
5829    ) -> bool {
5830        if names(own) {
5831            return false;
5832        }
5833        let caret = self.caret.min(self.source.len());
5834        if !self
5835            .attr_chain_at(caret)
5836            .iter()
5837            .any(|(span, attrs)| !span && names(attrs))
5838        {
5839            return false;
5840        }
5841        self.status = Some(format!("{what}: set on the div around the block"));
5842        true
5843    }
5844
5845    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
5846    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
5847    /// has.
5848    ///
5849    /// **The caret keeps its place in the text, not its byte offset.** How a
5850    /// format spells a block's attributes is markup written *around* the block
5851    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
5852    /// it in Markdown, a longer opening tag in HTML — and every one of those
5853    /// grows or shrinks above the author's own bytes. Where twig's change
5854    /// rewrites the block whole (Markdown's div is spliced as one region, block
5855    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
5856    /// and parks the caret at the end of the splice, past the closing `</div>`:
5857    /// the caret is then in no block at all, so a second press of the same menu
5858    /// answers "no block at the caret" and the toolbar's queries read nothing.
5859    /// [`reanchor_in_block`] is what carries it across instead — the block's
5860    /// content span before and after, which is the one thing the respelling
5861    /// leaves alone.
5862    ///
5863    /// Read *before* the splice and applied *after* `refresh`, because both
5864    /// halves of that mapping are facts about a tree twig is between: the
5865    /// block's old bytes are gone once the edit lands, and its new ones are not
5866    /// in `self.source` until the refresh puts them there.
5867    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
5868        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
5869            return;
5870        }
5871        // A blank line has no block to carry an attribute, and twig answers
5872        // `NotFound` there — say so in leaf's own words instead.
5873        let Some(at) = self.block_offset_for_caret() else {
5874            self.status = Some(format!("{what}: no block at the caret"));
5875            return;
5876        };
5877        self.record_caret();
5878        let pairs = attr_pairs(&attrs);
5879        let was = self.block_content_at(at);
5880        let text = was.clone().map(|s| self.source[s].to_string());
5881        match self.editor.set_block_attrs(at, &pairs) {
5882            Ok(change) => {
5883                let (caret, anchor) = (self.caret, self.anchor);
5884                self.last_edit_kind = None; // structural edit is its own undo step
5885                self.refresh();
5886                let now = self.block_content_in(&change.new, text.as_deref());
5887                // A block the two halves cannot both name — a code block, a
5888                // caret in a list's marker — takes the plain arithmetic, which
5889                // is what it had before.
5890                let block = was.as_ref().zip(now.as_ref());
5891                self.caret = reanchor_in_block(caret, &change, block);
5892                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
5893                self.dirty = self.source != self.clean_source;
5894                self.status = None;
5895                // The clamp reads the caret floor off the map, and this edit
5896                // can move the floor: taking the `{…}` line off a djot
5897                // document's first block moves the first rendered offset to 0,
5898                // and a floor read from the old map stood the caret past the
5899                // block's text. So the map is this revision's before the clamp
5900                // — see `open_paragraph_at_block_edge`.
5901                self.rebuild_map();
5902                self.clamp_caret();
5903                self.record_caret();
5904            }
5905            Err(e) => self.status = Some(format!("{what}: {e}")),
5906        }
5907    }
5908
5909    /// The content span of the innermost paragraph or heading covering `off` —
5910    /// the author's own bytes, without the `# ` or the `<p>` that spells the
5911    /// block around them.
5912    ///
5913    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
5914    /// reads, so that what a gesture re-anchors by is the block it wrote to.
5915    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
5916        self.nodes()
5917            .into_iter()
5918            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5919            .filter(|n| n.span.start <= off && off <= n.span.end)
5920            .min_by_key(|n| n.span.end - n.span.start)
5921            .map(|n| n.content_span.unwrap_or(n.span))
5922    }
5923
5924    /// [`block_content_at`](Self::block_content_at)'s other half: the content
5925    /// span of the block `region` holds now, found by the bytes it held before.
5926    ///
5927    /// Matched on the text rather than taken as the first block in the region,
5928    /// because a rewritten region is markup and all — `<div class="center">`
5929    /// carries words of its own — and because the block this gesture moved is
5930    /// the one whose content the respelling did not touch. `None` where the
5931    /// region holds no block at all, which is djot's every case: the `{…}` line
5932    /// is spliced above the block and the block itself never moves through the
5933    /// change at all, only past it.
5934    fn block_content_in(
5935        &mut self,
5936        region: &Range<usize>,
5937        text: Option<&str>,
5938    ) -> Option<Range<usize>> {
5939        let text = text?;
5940        let spans: Vec<Range<usize>> = self
5941            .nodes()
5942            .into_iter()
5943            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5944            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
5945            .map(|n| n.content_span.unwrap_or(n.span))
5946            .collect();
5947        spans
5948            .into_iter()
5949            .find(|s| self.source.get(s.clone()) == Some(text))
5950    }
5951
5952    /// Hand `attrs` to twig as the attribute set of the span over `[start,
5953    /// end)` — wrapping one, or re-styling the one the range already lies in,
5954    /// or unwrapping it when `attrs` is empty.
5955    ///
5956    /// What the splice leaves selected is the span's **content** — the author's
5957    /// words — and not the whole of `change.new`, which is markup and all:
5958    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
5959    /// selection reaching past the node's own span lies in no span at all, so a
5960    /// second press of the menu would nest a fresh one instead of re-styling
5961    /// the one just written.
5962    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
5963        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
5964            return;
5965        }
5966        self.record_caret();
5967        let pairs = attr_pairs(&attrs);
5968        match self.editor.wrap_range_attrs(start, end, &pairs) {
5969            Ok(change) => {
5970                self.last_edit_kind = None;
5971                self.refresh();
5972                let content = self.span_content_in(&change.new);
5973                self.anchor = Some(content.start);
5974                self.caret = content.end;
5975                self.dirty = self.source != self.clean_source;
5976                self.status = None;
5977                self.clamp_caret();
5978                self.record_caret();
5979            }
5980            Err(e) => self.status = Some(format!("{what}: {e}")),
5981        }
5982    }
5983
5984    /// The content range of the attributed span `spliced` now holds — the
5985    /// outermost one inside it, since that is the one just written — or
5986    /// `spliced` itself where the splice left no span, which is what an unwrap
5987    /// leaves behind.
5988    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
5989        self.nodes()
5990            .into_iter()
5991            .filter(wysiwyg::is_run_span)
5992            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
5993            .max_by_key(|n| n.span.end - n.span.start)
5994            .and_then(|n| n.content_span)
5995            .unwrap_or_else(|| spliced.clone())
5996    }
5997
5998    /// The attribute set `set_block_attrs` is about to **replace** at the caret
5999    /// — which is the block's own, except in Markdown, where twig writes a
6000    /// block's attributes onto a `<div>` around it and rewrites that div when
6001    /// the block is its sole child. Reading the paragraph there would hand back
6002    /// an empty list and quietly drop everything the div said.
6003    ///
6004    /// Empty when the caret is in no block at all, which is the same list a
6005    /// block carrying no attributes gives — and the right one either way, since
6006    /// the gesture then refuses on its own.
6007    fn block_attrs_at_caret(&mut self) -> Attrs {
6008        let Some(off) = self.block_offset_for_caret() else {
6009            return Vec::new();
6010        };
6011        let nodes = self.nodes();
6012        let Some(block) = nodes
6013            .iter()
6014            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
6015            .filter(|n| n.span.start <= off && off <= n.span.end)
6016            .min_by_key(|n| n.span.end - n.span.start)
6017        else {
6018            return Vec::new();
6019        };
6020        if self.format == Format::Markdown
6021            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
6022            && wysiwyg::element_tag(parent) == Some("div")
6023            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
6024        {
6025            return parent.attrs.clone();
6026        }
6027        block.attrs.clone()
6028    }
6029
6030    /// The attribute set `wrap_range_attrs` is about to **replace** over
6031    /// `[start, end)` — the innermost attributed span the range lies inside,
6032    /// which twig re-styles rather than nesting a second one in. Empty when the
6033    /// range lies in no span, where the gesture mints a fresh one.
6034    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
6035        self.nodes()
6036            .into_iter()
6037            .filter(wysiwyg::is_run_span)
6038            .filter(|n| n.span.start <= start && end <= n.span.end)
6039            .min_by_key(|n| n.span.end - n.span.start)
6040            .map(|n| n.attrs)
6041            .unwrap_or_default()
6042    }
6043
6044    /// The attribute lists that bear on a presentation query, **nearest first**:
6045    /// the attributed spans the caret stands in (innermost first), then its
6046    /// block, then the `div`s around it. A `find_map` down this is the whole of
6047    /// each query, and the order is the rule the walker draws by.
6048    ///
6049    /// Read at the caret, and at the selection's *start* when the caret stands
6050    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
6051    /// caret one past the span it just wrote — `toggle`'s convention — so
6052    /// asking the menu which entry that press just ticked must not answer
6053    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
6054    fn presentation_chain(&mut self) -> Vec<Attrs> {
6055        let caret = self.caret.min(self.source.len());
6056        let mut chain = self.attr_chain_at(caret);
6057        if !chain.iter().any(|(span, _)| *span)
6058            && let Some((start, _)) = self.selection()
6059        {
6060            let alt = self.attr_chain_at(start);
6061            if alt.iter().any(|(span, _)| *span) {
6062                chain = alt;
6063            }
6064        }
6065        chain.into_iter().map(|(_, attrs)| attrs).collect()
6066    }
6067
6068    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
6069    /// node bearing the vocabulary that covers it, innermost first, each paired
6070    /// with whether it is an attributed span (which is what tells the caller
6071    /// its run-level answer came from a run).
6072    ///
6073    /// Sorted by span length, which *is* the nesting order: a span lies inside
6074    /// its block and a block inside its div, so shortest-first is
6075    /// nearest-first without a second tree walk.
6076    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
6077        let off = off.min(self.source.len());
6078        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
6079        for n in self.nodes() {
6080            let span = wysiwyg::is_run_span(&n);
6081            let block = matches!(n.kind, Kind::Para | Kind::Heading);
6082            let div = wysiwyg::element_tag(&n) == Some("div");
6083            if !(span || block || div) {
6084                continue;
6085            }
6086            // A span is half-open, the way a mark is: the offset one past it is
6087            // the text after it. A block and a div claim their end too, so a
6088            // caret resting at the end of a line still reads its paragraph.
6089            let inside = if span {
6090                n.span.start <= off && off < n.span.end
6091            } else {
6092                n.span.start <= off && off <= n.span.end
6093            };
6094            if !inside {
6095                continue;
6096            }
6097            hits.push((n.span.end - n.span.start, span, n.attrs));
6098        }
6099        hits.sort_by_key(|(len, _, _)| *len);
6100        hits.into_iter()
6101            .map(|(_, span, attrs)| (span, attrs))
6102            .collect()
6103    }
6104
6105    /// Convert the block at the caret to a heading level or paragraph.
6106    pub fn set_block(&mut self, kind: BlockKind) {
6107        // The read-only gate — this door reaches twig without the splice.
6108        if self.read_only {
6109            return;
6110        }
6111        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
6112            return;
6113        }
6114        self.record_caret();
6115        // A blank line has no node to convert, and twig opens a block there
6116        // rather than declining — so the caret's own offset is the right thing
6117        // to hand it when `block_offset_for_caret` finds nothing.
6118        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
6119        match self.editor.set_block(offset, kind) {
6120            Ok(change) => {
6121                self.last_edit_kind = None;
6122                self.refresh();
6123                // Opening a block on a blank line writes a marker the caret
6124                // belongs *after*; converting an existing one moves nothing.
6125                self.caret = self.caret.max(change.new.end);
6126                self.clamp_caret();
6127                self.anchor = None;
6128                self.dirty = self.source != self.clean_source;
6129                self.status = None;
6130                self.record_caret();
6131            }
6132            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6133        }
6134    }
6135
6136    /// Whether `off` is inside a text block (paragraph, heading, code block…).
6137    fn has_block_at(&mut self, off: usize) -> bool {
6138        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
6139            chain
6140                .iter()
6141                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6142        })
6143    }
6144
6145    /// The offset to hand twig's `set_block`: the caret when it is already inside
6146    /// a block, otherwise nudged onto the previous character (a caret at a line
6147    /// end sits at the doc level, outside the block). `None` when the caret is on
6148    /// a blank line — a new paragraph with no block node to convert.
6149    fn block_offset_for_caret(&mut self) -> Option<usize> {
6150        let caret = self.caret.min(self.source.len());
6151        if self.has_block_at(caret) {
6152            return Some(caret);
6153        }
6154        // Nudge to the previous character — but never across a newline: that would
6155        // target the previous block, and a blank line genuinely has no block.
6156        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
6157            && ch != '\n'
6158            && self.has_block_at(i)
6159        {
6160            return Some(i);
6161        }
6162        None
6163    }
6164
6165    /// The heading level of the text block at the caret, or `None` when that
6166    /// block is not a heading.
6167    pub fn current_heading_level(&mut self) -> Option<u32> {
6168        let caret = self.caret;
6169        self.nodes()
6170            .into_iter()
6171            .filter(|n| n.kind == Kind::Heading)
6172            .find(|n| n.span.start <= caret && caret <= n.span.end)
6173            .and_then(|n| n.level)
6174    }
6175
6176    /// The inline marks in force at the caret (or over the selection) — what a
6177    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
6178    /// inline counterpart. Cheap enough to call every frame: one twig
6179    /// `ancestors_at` query per caret (two with a selection), each walking root
6180    /// → deepest node at one offset. It never snapshots the tree the way
6181    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
6182    /// the only allocation is twig's own small ancestor `Vec`.
6183    ///
6184    /// **A selection reports a mark only when the mark covers *all* of it.**
6185    /// That's what every real toolbar means by an active button — Bold lit over
6186    /// a half-bold selection would claim a press turns bold *off*, when
6187    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
6188    /// Whole-coverage is asked as "is the same mark node standing over both the
6189    /// first and the last character?": inline nodes are contiguous, so one node
6190    /// covering both ends covers every byte between them. Two touching runs
6191    /// (`**a****b**`) are two nodes, and correctly light nothing.
6192    ///
6193    /// At a bare caret a mark is active when the caret stands inside the mark's
6194    /// span — `span.start <= caret < span.end`, delimiters included, which is
6195    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
6196    /// opening `*` (2) through the last byte of the closing `**` (9) are all
6197    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
6198    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
6199    /// typing would actually land inside the marked run. The offset one past the
6200    /// mark (10) is the text after it and reports nothing, at the end of the
6201    /// buffer exactly as in the middle.
6202    pub fn active_inline_marks(&mut self) -> InlineMarks {
6203        let Some((start, end)) = self.selection() else {
6204            // The marks actually in force at the caret, flipped by any armed
6205            // sticky delta — so `⌘b` at a bare caret lights the Bold button
6206            // immediately, before a single character is typed.
6207            let base: InlineMarks = self
6208                .marks_at(self.caret)
6209                .into_iter()
6210                .map(|(k, _)| k)
6211                .collect();
6212            return base.xor(self.pending_here());
6213        };
6214        // The selection's *last character*, not its exclusive end: `end` is the
6215        // offset one past the selection, which for a selection ending exactly at
6216        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
6217        // entirely bold, but offset 10 is the space after).
6218        let last = prev_boundary(&self.source, end);
6219        let head = self.marks_at(start);
6220        let tail = self.marks_at(last);
6221        head.into_iter()
6222            .filter(|m| tail.contains(m))
6223            .map(|(k, _)| k)
6224            .collect()
6225    }
6226
6227    /// The inline marks whose span covers `off`, each with the id of the node
6228    /// carrying it — the id is what lets a selection tell one mark node from
6229    /// another of the same kind.
6230    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
6231        let off = off.min(self.source.len());
6232        self.editor
6233            .ancestors_at(off)
6234            .unwrap_or_default()
6235            .into_iter()
6236            // `span.end` is the offset one *past* the mark, so it isn't in it.
6237            // twig already resolves a boundary to whatever starts there — in
6238            // `**bold** x` offset 8 is the following text, not the strong — but
6239            // when nothing follows, the tie has nobody to break for and the
6240            // chain still ends at the mark. That would make the answer at the
6241            // last offset of the document depend on whether the file happens to
6242            // end in a newline; the rule is `span.start <= off < span.end`, and
6243            // it's the same rule at the end of a buffer as in the middle.
6244            .filter(|m| off < m.span.end)
6245            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
6246            .collect()
6247    }
6248
6249    /// Toggle a heading at the caret: if the block is already this heading level,
6250    /// revert it to a paragraph; otherwise convert it to this heading level.
6251    /// This gives the heading commands the same toggle feel as bold/italic/code —
6252    /// re-applying a heading a line already has turns it back into body text.
6253    pub fn toggle_heading(&mut self, level: u32) {
6254        if self.current_heading_level() == Some(level) {
6255            self.set_block(BlockKind::Paragraph);
6256        } else {
6257            self.set_block(BlockKind::Heading(level));
6258        }
6259    }
6260
6261    /// Toggle a block quote around the selection, or around the block at the
6262    /// caret — the toolbar's Quote button.
6263    pub fn toggle_blockquote(&mut self) {
6264        self.toggle_container(BlockContainerKind::BlockQuote);
6265    }
6266
6267    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
6268    /// over the block at the caret — one op with the kind as a flag, the way
6269    /// `toggle_heading` takes its level, so a frontend needs no twig type to
6270    /// name the two buttons.
6271    ///
6272    /// Pressing the *other* list's button while in a list converts in place
6273    /// rather than nesting, so the pair reads as one three-state control
6274    /// (bulleted / numbered / neither) rather than two independent wrappers.
6275    pub fn toggle_list(&mut self, ordered: bool) {
6276        self.toggle_container(if ordered {
6277            BlockContainerKind::OrderedList
6278        } else {
6279            BlockContainerKind::BulletList
6280        });
6281    }
6282
6283    /// Toggle a fenced code block over the selection, or over the block at the
6284    /// caret — the toolbar's Code Block button, and the only way the rich view
6285    /// offers to open one: a typed backtick is escaped there, since it is a
6286    /// character the author wrote and not markup they meant.
6287    ///
6288    /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
6289    /// against the body, keeps a quote's `> ` on every line, and peels a fence
6290    /// (or dedents an indented block) on the way back. What is leaf's is the
6291    /// shape twig declines: a blank line holds no block to fence, and the
6292    /// gesture nobody should have to learn is "type something first" — so leaf
6293    /// spells the empty fence itself, with the caret on the empty line inside it
6294    /// and a blank line either side, which is what typing into a new block and
6295    /// then Backspacing out of it would have left.
6296    ///
6297    /// Inside a list item twig fences at the item's content column and keeps
6298    /// the marker (a task item's box too) on the opening fence's line, so the
6299    /// block stays the item's; unfencing puts the marker back on the text.
6300    /// What twig still refuses — an AsciiDoc item's own first line — is
6301    /// reported rather than worked around.
6302    pub fn toggle_code_block(&mut self) {
6303        // The read-only gate — this door reaches twig without the splice.
6304        if self.read_only {
6305            return;
6306        }
6307        if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
6308            return;
6309        }
6310        let selected = self.selection();
6311        // The caret at a code block's rows resolves to its *content*, and twig
6312        // finds the block from any offset inside its span — but a caret parked
6313        // on the closing fence's own line end is past it, so the block's start
6314        // is handed over whenever the caret is in one at all.
6315        let (start, end) = match selected {
6316            Some(range) => range,
6317            None => match self.code_block_start_at_caret() {
6318                Some(start) => (start, start),
6319                None => match self.block_offset_for_caret() {
6320                    Some(off) => (off, off),
6321                    None => return self.open_empty_code_block(),
6322                },
6323            },
6324        };
6325        self.record_caret();
6326        match self.editor.toggle_code_block(start, end, None) {
6327            Ok(change) => {
6328                // Read the caret's place out of the *pre-edit* source, before
6329                // `refresh` swaps it out — and how many lines the region held,
6330                // which is what says whether a fence line went in above the
6331                // caret's line or came off it.
6332                let place = selected
6333                    .is_none()
6334                    .then(|| self.caret_line_tail(&change.old));
6335                let old_lines = self.source[change.old.clone()].matches('\n').count();
6336                self.last_edit_kind = None; // structural edit is its own undo step
6337                self.refresh();
6338                match place {
6339                    // From a selection: keep the block selected, so a second
6340                    // press reverses the first (twig unfences from any offset in
6341                    // the fence's span, the region's start included).
6342                    None => {
6343                        self.anchor = Some(change.new.start);
6344                        self.caret = change.new.end;
6345                    }
6346                    // From a caret: the same line, the same distance from its
6347                    // end, shifted by the opening fence that was written above
6348                    // it (or peeled off). Dedenting an indented block keeps the
6349                    // lines one-to-one, and shifts nothing.
6350                    Some((line, tail)) => {
6351                        let new_lines = self.source[change.new.start.min(self.source.len())
6352                            ..change.new.end.min(self.source.len())]
6353                            .matches('\n')
6354                            .count();
6355                        let line = match new_lines.cmp(&old_lines) {
6356                            std::cmp::Ordering::Greater => line + 1,
6357                            std::cmp::Ordering::Less => line.saturating_sub(1),
6358                            std::cmp::Ordering::Equal => line,
6359                        };
6360                        self.anchor = None;
6361                        self.caret = self.line_tail_offset(&change.new, (line, tail));
6362                    }
6363                }
6364                self.dirty = self.source != self.clean_source;
6365                self.status = None;
6366                self.clamp_caret();
6367                self.record_caret();
6368            }
6369            Err(e) => self.status = Some(format!("code block: {e}")),
6370        }
6371    }
6372
6373    /// Write an empty fenced block on the blank line the caret stands on, and
6374    /// put the caret on the empty line inside it — the half of
6375    /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
6376    /// twig reports `NotFound` for a range no block covers.
6377    ///
6378    /// Inside a quote the fence lines and the empty line keep the quote's
6379    /// prefix, as twig's own fencing does. A blank line goes in on whichever
6380    /// side has text against it, since a fence may interrupt a paragraph in
6381    /// Markdown but a block standing tight against its neighbours is not the
6382    /// document any editor writes.
6383    fn open_empty_code_block(&mut self) {
6384        let caret = self.caret.min(self.source.len());
6385        let prefix = self.quote_prefix_at(caret);
6386        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
6387        let line_end = self.source[caret..]
6388            .find('\n')
6389            .map_or(self.source.len(), |i| caret + i);
6390        let prev_blank = line_start == 0
6391            || self.source[..line_start - 1]
6392                .rsplit('\n')
6393                .next()
6394                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6395        let next_blank = line_end >= self.source.len()
6396            || self.source[line_end + 1..]
6397                .split('\n')
6398                .next()
6399                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6400        // A blank line inside a quote is a bare `>`: the space after it is
6401        // the content's, and there is none.
6402        let blank = prefix.trim_end();
6403        let mut text = String::new();
6404        if !prev_blank {
6405            text.push_str(blank);
6406            text.push('\n');
6407        }
6408        text.push_str(&prefix);
6409        text.push_str("```\n");
6410        text.push_str(&prefix);
6411        let inside = text.len();
6412        text.push('\n');
6413        text.push_str(&prefix);
6414        text.push_str("```");
6415        if !next_blank {
6416            text.push('\n');
6417            text.push_str(blank);
6418        }
6419        // Over whatever the blank line held (its quote prefix, trailing
6420        // spaces), so the block's own prefix is the one twig will read back.
6421        let at = line_start;
6422        if !self.splice(at, line_end, &text, EditKind::Other) {
6423            return;
6424        }
6425        self.caret = at + inside;
6426        self.anchor = None;
6427        self.clamp_caret();
6428        self.record_caret();
6429    }
6430
6431    /// Whether the caret stands in a code block, fenced or indented — what
6432    /// lights the Code Block button. Wider than
6433    /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
6434    /// narrower question a language prompt needs.
6435    pub fn caret_in_code_block(&mut self) -> bool {
6436        self.code_block_start_at_caret().is_some()
6437    }
6438
6439    /// Whether the caret stands anywhere inside a block quote, however deep —
6440    /// what lights the Block Quote button. A quote is a container rather than
6441    /// a block kind, so this is true alongside a heading or a code block, not
6442    /// instead of one.
6443    pub fn caret_in_blockquote(&mut self) -> bool {
6444        let off = self.caret.min(self.source.len());
6445        // A quote's span stops short of its closing newline, so the end of its
6446        // last line is `span.end` itself, and has to count.
6447        self.nodes()
6448            .into_iter()
6449            .any(|n| n.kind == Kind::BlockQuote && n.span.start <= off && off <= n.span.end)
6450    }
6451
6452    // ── Task list items ──────────────────────────────────────────────────────
6453    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
6454    // inline content of the item's first paragraph rather than part of its
6455    // marker, so adding or removing one must leave the item's continuation
6456    // indentation alone, and an item inside a quote is found past the quote
6457    // markers. leaf names the gesture and the offset; the spelling is twig's.
6458
6459    /// Whether the list item at the caret carries a checkbox, and which way it
6460    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
6461    /// item or no item at all. What a toolbar reads to light its checkbox button.
6462    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
6463        self.task_checked_at(self.caret)
6464    }
6465
6466    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
6467    /// offset — what a frontend asks before deciding a click landed on a box.
6468    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
6469        self.innermost_list_item(offset.min(self.source.len()))?
6470            .checked
6471    }
6472
6473    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
6474    /// A no-op with a reported reason when the caret is in no task item — minting
6475    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
6476    pub fn toggle_task_checked(&mut self) {
6477        self.toggle_task_at(self.caret);
6478    }
6479
6480    /// Tick or untick the task item covering `offset` — what a *click* on a
6481    /// rendered checkbox is. Separate from the caret form because a click carries
6482    /// its own offset and must not first move the caret there: ticking a box
6483    /// three paragraphs away should not take the cursor with it.
6484    pub fn toggle_task_at(&mut self, offset: usize) {
6485        // The read-only gate — this door reaches twig without the splice.
6486        if self.read_only {
6487            return;
6488        }
6489        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
6490            return;
6491        }
6492        let offset = offset.min(self.source.len());
6493        self.record_caret();
6494        match self.editor.toggle_task_checked(offset) {
6495            Ok(_) => self.after_task_edit(),
6496            Err(e) => self.status = Some(format!("task: {e}")),
6497        }
6498    }
6499
6500    /// Give the list item at the caret a checkbox, or take its checkbox away —
6501    /// the gesture that converts between a plain bullet and a task. A new box
6502    /// arrives unticked.
6503    ///
6504    /// Outside a list the caret's block becomes a bullet first, and the box
6505    /// goes on the new item — one undo step. twig's gesture only converts an
6506    /// item that is already there, and a Checklist button that did nothing on
6507    /// a paragraph read as broken.
6508    pub fn toggle_task_item(&mut self) {
6509        // The read-only gate — this door reaches twig without the splice.
6510        if self.read_only {
6511            return;
6512        }
6513        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
6514            return;
6515        }
6516        if self
6517            .innermost_list_item(self.caret.min(self.source.len()))
6518            .is_none()
6519        {
6520            if self.refuse_unsupported(
6521                "task",
6522                Gesture::ToggleBlockContainer(BlockContainerKind::BulletList),
6523            ) {
6524                return;
6525            }
6526            self.begin_undo_group();
6527            self.toggle_list(false);
6528            if self
6529                .innermost_list_item(self.caret.min(self.source.len()))
6530                .is_some()
6531            {
6532                self.box_item_at_caret();
6533            }
6534            self.end_undo_group();
6535            return;
6536        }
6537        self.box_item_at_caret();
6538    }
6539
6540    /// twig's half of [`toggle_task_item`](Self::toggle_task_item): the box on
6541    /// the item the caret is already in.
6542    fn box_item_at_caret(&mut self) {
6543        let caret = self.caret.min(self.source.len());
6544        self.record_caret();
6545        match self.editor.toggle_task_item(caret) {
6546            Ok(_) => self.after_task_edit(),
6547            Err(e) => self.status = Some(format!("task: {e}")),
6548        }
6549    }
6550
6551    /// Settle after a task gesture. The caret rides its old byte offset and is
6552    /// clamped back in: a box is three or four bytes on the item's first line, so
6553    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
6554    /// real stop either way.
6555    fn after_task_edit(&mut self) {
6556        self.last_edit_kind = None;
6557        self.refresh();
6558        self.anchor = None;
6559        self.dirty = self.source != self.clean_source;
6560        self.status = None;
6561        self.clamp_caret();
6562        self.record_caret();
6563    }
6564
6565    // ── Tables ───────────────────────────────────────────────────────────────
6566    // A table is a grid, and twig edits it as one — add/remove/move a row or
6567    // column, set a column's alignment — re-spelling the whole table in a single
6568    // splice. Every gesture is anchored at the caret's cell. leaf just names the
6569    // gesture and re-reads the result; the whole table's numbering, borders, and
6570    // delimiter are twig's to keep straight.
6571
6572    /// Whether the caret is inside a table — what a frontend asks to enable or
6573    /// disable its table controls.
6574    ///
6575    /// An HTML `<table>` still answers `true`: the caret really is in a table,
6576    /// and the reason the grid controls stay dark there is
6577    /// [`Capabilities::table`], which is a fact about the document's format
6578    /// rather than about the caret. A frontend needs both.
6579    pub fn caret_in_table(&mut self) -> bool {
6580        let caret = self.caret.min(self.source.len());
6581        self.editor
6582            .ancestors_at(caret)
6583            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
6584            .unwrap_or(false)
6585    }
6586
6587    /// One grid op, guarded and settled — the shared body of the seven below.
6588    ///
6589    /// The guard is why this exists rather than seven copies of the same three
6590    /// lines, and it is the one guard leaf cannot delegate to twig. The table
6591    /// editor is the gesture family that consults no `Syntax` table (it spells a
6592    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
6593    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
6594    /// grid as a *pipe table* and reports success, swapping the element out for
6595    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
6596    /// downstream could tell that from a successful edit — the splice is real,
6597    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
6598    /// stopping at the door rather than detecting after the fact. See
6599    /// [`spells_pipe_tables`].
6600    fn table_op(
6601        &mut self,
6602        what: &str,
6603        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
6604    ) {
6605        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
6606            return;
6607        }
6608        self.record_caret();
6609        let at = self.caret;
6610        let r = op(&mut self.editor, at);
6611        self.apply_table(r, what);
6612    }
6613
6614    /// Insert an empty row below (`below`) or above the caret's row.
6615    pub fn table_insert_row(&mut self, below: bool) {
6616        self.table_op("table row", |e, at| e.table_insert_row(at, below));
6617    }
6618
6619    /// Delete the caret's row (not the header, not the last body row).
6620    pub fn table_delete_row(&mut self) {
6621        self.table_op("table row", |e, at| e.table_delete_row(at));
6622    }
6623
6624    /// Insert an empty column right (`right`) or left of the caret's column.
6625    pub fn table_insert_column(&mut self, right: bool) {
6626        self.table_op("table column", |e, at| e.table_insert_column(at, right));
6627    }
6628
6629    /// Delete the caret's column (unless it is the only one).
6630    pub fn table_delete_column(&mut self) {
6631        self.table_op("table column", |e, at| e.table_delete_column(at));
6632    }
6633
6634    /// Set the caret's column to `alignment`.
6635    pub fn table_set_alignment(&mut self, alignment: Alignment) {
6636        self.table_op("table alignment", |e, at| {
6637            e.table_set_alignment(at, alignment)
6638        });
6639    }
6640
6641    /// Move the caret's row one place down (`down`) or up, within the body rows.
6642    pub fn table_move_row(&mut self, down: bool) {
6643        self.table_op("table row", |e, at| e.table_move_row(at, down));
6644    }
6645
6646    /// Move the caret's column one place right (`right`) or left.
6647    pub fn table_move_column(&mut self, right: bool) {
6648        self.table_op("table column", |e, at| e.table_move_column(at, right));
6649    }
6650
6651    /// Settle the caret and document flags after a table op (or report its
6652    /// error). twig re-spells the whole table, so the caret rides its old byte
6653    /// offset and is clamped back into the rebuilt bytes — near enough to where
6654    /// it was, since the op preserves the cells' content and order around it.
6655    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
6656        match result {
6657            Ok(()) => {
6658                self.last_edit_kind = None;
6659                self.refresh();
6660                self.anchor = None;
6661                self.clamp_caret();
6662                self.dirty = self.source != self.clean_source;
6663                self.status = None;
6664                self.record_caret();
6665            }
6666            Err(e) => self.status = Some(format!("{what}: {e}")),
6667        }
6668    }
6669
6670    /// One `toggle_block_container` over the block-level target.
6671    ///
6672    /// leaf says *where*; twig decides everything else — which blocks the range
6673    /// covers, whether that means wrapping, unwrapping, nesting or converting,
6674    /// and how this document's format spells the prefix. The rule that a
6675    /// container only comes off when the range covers every block it holds is
6676    /// what the re-anchoring below is built around.
6677    fn toggle_container(&mut self, kind: BlockContainerKind) {
6678        // The read-only gate — this door reaches twig without the splice.
6679        if self.read_only {
6680            return;
6681        }
6682        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
6683            return;
6684        }
6685        let selected = self.selection();
6686        // A blank line holds no block, and twig opens an *empty* container on one
6687        // — since 3.2.0; it used to decline the range with `NotFound`, which is
6688        // why this used to lend it a scratch paragraph to wrap. Worth knowing
6689        // here because the line-for-line caret mapping below cannot describe it:
6690        // opening one under a paragraph writes the blank line the format needs
6691        // above the marker too, so the rewritten region has a line the old one
6692        // didn't, and "the same line, the same distance from its end" lands on
6693        // that new blank instead of in the container.
6694        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
6695        // Without a selection the target is the caret's own block, resolved the
6696        // way `set_block` resolves it — a caret at a line end sits at the doc
6697        // level and has to be nudged back onto the block it looks like it's in.
6698        // An empty range is enough: twig widens to the whole lines it touches.
6699        let (start, end) = match selected {
6700            Some(range) => range,
6701            None => {
6702                let off = self.block_offset_for_caret().unwrap_or(self.caret);
6703                (off, off)
6704            }
6705        };
6706        self.record_caret();
6707        match self.editor.toggle_block_container(start, end, kind) {
6708            Ok(change) => {
6709                // Read the caret's place out of the *pre-edit* source, before
6710                // `refresh` swaps that source out from under it.
6711                let place = (selected.is_none() && !opened_empty)
6712                    .then(|| self.caret_line_tail(&change.old));
6713                self.last_edit_kind = None; // structural edit is its own undo step
6714                self.refresh();
6715                match place {
6716                    // Both land the caret at the far end of what twig wrote, and
6717                    // differ only in what they leave selected.
6718                    //
6719                    // From a selection: select what the container now holds, the
6720                    // way `toggle` keeps its marked region selected — and for a
6721                    // stronger reason than symmetry: a container comes *off* only
6722                    // a range covering every block it holds, so a selection left
6723                    // on its old bytes (now short by a prefix per line) would nest
6724                    // on the second press instead of reversing the first.
6725                    //
6726                    // From a blank line: nothing to select, and the end of the
6727                    // region is exactly past the bare `> ` / `- ` twig wrote —
6728                    // the caret standing inside the container that was asked for.
6729                    None => {
6730                        self.anchor = (!opened_empty).then_some(change.new.start);
6731                        self.caret = change.new.end;
6732                    }
6733                    Some(place) => {
6734                        self.anchor = None;
6735                        self.caret = self.line_tail_offset(&change.new, place);
6736                    }
6737                }
6738                self.dirty = self.source != self.clean_source;
6739                self.status = None;
6740                self.clamp_caret();
6741                self.record_caret();
6742            }
6743            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6744        }
6745    }
6746
6747    /// The caret's place inside the region a container toggle is rewriting, in
6748    /// the only terms the rewrite preserves: which of the region's lines it sits
6749    /// on, and how many bytes of that line lie ahead of it.
6750    ///
6751    /// A container's markup goes in at column 0 and never touches what follows
6752    /// on the line, so that pair survives the edit exactly where a byte offset
6753    /// does not — a caret left on its old offset slides back by one prefix per
6754    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
6755    /// `> ` it just asked for.
6756    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
6757        let caret = self.caret.clamp(old.start, old.end);
6758        let line = self.source[old.start..caret].matches('\n').count();
6759        let end = self.source[caret..old.end]
6760            .find('\n')
6761            .map_or(old.end, |i| caret + i);
6762        (line, end - caret)
6763    }
6764
6765    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
6766    /// region: the offset `tail` bytes back from the end of the region's `line`.
6767    ///
6768    /// Both walks are clamped rather than trusted, because the one op that does
6769    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
6770    /// the items back apart with blank lines between them — and a caret landing
6771    /// on the nearest line of the right item beats one landing out of the region
6772    /// entirely.
6773    fn line_tail_offset(
6774        &self,
6775        new: &std::ops::Range<usize>,
6776        (line, tail): (usize, usize),
6777    ) -> usize {
6778        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
6779        let mut start = 0;
6780        for _ in 0..line {
6781            match region[start..].find('\n') {
6782                Some(i) => start += i + 1,
6783                None => break,
6784            }
6785        }
6786        let end = region[start..]
6787            .find('\n')
6788            .map_or(region.len(), |i| start + i);
6789        new.start + end.saturating_sub(tail).max(start)
6790    }
6791
6792    /// Link the selection to `destination` — the toolbar's Link button. With no
6793    /// selection it acts at the caret, which re-points a link the caret is
6794    /// already standing in (twig replaces an existing link's destination and
6795    /// keeps its text) and otherwise spells a link that has no text of its own:
6796    /// an autolink (`<https://x.dev>`) where the destination is one, and
6797    /// `[destination](destination)` where it isn't.
6798    ///
6799    /// `destination` reaches twig raw. Escaping it is format knowledge and the
6800    /// two formats genuinely disagree — Markdown ends a destination at the first
6801    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
6802    /// itself — so the side holding the document is the side that gets to spell
6803    /// it. A destination twig can't carry at all (one with a newline) comes back
6804    /// as an error rather than a quietly rewritten URL.
6805    pub fn insert_link(&mut self, destination: &str) {
6806        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6807            return;
6808        }
6809        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6810        self.record_caret();
6811        match self.editor.insert_link(start, end, destination) {
6812            Ok(change) => {
6813                self.last_edit_kind = None;
6814                self.refresh();
6815                match self.link_text_span(change.new.start) {
6816                    // A link with text of its own: select it, so typing replaces
6817                    // a `[dest](dest)`'s stand-in label and a second press
6818                    // re-points what the first one linked.
6819                    Some(text) => {
6820                        self.anchor = (text.start != text.end).then_some(text.start);
6821                        self.caret = text.end;
6822                    }
6823                    // An autolink is finished the moment it's written — its text
6824                    // *is* the URL. Leaving it selected would aim the next press
6825                    // at the one shape twig still wraps instead of re-points.
6826                    None => {
6827                        self.anchor = None;
6828                        self.caret = change.new.end;
6829                    }
6830                }
6831                self.dirty = self.source != self.clean_source;
6832                self.status = None;
6833                self.clamp_caret();
6834                self.record_caret();
6835            }
6836            Err(e) => self.status = Some(format!("link: {e}")),
6837        }
6838    }
6839
6840    /// Insert a link to `destination` whose text is `label` — for a host that
6841    /// links something it has just made and knows the name of, where
6842    /// [`insert_link`](Self::insert_link)'s `[dest](dest)` would leave a file
6843    /// name where a title belongs.
6844    ///
6845    /// With no selection, `label` is written at the caret as the link's text
6846    /// and the caret lands just past the link, nothing selected: the link is
6847    /// finished, not a stand-in to type over. The label goes in through twig's
6848    /// `insert_literal`, so it is escaped in the body's own grammar the way the
6849    /// destination is — a `]` or `*` in a title stays that character rather
6850    /// than closing the link or opening emphasis. The label and the link are
6851    /// one undo step.
6852    ///
6853    /// Everywhere else this is [`insert_link`](Self::insert_link) and the
6854    /// label is ignored: with a selection, which is linked as it stands; with
6855    /// a caret standing inside a link or an autolink, which is re-pointed and
6856    /// keeps the text it has, so a host's Edit Link can call this one verb for
6857    /// both; and with an empty `label`.
6858    pub fn insert_link_labelled(&mut self, destination: &str, label: &str) {
6859        if self.selection().is_some() || label.is_empty() {
6860            return self.insert_link(destination);
6861        }
6862        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6863            return;
6864        }
6865        let caret = self.caret;
6866        if self
6867            .nodes()
6868            .into_iter()
6869            .any(|n| n.kind == Kind::Link && n.span.start < caret && caret < n.span.end)
6870        {
6871            return self.insert_link(destination);
6872        }
6873        self.record_caret();
6874        let text = match self.editor.insert_literal(caret, label) {
6875            Ok(change) => change.new,
6876            Err(e) => {
6877                self.status = Some(format!("link: {e}"));
6878                return;
6879            }
6880        };
6881        match self.editor.insert_link(text.start, text.end, destination) {
6882            Ok(change) => {
6883                // Two twig steps, the label's and the link's: each counted as
6884                // `refresh` counts an edit, then folded into one, so the label
6885                // is never on the history without its link. Inside a host's
6886                // undo group the second `refresh` has folded it already.
6887                self.last_edit_kind = None;
6888                self.refresh();
6889                self.refresh();
6890                self.coalesce_last_undo();
6891                self.anchor = None;
6892                self.caret = change.new.end;
6893                self.goal_col = None;
6894                self.dirty = self.source != self.clean_source;
6895                self.status = None;
6896                self.clamp_caret();
6897                self.record_caret();
6898            }
6899            Err(e) => {
6900                // Take the label back out, so a destination twig refuses
6901                // leaves the document as it found it. Writing the label ended
6902                // whatever was undone before it, so nothing is left to redo.
6903                let _ = self.editor.undo();
6904                self.redo_steps = 0;
6905                self.status = Some(format!("link: {e}"));
6906            }
6907        }
6908    }
6909
6910    /// Insert a block-level image at the caret: `![alt](destination)`. Any
6911    /// selection becomes the alt text (so "select a caption, insert image" labels
6912    /// it); with no selection, `alt` is used — empty for none. The caret lands
6913    /// just past the inserted image.
6914    ///
6915    /// Both halves go through twig (`insert_literal` for the alt text,
6916    /// `insert_image` for the image), so neither is spelled here. That used to be a
6917    /// `format!`, and it was wrong the first time an app inserted a real filename:
6918    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
6919    /// not an image at all — and the fix is per-format, since moving into the
6920    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
6921    pub fn insert_image(&mut self, destination: &str, alt: &str) {
6922        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
6923            return;
6924        }
6925        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6926        self.record_caret();
6927        // With no selection and an explicit `alt`, the alt text has to exist in the
6928        // document before it can be the image's — and it is raw caller input, so
6929        // it goes in through `insert_literal`, which escapes it for the format
6930        // rather than letting a `]` in someone's caption close the image early.
6931        let (start, end) = if start == end && !alt.is_empty() {
6932            match self.editor.insert_literal(start, alt) {
6933                Ok(change) => (change.new.start, change.new.end),
6934                Err(e) => {
6935                    self.status = Some(format!("image: {e}"));
6936                    return;
6937                }
6938            }
6939        } else {
6940            (start, end)
6941        };
6942        match self.editor.insert_image(start, end, destination) {
6943            Ok(change) => {
6944                self.last_edit_kind = None;
6945                self.refresh();
6946                // Just past the image, nothing selected — where a caret belongs
6947                // after inserting one.
6948                self.anchor = None;
6949                self.caret = change.new.end;
6950                self.dirty = self.source != self.clean_source;
6951                self.status = None;
6952                self.clamp_caret();
6953                self.record_caret();
6954            }
6955            Err(e) => self.status = Some(format!("image: {e}")),
6956        }
6957    }
6958
6959    /// Insert a block-level image, video, or audio at the caret. The image case
6960    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
6961    /// HTML elements, which is the only spelling Markdown and Djot have for them:
6962    ///
6963    /// ```text
6964    /// <video src="clip.mp4" controls>alt</video>
6965    /// <audio src="take.mp3" controls>alt</audio>
6966    /// ```
6967    ///
6968    /// HTML rather than a `::video{…}` directive deliberately. A directive means
6969    /// something only to an app that knows the vocabulary, so the document would
6970    /// read as literal punctuation everywhere else; `<video>` is what every other
6971    /// renderer already understands, and what leaf's own reader picks back up
6972    /// through `html_elements` promotion (see [`parse_extensions`]).
6973    ///
6974    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
6975    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
6976    /// `html_elements`. Before that only the multi-line form parsed as a block at
6977    /// all, and this wrote three lines to work around it.
6978    ///
6979    /// `controls` is always written: a player with no transport is a still frame
6980    /// the reader can't do anything with. Any selection becomes the element's
6981    /// fallback text, exactly as it becomes an image's alt.
6982    ///
6983    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
6984    /// applies, and bites harder here: a `"` in `destination` closes the
6985    /// attribute. A frontend taking these from a file picker is fine; one taking
6986    /// them from free text should keep them tame.
6987    ///
6988    /// [`MediaInfo`]: crate::MediaInfo
6989    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6990        if kind == MediaKind::Image {
6991            return self.insert_image(destination, alt);
6992        }
6993        // Gated on the *image* gesture, not on one of its own — there isn't one,
6994        // since the bytes below are spelled here rather than by twig, and an HTML
6995        // document would in fact parse them. The button is one control with three
6996        // kinds behind it, and two of them working in a format where the third
6997        // cannot is a worse surface than three that agree — especially as
6998        // `insert_image` is the kind anyone reaches for first.
6999        if self.refuse_unsupported("media", Gesture::InsertImage) {
7000            return;
7001        }
7002        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
7003        let alt_text = self
7004            .selected_text()
7005            .map(str::to_string)
7006            .unwrap_or_else(|| alt.to_string());
7007        let tag = match kind {
7008            MediaKind::Audio => "audio",
7009            _ => "video",
7010        };
7011        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
7012        self.edit(start, end, &markup);
7013    }
7014
7015    /// Append media at the **end** of the document, as a block of its own —
7016    /// the verb for a picture that *arrives* rather than one the writer
7017    /// places: an attachment imported while the caret was wherever it last
7018    /// was, a drawing placed from a tray under the body. At the caret it
7019    /// would land inline in front of whatever word the caret happened to be
7020    /// beside, which for an editor nobody has tapped yet is the first word
7021    /// of the document.
7022    ///
7023    /// The document is ended with a blank line first, where it does not
7024    /// already end with one, so the media is a paragraph of its own rather
7025    /// than a lazy continuation of the last one; an empty document needs no
7026    /// separator. Then [`insert_media`](Self::insert_media) at the new end,
7027    /// with everything that means: the same markup per kind, the same
7028    /// refusal on a format without images. The selection is dropped — the
7029    /// verb is about the end of the document, not about what was selected —
7030    /// and the caret is left past the media, as `insert_media` leaves it.
7031    pub fn append_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
7032        if self.read_only || self.refuse_unsupported("media", Gesture::InsertImage) {
7033            return;
7034        }
7035        let separator = if self.source.is_empty() || self.source.ends_with("\n\n") {
7036            ""
7037        } else if self.source.ends_with('\n') {
7038            "\n"
7039        } else {
7040            "\n\n"
7041        };
7042        let end = self.source.len();
7043        self.anchor = None;
7044        self.caret = end;
7045        if !separator.is_empty() {
7046            self.edit(end, end, separator);
7047        }
7048        self.anchor = None;
7049        self.caret = self.source.len();
7050        self.insert_media(kind, destination, alt);
7051    }
7052
7053    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
7054    /// button. Spelling and placement are both twig's; leaf used to write `---`
7055    /// itself, which was the Markdown spelling in a djot document too.
7056    ///
7057    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
7058    /// mid-paragraph and lands it after the caret's whole block. To get a rule
7059    /// *at* the caret — the paragraph parted in two around it, which is what a
7060    /// rule button is understood to do — the paragraph is first divided with
7061    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
7062    /// the offset `split_block` returns puts the rule after the *second* half
7063    /// instead, which is a rule in the right document and the wrong place.
7064    ///
7065    /// Only a plain paragraph is split, and only where there is something to
7066    /// part: at the paragraph's end the split has no second half to mint and
7067    /// would write the separator anyway — a blank line and the empty slot Enter
7068    /// leaves for the next paragraph, which the rule then lands above and
7069    /// nothing fills — so there the rule goes straight after the paragraph,
7070    /// which is where the split-and-aim was sending it regardless. At the
7071    /// paragraph's *start* the split is kept, though it parts nothing either:
7072    /// `|para` becomes `\npara` with the caret on the new blank line, and a
7073    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
7074    /// "before the paragraph" is said through a gesture that only knows
7075    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
7076    /// else the rule simply lands after the block, which is both twig's own
7077    /// answer and the better one: splitting a fenced code block would leave two
7078    /// fences with a rule between them, and splitting a list item would mint an
7079    /// item nobody asked for on the way to a rule that lands after the list
7080    /// regardless. A table and a setext heading refuse the split outright, so
7081    /// they take the same path by themselves.
7082    ///
7083    /// The caret ends on the line under the rule, which is where the writer
7084    /// goes on: at the start of the paragraph's second half when the rule
7085    /// parted one, and otherwise on an empty line opened under the rule — see
7086    /// [`stand_under_block`](Self::stand_under_block). It used to be left at
7087    /// the end of what twig wrote, which is the rule's own row: the caret was
7088    /// drawn beside the rule, and mid-document the next keystroke joined the
7089    /// block below.
7090    pub fn insert_thematic_break(&mut self) {
7091        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
7092        {
7093            return;
7094        }
7095        self.caret = self.skip_trailing_close_delims(self.caret);
7096        // A selection is replaced by the rule, so collapse it first and let the
7097        // split-and-rule below run from the caret it leaves behind.
7098        if let Some((s, e)) = self.selection() {
7099            self.splice(s, e, "", EditKind::Other);
7100        }
7101        self.anchor = None;
7102        self.record_caret();
7103        let at = self.caret;
7104        // A failure here is not fatal: the rule still lands after the block,
7105        // which is exactly what this call was trying to improve on.
7106        let parted = self.part_for_block(at);
7107        match self.editor.insert_thematic_break(at) {
7108            Ok(change) => {
7109                self.last_edit_kind = None;
7110                self.refresh();
7111                if parted {
7112                    self.coalesce_last_undo();
7113                }
7114                self.anchor = None;
7115                self.caret = change.new.end;
7116                self.dirty = self.source != self.clean_source;
7117                self.status = None;
7118                self.clamp_caret();
7119                self.record_caret();
7120                self.caret_under_written_block(change.new, parted);
7121            }
7122            Err(e) => self.status = Some(format!("thematic break: {e}")),
7123        }
7124    }
7125
7126    /// Part the bare paragraph at `at` for a block gesture, where
7127    /// [`caret_parts_bare_paragraph`](Self::caret_parts_bare_paragraph) says
7128    /// to, and say whether it was parted.
7129    ///
7130    /// The split is counted as a step of its own, so the gesture can fold the
7131    /// block it writes next into it. It used to go to twig uncounted: twig
7132    /// kept it as a step and leaf's count did not, so one Undo took back the
7133    /// block and left the paragraph parted, with Undo then reporting nothing
7134    /// left to undo.
7135    fn part_for_block(&mut self, at: usize) -> bool {
7136        if !self.caret_parts_bare_paragraph() || self.editor.split_block(at).is_err() {
7137            return false;
7138        }
7139        self.refresh();
7140        true
7141    }
7142
7143    /// Where a block gesture leaves the caret once twig has written the block
7144    /// `new` spans — a rule, a page break: on the line under it. That is the
7145    /// start of the paragraph's second half when the gesture `parted` one,
7146    /// and otherwise the line [`stand_under_block`](Self::stand_under_block)
7147    /// finds or writes, folded into the gesture's step.
7148    ///
7149    /// The block is the outermost node twig's change opens, whatever its
7150    /// kind, since the kind a leaf directive comes back as is the format's.
7151    fn caret_under_written_block(&mut self, new: Range<usize>, parted: bool) {
7152        let block = self
7153            .nodes()
7154            .into_iter()
7155            .filter(|n| n.kind != Kind::Doc && new.contains(&n.span.start))
7156            .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)));
7157        let Some(block) = block else {
7158            return;
7159        };
7160        let (end, _) = wysiwyg::block_line(&self.source, block.span.end);
7161        if parted {
7162            // The second half starts at the first text under the block; the
7163            // split left it no leading whitespace.
7164            let rest = &self.source[end..];
7165            self.caret = end + (rest.len() - rest.trim_start().len());
7166            self.record_caret();
7167        } else if self.stand_under_block(end) {
7168            self.coalesce_last_undo();
7169        }
7170    }
7171
7172    /// Stand the caret on the line under the block whose last line's text ends
7173    /// at `end` — a thematic break, a page break — first writing that line if
7174    /// the block has none: where the rule and page-break buttons leave the
7175    /// caret, and where Enter on a rule takes it. Whether anything was
7176    /// written, so a caller can fold it into its own step.
7177    ///
7178    /// The caret's line is the first under the block that the view gives a
7179    /// home: the second blank line in [`LineFlow::Fold`], which draws the
7180    /// first as the gap closing the block, and the first in
7181    /// [`LineFlow::Preserve`], where every blank line is somewhere to type.
7182    /// When anything follows, one more blank line has to stand between the
7183    /// caret's line and it, or what is typed there runs on into the next
7184    /// block. Only what that shape is missing is written. A block twig wrote on
7185    /// a blank line still has the blank lines that were under the caret and
7186    /// needs one line more, or none, where a rule written under a paragraph
7187    /// needs two.
7188    ///
7189    /// Blank means blank inside the block's container: a quote's blank lines
7190    /// keep their `>`, and the caret's line wears the quote's whole prefix, as
7191    /// the line Enter opens in a quote does. The document's last line, when no
7192    /// newline ends it, counts only once a blank line stands above it, since
7193    /// only then does the view draw it as a row. Fold always writes the gap
7194    /// above it if it isn't there, so there a block ending the document takes
7195    /// one newline more and the caret its end.
7196    fn stand_under_block(&mut self, end: usize) -> bool {
7197        let prefix = self.continuation_prefix_at(end);
7198        let blank = prefix.trim_end();
7199        let gap = usize::from(self.line_flow == LineFlow::Fold);
7200        // The blank lines directly under the block's own, by where each starts,
7201        // and whether the line that ends the run has anything on it.
7202        let mut blanks = Vec::new();
7203        let mut follows = false;
7204        let mut at = end;
7205        while let Some(nl) = self.source[at..].find('\n') {
7206            let start = at + nl + 1;
7207            let len = self.source[start..].find('\n');
7208            let line = &self.source[start..len.map_or(self.source.len(), |len| start + len)];
7209            let line = line.trim_end();
7210            if line != blank {
7211                follows = !line.trim().is_empty();
7212                break;
7213            }
7214            let Some(len) = len else {
7215                if gap > 0 || !blanks.is_empty() {
7216                    blanks.push(start);
7217                }
7218                break;
7219            };
7220            blanks.push(start);
7221            at = start + len;
7222        }
7223        let missing = (gap + 1 + usize::from(follows)).saturating_sub(blanks.len());
7224        // What is missing goes in at the rule's end, above the blank lines
7225        // already there, so the caret's line is the same one counted from
7226        // the rule either way: written here, or one of those, moved down.
7227        let mut text = String::new();
7228        let mut caret = None;
7229        for line in 1..=missing {
7230            text.push('\n');
7231            if line == gap + 1 {
7232                text.push_str(&prefix);
7233                caret = Some(end + text.len());
7234            } else {
7235                text.push_str(blank);
7236            }
7237        }
7238        let caret = caret.unwrap_or_else(|| {
7239            let start = blanks[gap - missing];
7240            let line = self.source[start..].split('\n').next().unwrap_or("");
7241            start + text.len() + line.trim_end_matches('\r').len().min(prefix.len())
7242        });
7243        if !text.is_empty() && !self.splice(end, end, &text, EditKind::Other) {
7244            return false;
7245        }
7246        self.caret = caret.min(self.source.len());
7247        self.anchor = None;
7248        self.goal_col = None;
7249        self.record_caret();
7250        !text.is_empty()
7251    }
7252
7253    /// The thematic break the caret stands under, as its text's end, when
7254    /// Enter there belongs to the rule: the caret at the rule's home past it
7255    /// (the start of the line under it), that line blank, and the caret drawn
7256    /// on the rule's own row.
7257    ///
7258    /// Drawn on the rule's row, because the home past a rule is also the start
7259    /// of the line under it, and which of the two the view draws it on is the
7260    /// flow's. [`LineFlow::Fold`] draws that line as the gap under the rule,
7261    /// no caret's home, so the caret is beside the rule. [`LineFlow::Preserve`]
7262    /// makes every blank line a home, so it draws the caret there and Enter is
7263    /// an ordinary blank line's — unless it is the document's unterminated
7264    /// last line, which is no row in either flow.
7265    fn rule_above_caret(&mut self) -> Option<usize> {
7266        let caret = self.caret.min(self.source.len());
7267        let above = self.source[..caret].strip_suffix('\n')?;
7268        let above_start = above.rfind('\n').map_or(0, |i| i + 1);
7269        let text = above[above_start..].trim_end();
7270        let (last, _) = text.char_indices().next_back()?;
7271        let rule = self
7272            .editor
7273            .ancestors_at(above_start + last)
7274            .ok()?
7275            .into_iter()
7276            .find(|m| m.kind == Kind::ThematicBreak)?;
7277        let (end, home) = wysiwyg::block_line(&self.source, rule.span.end);
7278        if home != caret {
7279            return None;
7280        }
7281        let blank = self.continuation_prefix_at(end);
7282        let line_end = self.source[caret..].find('\n').map(|i| caret + i);
7283        let line = &self.source[caret..line_end.unwrap_or(self.source.len())];
7284        if line.trim_end() != blank.trim_end() && !line.trim().is_empty() {
7285            return None;
7286        }
7287        (self.line_flow == LineFlow::Fold || line_end.is_none()).then_some(end)
7288    }
7289
7290    /// Insert a fresh table at the caret — the toolbar's Table button. One
7291    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
7292    /// the document's own dialect and placed the way its thematic break is:
7293    /// after the caret's block, blank-separated. A bare paragraph is parted
7294    /// around the caret first, exactly as
7295    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
7296    /// table lands *at* the caret rather than after everything the caret's
7297    /// paragraph says.
7298    ///
7299    /// The caret ends in the first header cell, selected the way Tab selects
7300    /// a cell — the natural next act is to type the heading, and Tab then
7301    /// walks the grid. That cell is read back from the rebuilt table map
7302    /// rather than computed from the splice, because twig's blank line and
7303    /// quote prefix put the first bar at an offset only the reparse knows.
7304    ///
7305    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
7306    /// rows or columns is twig's refusal (a header with nothing under it is
7307    /// what its row delete refuses to leave), reported through `status` —
7308    /// and refused here before the paragraph is parted, which would otherwise
7309    /// be left parted around a table never written.
7310    pub fn insert_table(&mut self, rows: usize, cols: usize) {
7311        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
7312            return;
7313        }
7314        // twig's one refusal of a shape, asked before the paragraph is parted
7315        // for a table that would never be written into it.
7316        if rows == 0 || cols == 0 {
7317            self.status = Some("table: needs at least one row and one column".into());
7318            return;
7319        }
7320        self.caret = self.skip_trailing_close_delims(self.caret);
7321        if let Some((s, e)) = self.selection() {
7322            self.splice(s, e, "", EditKind::Other);
7323        }
7324        self.anchor = None;
7325        self.record_caret();
7326        let at = self.caret;
7327        let parted = self.part_for_block(at);
7328        match self.editor.insert_table(at, rows, cols) {
7329            Ok(change) => {
7330                self.last_edit_kind = None;
7331                self.refresh();
7332                if parted {
7333                    self.coalesce_last_undo();
7334                }
7335                self.anchor = None;
7336                self.caret = change.new.end;
7337                self.dirty = self.source != self.clean_source;
7338                self.status = None;
7339                self.clamp_caret();
7340                // Into the first header cell of the table just written: the
7341                // first table whose grid begins inside the splice.
7342                self.rebuild_map();
7343                let first_cell = self
7344                    .vmap
7345                    .tables
7346                    .iter()
7347                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
7348                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
7349                    .map(|cell| (cell.start, cell.end));
7350                if let Some((start, end)) = first_cell {
7351                    self.select_cell(start, end);
7352                }
7353                self.record_caret();
7354            }
7355            Err(e) => self.status = Some(format!("table: {e}")),
7356        }
7357    }
7358
7359    /// Whether the caret sits in a paragraph and nothing else — no list item, no
7360    /// quote, no fence, no table — with paragraph text still ahead of it. The
7361    /// one shape where parting the block around the caret is unambiguously what
7362    /// a rule button means; see
7363    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
7364    /// container is left to take the rule after itself.
7365    ///
7366    /// The "text ahead" half is what keeps `split_block` from running at the
7367    /// one edge where its output composes badly. At a paragraph's end twig
7368    /// cannot mint the empty second half (no format spells an empty
7369    /// paragraph), so it writes only the separator — a blank line and the
7370    /// slot Enter leaves for the paragraph to come — and a block then aimed at
7371    /// the first half lands above a slot that nothing fills: `para\n` with the
7372    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
7373    /// as nothing ahead, since the split would shed it as the second half's
7374    /// leading indent and leave the same slot. Which end of the newline a
7375    /// paragraph's span stops at differs between the formats (Markdown before
7376    /// it, djot after), which is why this reads the remaining bytes rather
7377    /// than comparing offsets. The paragraph's
7378    /// start is deliberately not the same case — see
7379    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
7380    /// split is kept.
7381    fn caret_parts_bare_paragraph(&mut self) -> bool {
7382        let caret = self.caret.min(self.source.len());
7383        let Ok(chain) = self.editor.ancestors_at(caret) else {
7384            return false;
7385        };
7386        let mut para_end = None;
7387        for m in chain {
7388            match m.kind {
7389                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
7390                Kind::ListItem
7391                | Kind::TaskListItem
7392                | Kind::BlockQuote
7393                | Kind::CodeBlock
7394                | Kind::Table => return false,
7395                _ => {}
7396            }
7397        }
7398        match para_end {
7399            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
7400            _ => false,
7401        }
7402    }
7403
7404    /// The destination of the link under the caret — what a Link prompt shows so
7405    /// ⌘K on an existing link edits its URL instead of asking for it again.
7406    /// `None` when the caret stands in no link.
7407    ///
7408    /// An autolink carries no separate destination: its text *is* the URL, so
7409    /// that's what comes back for one.
7410    pub fn link_destination_at_caret(&mut self) -> Option<String> {
7411        self.link_destination_at(self.caret)
7412    }
7413
7414    /// The destination of the link at `off`.
7415    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
7416    /// the caret isn't.
7417    ///
7418    /// The offset form exists for the same reason
7419    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
7420    /// the document somewhere else — a footnote's text in a popover, say — has
7421    /// rows and runs but no caret in them, and still needs to know which of those
7422    /// runs a reader can follow.
7423    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
7424        self.nodes()
7425            .into_iter()
7426            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
7427            .filter(|n| n.span.start <= off && off < n.span.end)
7428            .max_by_key(|n| n.span.start)
7429            .and_then(|n| n.destination.or(n.text))
7430    }
7431
7432    /// The heading the caret is under — the nearest heading at or above it,
7433    /// whatever its level. See [`heading_at`](Self::heading_at).
7434    pub fn heading_at_caret(&mut self) -> Option<Heading> {
7435        self.heading_at(self.caret)
7436    }
7437
7438    /// The heading `off` is under: the last heading that starts at or before
7439    /// it, or the one `off` stands in. `None` above the first heading.
7440    ///
7441    /// The question between [`link_destination_at`](Self::link_destination_at)
7442    /// ("which link is this") and [`locate`](Self::locate) ("where does this
7443    /// fragment land"): a host writing a link *to* a place needs the heading
7444    /// that place is under, to name it by `#slug`. Nearest, not enclosing —
7445    /// a level-3 heading under a level-2 is the answer for the text below the
7446    /// level-3, because it is the finer place to land.
7447    pub fn heading_at(&mut self, off: usize) -> Option<Heading> {
7448        let nodes = self.nodes();
7449        let h = nodes
7450            .iter()
7451            .filter(|n| n.kind == Kind::Heading && n.span.start <= off)
7452            .max_by_key(|n| n.span.start)?;
7453        let mut text = String::new();
7454        inline_text(&nodes, h.first_child, &mut text);
7455        Some(Heading {
7456            text: text.trim().to_string(),
7457            level: h.level.unwrap_or(1),
7458            span: h.span.clone(),
7459        })
7460    }
7461
7462    /// Where the locator `id` lands in this document — the `#v2` half of a
7463    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
7464    /// answers to it.
7465    ///
7466    /// The other end of a link, and the reason this exists: without it a
7467    /// destination has only file granularity, so following a citation into a
7468    /// chapter drops the reader at the top of it to hunt for the verse. Which is
7469    /// also why it is a *document* query rather than a caret one — the document
7470    /// being asked is usually not the one the reader is in.
7471    ///
7472    /// Three readings, tried in order, because the same `#some-heading` is
7473    /// written three ways across the formats leaf opens:
7474    ///
7475    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
7476    ///    the auto-ids djot mints for its headings. The only exact answer, so it
7477    ///    goes first — a document that says `{#v1}` has settled the question.
7478    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
7479    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
7480    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
7481    ///    authored anywhere land on a djot heading.
7482    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
7483    ///    none and `{#custom}` is literal text in a Markdown heading — so for
7484    ///    the format most vaults are written in, the heading's own words are the
7485    ///    only thing a fragment can name. This is the rule every Markdown
7486    ///    renderer already follows, which is what makes `#a-heading` mean in
7487    ///    diaryx what it means on the web.
7488    ///
7489    /// Ties go to the earliest match, then to the widest: a duplicated id is the
7490    /// document's mistake and the first one is the answer every anchor
7491    /// implementation gives, while preferring the wider span picks the section
7492    /// over the heading that opens it — more for a peek to show, same place to
7493    /// land.
7494    pub fn locate(&mut self, id: &str) -> Option<Landing> {
7495        let id = id.trim();
7496        if id.is_empty() {
7497            return None;
7498        }
7499        let nodes = self.nodes();
7500
7501        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
7502        // is picking, among nodes that start together, the one that ends last.
7503        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
7504            matches
7505                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
7506                .map(|n| Landing {
7507                    start: n.span.start,
7508                    end: n.span.end,
7509                })
7510        };
7511
7512        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
7513            return Some(landing);
7514        }
7515        let want = slug(id);
7516        if want.is_empty() {
7517            return None;
7518        }
7519        if let Some(landing) = pick(
7520            &mut nodes
7521                .iter()
7522                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
7523        ) {
7524            return Some(landing);
7525        }
7526
7527        // A heading by its words. Its span is one line, so the end comes from
7528        // where the *section* it opens gives out — the next heading that is not
7529        // under it, or the end of the document. A Markdown heading has no
7530        // section node to ask (twig only builds those for djot), and a peek that
7531        // showed the heading alone would answer "what does that say" with the
7532        // title of the thing it says.
7533        let heading = nodes
7534            .iter()
7535            .filter(|n| n.kind == Kind::Heading)
7536            .filter(|n| {
7537                n.content_span
7538                    .clone()
7539                    .and_then(|s| self.source.get(s))
7540                    .is_some_and(|text| slug(text) == want)
7541            })
7542            .min_by_key(|n| n.span.start)?;
7543        let level = heading.level.unwrap_or(u32::MAX);
7544        let end = nodes
7545            .iter()
7546            .filter(|n| n.kind == Kind::Heading)
7547            .filter(|n| n.span.start > heading.span.start)
7548            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
7549            .map(|n| n.span.start)
7550            .min()
7551            .unwrap_or(self.source.len());
7552        Some(Landing {
7553            start: heading.span.start,
7554            end,
7555        })
7556    }
7557
7558    /// Write a footnote at the caret — the toolbar's Footnote button, and the
7559    /// one gesture in the footnote story that *authors* rather than follows.
7560    ///
7561    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
7562    /// the `[^1]:` definition at the end of the document. Half a footnote is not
7563    /// a footnote — a bare reference with nothing defining it renders as literal
7564    /// brackets — so a single button that wrote only the reference would leave
7565    /// the author to hand-spell the other half in a document that had just
7566    /// stopped showing them what the first half meant. One edit also means one
7567    /// undo takes both back.
7568    ///
7569    /// The definition's body is left empty and **the caret lands in it**, which
7570    /// is the whole point of pressing the button: nobody wants a reference to a
7571    /// note they have not written yet. Getting back to where they were writing
7572    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
7573    /// the same return leg a reader following a reference already uses, so the
7574    /// author is left standing on the near end of a round trip that works.
7575    ///
7576    /// A selection collapses to its *end* rather than being replaced: a
7577    /// reference annotates the words before it, so "select the claim, add a
7578    /// footnote" should mark that claim, not consume it.
7579    pub fn insert_footnote(&mut self) {
7580        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
7581            return;
7582        }
7583        let at = self.selection().map_or(self.caret, |(_, end)| end);
7584        self.anchor = None;
7585        self.caret = at;
7586        self.record_caret();
7587        let label = self.next_footnote_label();
7588        match self.editor.insert_footnote(at, &label) {
7589            Ok(change) => {
7590                self.last_edit_kind = None;
7591                self.refresh();
7592                self.anchor = None;
7593                // `change.new` runs from the reference to the end of the
7594                // document, so its start is the `[^1]` just written and
7595                // `footnote_at` resolves it to the note the same way a reader's
7596                // tap does — and to the note's *body*, which is already a caret
7597                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
7598                // and has none), so this needs no snap on top. The fallback is
7599                // the reference's own offset: a format that spelled the pair some
7600                // way leaf can't read back should still leave the caret on the
7601                // edit rather than at the far end of a document it just grew.
7602                self.caret = self
7603                    .footnote_at(change.new.start)
7604                    .and_then(|note| note.offset)
7605                    .unwrap_or(change.new.start);
7606                self.dirty = self.source != self.clean_source;
7607                self.status = None;
7608                self.clamp_caret();
7609                self.record_caret();
7610            }
7611            Err(e) => self.status = Some(format!("footnote: {e}")),
7612        }
7613    }
7614
7615    /// The label to give a footnote the author has not named: the lowest counting
7616    /// number no footnote in the document is already wearing.
7617    ///
7618    /// twig takes the label rather than minting one, because it holds no opinion
7619    /// about what a document's footnotes should be called — and it is right not
7620    /// to. Numbering them is what every author of a numbered note expects, and
7621    /// re-using a taken number would silently point the new reference at somebody
7622    /// else's note (twig reuses an existing definition rather than appending a
7623    /// second one, which is the right rule for citing a note twice on purpose and
7624    /// exactly the wrong accident to have by default).
7625    ///
7626    /// *References* are counted alongside definitions, not just definitions: a
7627    /// document carrying a dangling `[^2]` has a 2 that means something to
7628    /// whoever wrote it, and minting a definition for it here would answer a
7629    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
7630    /// count entirely — they take no number, so they block none.
7631    fn next_footnote_label(&mut self) -> String {
7632        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
7633            .into_iter()
7634            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
7635            .filter_map(|label| label.parse().ok())
7636            .collect();
7637        taken.extend(
7638            self.nodes()
7639                .into_iter()
7640                .filter(|n| n.kind == Kind::FootnoteReference)
7641                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
7642                .filter_map(|label| label.parse::<u32>().ok()),
7643        );
7644        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
7645    }
7646
7647    /// The footnote reference under the caret, resolved to the note it names.
7648    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
7649    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
7650        self.footnote_at(self.caret)
7651    }
7652
7653    /// The footnote reference at `off`, resolved to the note it names — what a
7654    /// frontend shows when a reader activates a `[^1]`.
7655    ///
7656    /// A reference is not a link node, so
7657    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
7658    /// (and should not) answer for one: a link names a destination to leave for,
7659    /// a reference names a note that is already in this document. Following one
7660    /// is a move within the page, which is why this hands back an `offset`
7661    /// rather than something to open.
7662    ///
7663    /// Offset-based rather than caret-only because the gesture that wants this
7664    /// most is the one that must not move the caret: a pointer hovering a `[1]`
7665    /// asks what note it names without disturbing where the reader was typing.
7666    /// The caret is just the offset a click already placed —
7667    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
7668    ///
7669    /// `None` when `off` stands in no reference. A reference whose note the
7670    /// document never defines is *not* `None` — it answers with the label it
7671    /// looked for and no text, which is what lets a frontend say so instead of
7672    /// silently doing nothing.
7673    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
7674        // Innermost-wins by latest start, the rule its link sibling uses.
7675        let span = self
7676            .nodes()
7677            .into_iter()
7678            .filter(|n| n.kind == Kind::FootnoteReference)
7679            .filter(|n| n.span.start <= off && off < n.span.end)
7680            .max_by_key(|n| n.span.start)?
7681            .span;
7682        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
7683
7684        // The note itself. Definitions are roots beside `doc` rather than
7685        // children of it, so they're asked for directly — see
7686        // `wysiwyg::footnote_definitions`.
7687        let note = wysiwyg::footnote_definitions(&mut self.editor)
7688            .into_iter()
7689            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
7690        let Some(note) = note else {
7691            return Some(FootnoteRef {
7692                label,
7693                text: None,
7694                offset: None,
7695                end: None,
7696            });
7697        };
7698        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
7699        Some(FootnoteRef {
7700            label,
7701            text: body
7702                .clone()
7703                .and_then(|b| self.source.get(b))
7704                .map(str::to_string),
7705            // The body's start, not the definition's — see `FootnoteRef::offset`.
7706            offset: body.clone().map(|b| b.start),
7707            end: body.map(|b| b.end),
7708        })
7709    }
7710
7711    /// The footnote *definition* the caret stands in, and where the reference
7712    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
7713    /// at the caret's offset.
7714    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
7715        self.footnote_definition_at(self.caret)
7716    }
7717
7718    /// The footnote definition spanning `off`, and where the reference that
7719    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
7720    ///
7721    /// The mirror image, deliberately: the same gesture that takes a reader from
7722    /// `[1]` down to the note takes them from the note back up to `[1]`, so
7723    /// following a footnote is a round trip rather than a fall. It needs no
7724    /// memory of how the reader arrived — the document says where the reference
7725    /// is — which is what makes it work for a reader who scrolled to the notes
7726    /// themselves, and what keeps it right after an edit moves either end.
7727    ///
7728    /// `None` when `off` stands in no definition. A definition nothing cites is
7729    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
7730    /// its label and no offset, so a frontend can say "nothing refers to this"
7731    /// rather than offer a jump that goes nowhere.
7732    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
7733        // Definitions are roots beside `doc`, so `nodes()` — which walks the
7734        // document body — never reports one. They're asked for directly, the way
7735        // `footnote_at` asks for the note it resolves to.
7736        //
7737        // Closed at the end, unlike the half-open test its neighbours use. A
7738        // definition's span stops at its last content byte — the newline ending
7739        // the line is outside it — so `span.end` is the caret stop at the end of
7740        // the note's own row, not the first byte of anything after. Excluding it
7741        // meant the one caret an author is guaranteed to have, the one left
7742        // sitting at the end of the note they just typed, was in no definition at
7743        // all: writing a note and then asking to go back to its reference
7744        // answered nothing. Two definitions in a row still can't both match —
7745        // there is a blank line between them — and `max_by_key` decides anyway.
7746        let note = wysiwyg::footnote_definitions(&mut self.editor)
7747            .into_iter()
7748            .filter(|m| m.span.start <= off && off <= m.span.end)
7749            .max_by_key(|m| m.span.start)?;
7750        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
7751
7752        // The earliest reference carrying this label. `min` rather than a `find`,
7753        // because `nodes()` reports a flattened walk whose order is twig's
7754        // business, not document order. Bound first: the walk needs `&mut self`
7755        // and reading the labels back out needs `&self.source`.
7756        let nodes = self.nodes();
7757        let offset = nodes
7758            .into_iter()
7759            .filter(|n| n.kind == Kind::FootnoteReference)
7760            .filter(|n| {
7761                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
7762            })
7763            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
7764            .map(|n| n.span.start + 2)
7765            .min();
7766        Some(FootnoteDef { label, offset })
7767    }
7768
7769    /// The destination of the image under the caret — what an image prompt shows
7770    /// so editing an existing image starts from its current URL instead of blank,
7771    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
7772    /// `None` when the caret stands in no image. A caret resting just after a
7773    /// block image (its trailing stop) is still "in" it — the half-open span test
7774    /// excludes that offset, which is the intended precision: past the image is
7775    /// past it.
7776    pub fn image_destination_at_caret(&mut self) -> Option<String> {
7777        let off = self.caret;
7778        self.nodes()
7779            .into_iter()
7780            .filter(|n| n.kind == Kind::Image)
7781            .filter(|n| n.span.start <= off && off < n.span.end)
7782            .max_by_key(|n| n.span.start)
7783            .and_then(|n| n.destination)
7784    }
7785
7786    /// The language of the fenced code block the caret stands in — what a
7787    /// language prompt shows so editing it starts from the current value rather
7788    /// than blank. `None` when the caret is in no code block, or in one whose
7789    /// fence carries no language (or an indented block, which has no fence).
7790    pub fn code_language_at_caret(&mut self) -> Option<String> {
7791        let start = self.code_block_start_at_caret()?;
7792        wysiwyg::code_language(&self.source, start)
7793    }
7794
7795    /// Whether the caret stands in a fenced code block — the one a language
7796    /// prompt could edit. A frontend gates its "set language" affordance on this
7797    /// (an indented block, which can't carry a language, reports `false`).
7798    pub fn caret_in_fenced_code(&mut self) -> bool {
7799        self.code_block_start_at_caret()
7800            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
7801    }
7802
7803    /// Set (or clear, with `""`) the language of the fenced code block the caret
7804    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
7805    /// block, and a reported error for a language the format's fence cannot
7806    /// carry.
7807    ///
7808    /// twig rewrites the info string, so the fence's own width — measured
7809    /// against a body neither side touches — is kept, and a language holding a
7810    /// space, a line end or the fence character is refused rather than written
7811    /// out to reparse as something else. Leaf used to splice over the info span
7812    /// itself and `trim()` the input, which handled the one bad case it had
7813    /// thought of.
7814    pub fn set_code_language(&mut self, lang: &str) {
7815        // The read-only gate — this door reaches twig without the splice.
7816        if self.read_only {
7817            return;
7818        }
7819        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
7820            return;
7821        }
7822        if self.code_block_start_at_caret().is_none() {
7823            return;
7824        }
7825        let lang = lang.trim();
7826        // `None` clears the info string; `Some("")` asks for an empty one. Both
7827        // write a bare fence, and the prompt's empty value means "clear".
7828        let want = (!lang.is_empty()).then_some(lang);
7829        self.record_caret();
7830        match self.editor.set_code_language(self.caret, want) {
7831            Ok(_) => {
7832                self.last_edit_kind = None;
7833                self.refresh();
7834                self.anchor = None;
7835                self.dirty = self.source != self.clean_source;
7836                self.status = None;
7837                self.clamp_caret();
7838                self.record_caret();
7839            }
7840            Err(e) => self.status = Some(format!("code language: {e}")),
7841        }
7842    }
7843
7844    /// The `span.start` of the code block covering the caret — the anchor
7845    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
7846    /// in none.
7847    fn code_block_start_at_caret(&mut self) -> Option<usize> {
7848        let off = self.caret;
7849        self.nodes()
7850            .into_iter()
7851            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
7852            .max_by_key(|n| n.span.start)
7853            .map(|n| n.span.start)
7854    }
7855
7856    /// The source range of the text inside the link covering `off` — what sits
7857    /// between its `[` and `]`. `None` when twig reports no link there.
7858    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
7859        self.nodes()
7860            .into_iter()
7861            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
7862            // the other's `span.start`; the link that starts latest at or before
7863            // `off` is the one `off` is actually in.
7864            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
7865            .max_by_key(|n| n.span.start)
7866            .and_then(|n| n.content_span)
7867    }
7868
7869    // ── undo / redo ───────────────────────────────────────────────────────────
7870    // twig owns the history of *bytes* (it owns the buffer) and now carries the
7871    // caret through it too: `record_caret` stashes each state's caret in twig's
7872    // opaque per-step blob, and undo/redo hand it back with the source they
7873    // restore. So leaf keeps no history of its own — no parallel stacks to march
7874    // in lockstep and silently drift out of it.
7875
7876    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
7877    /// where they were when that step began. Whether it did: `false` when
7878    /// there was nothing to undo, the document is read-only, or twig refused —
7879    /// the answer a host composing several histories into one walks on by.
7880    pub fn undo(&mut self) -> bool {
7881        if self.read_only {
7882            return false;
7883        }
7884        // Stepping through the history ends a group: what comes after is not
7885        // part of the step just taken back.
7886        self.close_undo_group();
7887        let (undone, redoable) = (self.undo_steps, self.redo_steps);
7888        match self.editor.undo() {
7889            Ok(Some(change)) => {
7890                self.after_history(change);
7891                // `refresh` counted the restore as an edit; it was a step back.
7892                self.undo_steps = undone.saturating_sub(1);
7893                self.redo_steps = redoable + 1;
7894                true
7895            }
7896            Ok(None) => {
7897                self.undo_steps = 0;
7898                self.status = Some("nothing to undo".into());
7899                false
7900            }
7901            Err(e) => {
7902                self.status = Some(format!("undo: {e}"));
7903                false
7904            }
7905        }
7906    }
7907
7908    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
7909    /// selection back where that step originally left them. Whether it did,
7910    /// as [`undo`](Self::undo) reports.
7911    pub fn redo(&mut self) -> bool {
7912        if self.read_only {
7913            return false;
7914        }
7915        self.close_undo_group();
7916        let (undone, redoable) = (self.undo_steps, self.redo_steps);
7917        match self.editor.redo() {
7918            Ok(Some(change)) => {
7919                self.after_history(change);
7920                // `refresh` counted the restore as an edit; it was a step forward.
7921                self.undo_steps = (undone + 1).min(UNDO_CAP);
7922                self.redo_steps = redoable.saturating_sub(1);
7923                true
7924            }
7925            Ok(None) => {
7926                self.redo_steps = 0;
7927                self.status = Some("nothing to redo".into());
7928                false
7929            }
7930            Err(e) => {
7931                self.status = Some(format!("redo: {e}"));
7932                false
7933            }
7934        }
7935    }
7936
7937    /// Fold the edit just made into the undo step before it — twig's
7938    /// `coalesce_last_undo`, with the `undo_steps` mirror following
7939    /// it. twig merges only when there are two steps to merge. Call it after
7940    /// [`refresh`](Self::refresh) has counted the edit being folded.
7941    ///
7942    /// Inside an [undo group](Self::begin_undo_group) it does nothing: the
7943    /// group's edits are already one step, which [`refresh`](Self::refresh)
7944    /// folds as they come, so a fold here could only reach across the group's
7945    /// start into the step before it.
7946    fn coalesce_last_undo(&mut self) {
7947        if self.undo_group.is_some() {
7948            return;
7949        }
7950        self.fold_last_undo();
7951    }
7952
7953    /// twig's `coalesce_last_undo`, unconditionally, with the mirror following.
7954    fn fold_last_undo(&mut self) {
7955        if self.editor.coalesce_last_undo().is_ok() && self.undo_steps >= 2 {
7956            self.undo_steps -= 1;
7957        }
7958    }
7959
7960    /// Open an undo group: every edit from here to the matching
7961    /// [`end_undo_group`](Self::end_undo_group) is one step, which a single
7962    /// [`undo`](Self::undo) takes back whole and a single [`redo`](Self::redo)
7963    /// puts back — a Replace All over twelve matches, or a Writing Tools
7964    /// session, rather than twelve presses of ⌘Z.
7965    ///
7966    /// Groups nest, and only the outermost end closes one. The step is folded
7967    /// as each edit lands (twig's `coalesce_last_undo`, one edit at a time),
7968    /// so a group of a thousand edits holds one step on the history and never
7969    /// meets twig's cap. Nothing typed before the group folds into it, and
7970    /// nothing typed after: it is a step of its own, even when it is a single
7971    /// edit. A group no edit landed in leaves no step at all.
7972    ///
7973    /// An [`undo`](Self::undo) or [`redo`](Self::redo) closes any open group,
7974    /// whatever its depth — the history has moved, and an edit made after it
7975    /// is not part of the step it moved past. A later `end_undo_group` is
7976    /// then a no-op.
7977    pub fn begin_undo_group(&mut self) {
7978        match &mut self.undo_group {
7979            Some(g) => g.depth += 1,
7980            None => {
7981                self.undo_group = Some(UndoGroup {
7982                    depth: 1,
7983                    has_step: false,
7984                });
7985                // The first edit inside is not a continuation of the typing
7986                // before it.
7987                self.last_edit_kind = None;
7988            }
7989        }
7990    }
7991
7992    /// Close the undo group [`begin_undo_group`](Self::begin_undo_group)
7993    /// opened. A no-op when none is open.
7994    pub fn end_undo_group(&mut self) {
7995        let Some(g) = &mut self.undo_group else {
7996            return;
7997        };
7998        if g.depth > 1 {
7999            g.depth -= 1;
8000        } else {
8001            self.close_undo_group();
8002        }
8003    }
8004
8005    /// Whether an undo group is open.
8006    pub fn in_undo_group(&self) -> bool {
8007        self.undo_group.is_some()
8008    }
8009
8010    /// Close any open group, however deeply nested.
8011    fn close_undo_group(&mut self) {
8012        if self.undo_group.take().is_some() {
8013            // Typing after the group is not a continuation of the last edit
8014            // in it.
8015            self.last_edit_kind = None;
8016        }
8017    }
8018
8019    /// Refresh the cached source and put the caret back where the step being
8020    /// undone/redone had it, clearing any active run.
8021    ///
8022    /// The caret comes from twig's blob for the restored state (what
8023    /// `record_caret` stored). `change` is only the fallback for a state with no
8024    /// blob — a caret at the end of the restored text, which is where this always
8025    /// landed before the blobs were kept. It is the edit site, not where the user
8026    /// was standing, so it's a floor and not the behaviour: undoing should hand
8027    /// back the document *and* the place you were working, which for an edit made
8028    /// anywhere but under the caret are two different places.
8029    fn after_history(&mut self, change: Change) {
8030        self.refresh();
8031        match self
8032            .editor
8033            .caret_blob()
8034            .ok()
8035            .and_then(|b| CaretState::from_blob(&b))
8036        {
8037            Some(state) => {
8038                self.caret = state.caret.min(self.source.len());
8039                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
8040            }
8041            None => {
8042                self.caret = change.new.end.min(self.source.len());
8043                self.anchor = None;
8044            }
8045        }
8046        self.goal_col = None;
8047        self.last_edit_kind = None;
8048        self.dirty = self.source != self.clean_source;
8049        self.status = None;
8050        self.clamp_caret();
8051    }
8052
8053    // ── the file ──────────────────────────────────────────────────────────────
8054
8055    #[cfg(feature = "fs")]
8056    pub fn save(&mut self) {
8057        if self.is_untitled() {
8058            // No path to write and no name to invent: ⌘S on an untitled document
8059            // is a Save As, and only a frontend has a picker to ask with. Say so
8060            // rather than failing at the filesystem with an empty path.
8061            self.status = Some("untitled — save as…".into());
8062            return;
8063        }
8064        let path = self.path.clone();
8065        if self.write(&path) {
8066            self.mark_saved();
8067        }
8068    }
8069
8070    /// Save As: write the document to `path` and *move* it there — `self.path`
8071    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
8072    /// what Save As means; a copy would leave the user editing a document whose
8073    /// name is no longer where their keystrokes go.
8074    ///
8075    /// The move only happens if the bytes actually landed. A failed write leaves
8076    /// the path, `dirty`, and the disk watermark exactly as they were, with the
8077    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
8078    /// must never come away believing it was saved.
8079    ///
8080    /// An existing `path` is overwritten, and the caller is the one that knows
8081    /// whether to ask first: a Save As picker has already run that prompt, and a
8082    /// second confirmation from down here would be the same question twice.
8083    ///
8084    /// `format` does **not** follow the new extension. The buffer is parsed as
8085    /// the format it was opened with, and re-reading it as another one is a
8086    /// conversion — a different, lossy operation that would throw away the undo
8087    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
8088    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
8089    /// until it's reopened.
8090    #[cfg(feature = "fs")]
8091    pub fn save_as(&mut self, path: PathBuf) {
8092        if !self.write(&path) {
8093            return;
8094        }
8095        self.path = path;
8096        self.mark_saved();
8097    }
8098
8099    /// Put `source` on disk at `path`, reporting whether it got there. The one
8100    /// place leaf writes a document, so a save and a Save As can't disagree
8101    /// about what a failure looks like.
8102    #[cfg(feature = "fs")]
8103    fn write(&mut self, path: &Path) -> bool {
8104        match std::fs::write(path, self.source.as_bytes()) {
8105            Ok(()) => true,
8106            Err(e) => {
8107                self.status = Some(format!("save failed: {e}"));
8108                false
8109            }
8110        }
8111    }
8112
8113    /// Re-base the document's saved watermark to the current bytes: clears
8114    /// `dirty`, records `source` as the new clean state (so undoing back to here
8115    /// clears the flag again), and re-stamps the on-disk hash.
8116    ///
8117    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
8118    /// the hook a **filesystem-free host** calls itself once it has persisted
8119    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
8120    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
8121    /// are already where that host wants them, and this just tells the model they
8122    /// are safe.
8123    pub fn mark_saved(&mut self) {
8124        self.clean_source = self.source.clone();
8125        self.dirty = false;
8126        // The bytes on disk are now ours, so this is the new watermark: without
8127        // re-stamping it, every save would report its own work as an external
8128        // change forever after.
8129        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
8130        self.status = Some(format!("saved {}", self.file_name()));
8131    }
8132
8133    /// [`Doc::mark_saved`] for a host whose write is not instantaneous: `saved`
8134    /// is the source it read before writing, and the edits made since are
8135    /// still unsaved.
8136    ///
8137    /// A host that reads [`Doc::source`], hands it to a write that takes a
8138    /// while, and then calls `mark_saved` loses whatever was typed meanwhile:
8139    /// the flag is cleared over text that never reached the disk, so nothing
8140    /// saves it again. Here `saved` becomes the clean state and the disk
8141    /// watermark, and `dirty` is whether the buffer has moved on from it.
8142    pub fn mark_saved_as(&mut self, saved: &str) {
8143        self.clean_source = saved.to_string();
8144        self.dirty = self.source != self.clean_source;
8145        self.disk_hash = Some(hash_bytes(saved.as_bytes()));
8146        if !self.dirty {
8147            self.status = Some(format!("saved {}", self.file_name()));
8148        }
8149    }
8150
8151    /// What the file looks like now against the bytes leaf last read or wrote.
8152    ///
8153    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
8154    /// so this is a filesystem round-trip, not a per-frame question — ask it
8155    /// when a window regains focus, on a timer, or before a save.
8156    ///
8157    /// This *only* reports the file. Whether the document also has unsaved edits
8158    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
8159    /// [`DiskState::Changed`] means a save overwrites someone's work and a
8160    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
8161    /// it has no way to ask — so it hands a frontend both halves and lets it put
8162    /// the question to the person who can answer it.
8163    #[cfg(feature = "fs")]
8164    pub fn disk_state(&self) -> DiskState {
8165        let Some(want) = self.disk_hash else {
8166            return DiskState::Untitled;
8167        };
8168        match std::fs::read(&self.path) {
8169            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
8170            Ok(_) => DiskState::Changed,
8171            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
8172            Err(_) => DiskState::Unreadable,
8173        }
8174    }
8175
8176    /// Re-read the file and replace the document with what's there — the other
8177    /// answer to a [`DiskState::Changed`].
8178    ///
8179    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
8180    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
8181    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
8182    /// document shouldn't have to argue with a guard.
8183    ///
8184    /// **The undo history survives, and the reload is one step in it.** The
8185    /// whole buffer is spliced with the file's bytes through the same door every
8186    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
8187    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
8188    /// swapped the document out from under a reader gives them back what they
8189    /// were looking at, marked dirty, and ^Z again carries on into whatever they
8190    /// had done before it. This used to build a fresh parse and drop the stack,
8191    /// on the reasoning that twig's history belongs to the buffer and these are
8192    /// different bytes; that is true of *rebasing* a step onto them and not of
8193    /// recording the swap itself as one, which is all this is. A splice twig
8194    /// won't take falls back to the fresh parse, and only that path still costs
8195    /// the history.
8196    ///
8197    /// The caret keeps its byte offset, clamped to the new length; the selection
8198    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
8199    /// file changed, so it can't know where the caret "still" is. Clamping keeps
8200    /// it where the user left it in the common case (a change further down the
8201    /// file, or none in the text they're sitting in), and never puts it
8202    /// somewhere invalid. A selection has two such offsets and no such excuse —
8203    /// silently reinterpreting one over changed bytes would arm the *next*
8204    /// keystroke to delete something the user never selected.
8205    ///
8206    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
8207    /// document alone with a status.
8208    #[cfg(feature = "fs")]
8209    pub fn reload(&mut self) {
8210        if self.is_untitled() {
8211            self.status = Some("no file to reload".into());
8212            return;
8213        }
8214        let bytes = match std::fs::read(&self.path) {
8215            Ok(b) => b,
8216            Err(e) => {
8217                self.status = Some(format!("reload failed: {e}"));
8218                return;
8219            }
8220        };
8221        let Ok(source) = String::from_utf8(bytes) else {
8222            self.status = Some("reload failed: file is not UTF-8".into());
8223            return;
8224        };
8225        // Already these bytes — someone saved a file back unchanged, or leaf's
8226        // own write is being read back. Re-baseline against it and stop: a
8227        // splice of the text onto itself would put an undo step on the stack for
8228        // something nobody did.
8229        if source == self.source {
8230            self.disk_hash = Some(hash_bytes(source.as_bytes()));
8231            self.clean_source = source;
8232            self.dirty = false;
8233            self.status = Some(format!("reloaded {}", self.file_name()));
8234            return;
8235        }
8236        let caret = self.caret;
8237        // The pre-reload caret, so undoing the swap puts it back where the
8238        // reader was standing — the same bracketing `splice_exact` does.
8239        self.record_caret();
8240        if self
8241            .editor
8242            .edit_range(0, self.source.len(), &source)
8243            .is_ok()
8244        {
8245            self.refresh();
8246        } else {
8247            // twig wouldn't take the splice. Start over from the bytes, which is
8248            // what this always did, and is the one path that still costs the
8249            // history — `format` is the format this document *is*, not what the
8250            // (unchanged) name now says, see `save_as`.
8251            match new_editor(source.as_bytes(), self.format) {
8252                Ok(editor) => {
8253                    self.editor = editor;
8254                    // A fresh editor, and so a fresh history — with no
8255                    // step in it for an open group to fold into.
8256                    self.undo_steps = 0;
8257                    self.redo_steps = 0;
8258                    if let Some(g) = &mut self.undo_group {
8259                        g.has_step = false;
8260                    }
8261                    self.source = source.clone();
8262                    // Not going through `refresh`, so the revision has to move
8263                    // here or every frontend keeps painting the old file from
8264                    // cache.
8265                    self.revision += 1;
8266                }
8267                Err(e) => {
8268                    self.status = Some(format!("reload failed: {e}"));
8269                    return;
8270                }
8271            }
8272        }
8273        self.disk_hash = Some(hash_bytes(source.as_bytes()));
8274        self.clean_source = self.source.clone();
8275        self.caret = caret.min(self.source.len());
8276        self.anchor = None;
8277        self.goal_col = None;
8278        self.last_edit_kind = None;
8279        self.dirty = false;
8280        self.status = Some(format!("reloaded {}", self.file_name()));
8281        self.clamp_caret();
8282        // And the post-reload caret, so a redo restores it.
8283        self.record_caret();
8284    }
8285
8286    /// Re-read the source from twig after it has changed the document. The one
8287    /// funnel every edit, undo, and redo comes through — so it's where the
8288    /// revision moves, and anything cached against the text dies here.
8289    fn refresh(&mut self) {
8290        if let Ok(s) = self.editor.source_str() {
8291            self.source = s;
8292        }
8293        self.revision += 1;
8294        // An edit is a step onto the history and the end of anything undone;
8295        // `undo`/`redo` come through here too and correct this after.
8296        self.undo_steps = (self.undo_steps + 1).min(UNDO_CAP);
8297        self.redo_steps = 0;
8298        // Inside a group, fold each step into the group's first as it lands.
8299        // Not for `undo`/`redo`, which close the group before they get here.
8300        if let Some(g) = &mut self.undo_group {
8301            if g.has_step {
8302                self.fold_last_undo();
8303            } else {
8304                g.has_step = true;
8305            }
8306        }
8307        self.clamp_caret();
8308    }
8309
8310    /// Whether [`undo`](Self::undo) has a step to take back — for a native
8311    /// Edit menu to enable its item by, and exactly when `undo` would move.
8312    pub fn can_undo(&self) -> bool {
8313        !self.read_only && self.undo_steps > 0
8314    }
8315
8316    /// Whether [`redo`](Self::redo) has an undone step to restore.
8317    pub fn can_redo(&self) -> bool {
8318        !self.read_only && self.redo_steps > 0
8319    }
8320
8321    // ── caret movement ─────────────────────────────────────────────────────────
8322    // `extend` grows the selection (Shift+motion): it pins the anchor on the
8323    // first extended step and moves only the caret; an un-extended motion drops
8324    // the selection.
8325
8326    /// Place the caret at byte `offset` (clamped to a char boundary), extending
8327    /// the selection when `extend` is set. The public form of `move_to`, for a
8328    /// frontend that hit-tests pixels straight to a source offset.
8329    pub fn place_caret(&mut self, offset: usize, extend: bool) {
8330        self.goal_col = None;
8331        let before = self.caret;
8332        // A pixel hit-test can land between the visible caret stops — in the
8333        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
8334        // Snap to the nearest real stop so the caret can't come to rest where it
8335        // would draw in one place and type in another. The `(row, col)` click
8336        // path (`click`) already snaps this way through `offset_of_pos`; the
8337        // source view reaches every byte, so it snaps to nothing.
8338        let target = match self.view {
8339            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
8340            // The source view reaches every byte, so there is no stop to snap
8341            // to — but "every byte" still means every *character* boundary. A
8342            // caret resting inside a multi-byte character draws nowhere real
8343            // and panics the next time anything slices there.
8344            View::Source => self.char_boundary_at_or_before(offset),
8345        };
8346        self.move_to(target, extend);
8347        self.clamp_caret();
8348        self.debug_assert_on_a_stop(before);
8349    }
8350
8351    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
8352    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
8353    /// grab the metadata) while the source view still selects the literal whole.
8354    pub fn select_all(&mut self) {
8355        self.anchor = Some(self.caret_floor());
8356        self.caret = self.source.len();
8357        self.goal_col = None;
8358        self.last_edit_kind = None;
8359        self.status = None;
8360    }
8361
8362    /// Select the word (or whitespace / punctuation run) at `offset` — the
8363    /// double-click gesture. Anchors on the run's start with the caret at its
8364    /// end so a following Shift-motion extends from the far edge.
8365    pub fn select_word_at(&mut self, offset: usize) {
8366        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
8367        self.anchor = Some(s);
8368        self.caret = e;
8369        self.goal_col = None;
8370        self.last_edit_kind = None;
8371        self.status = None;
8372        self.clamp_caret();
8373    }
8374
8375    /// Select the whole enclosing text block (paragraph, heading, list item's
8376    /// text…) at `offset` — the triple-click gesture. Reads the range straight
8377    /// from the AST (twig's `content_span`), so it selects the entire *logical*
8378    /// paragraph even when that paragraph soft-wraps across several visual rows —
8379    /// where a visual-row-based select breaks down, because one source offset at
8380    /// a wrap boundary belongs to two rows at once.
8381    pub fn select_block_at(&mut self, offset: usize) {
8382        let off = offset.min(self.source.len());
8383        let range = self
8384            .editor
8385            .ancestors_at(off)
8386            .ok()
8387            .and_then(|chain| {
8388                // Ancestors run root → deepest; the deepest node that is neither
8389                // an inline span nor a multi-block container is the text block
8390                // the caret sits in (a paragraph, a heading, a code block…).
8391                chain
8392                    .into_iter()
8393                    .rev()
8394                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
8395                    .map(|m| m.content_span.unwrap_or(m.span))
8396            })
8397            .unwrap_or_else(|| source_line_range(&self.source, off));
8398        self.anchor = Some(range.start.min(self.source.len()));
8399        self.caret = range.end.min(self.source.len());
8400        self.goal_col = None;
8401        self.last_edit_kind = None;
8402        self.status = None;
8403        self.clamp_caret();
8404    }
8405
8406    /// Select the exact source range `[start, end)` — anchor at `start`, caret
8407    /// at `end` — without snapping either end to a visible caret stop.
8408    ///
8409    /// The one caret verb that takes a range it was *handed* rather than one it
8410    /// worked out, for a host that already knows the bytes it means: a search
8411    /// hit, an annotation's footprint, a quote re-anchored through
8412    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
8413    /// wrong tool for that, and not by a little — it snaps to the nearest
8414    /// *visible* stop, and where a range butts up against a hidden delimiter
8415    /// the nearest stop is the one before it, so selecting the "needle" of
8416    /// `**needle**` comes back with "needl" and an edit against it strands the
8417    /// "e".
8418    ///
8419    /// What `place_caret` does that is bookkeeping rather than snapping still
8420    /// happens here, because a host handing in a range is not asking to opt out
8421    /// of the invariants:
8422    ///
8423    /// - both ends are clamped into the document and up to
8424    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
8425    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
8426    ///   types into the metadata;
8427    /// - both land on character boundaries, so nothing slices a `é` in half;
8428    /// - the sticky vertical goal column is dropped, and any armed inline mark
8429    ///   disarmed, since a range from outside inherits neither.
8430    ///
8431    /// An empty range is a caret rather than a selection —
8432    /// [`selection`](Self::selection) reports `None` for it, as it does for any
8433    /// anchor that has met the caret.
8434    pub fn select_range(&mut self, start: usize, end: usize) {
8435        let floor = self.caret_floor();
8436        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
8437        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
8438        self.anchor = Some(anchor);
8439        self.caret = caret;
8440        self.goal_col = None;
8441        self.status = None;
8442        self.last_edit_kind = None;
8443        self.clear_pending();
8444    }
8445
8446    /// `offset` itself if it is a character boundary, else the boundary before
8447    /// it. An offset that isn't one draws nowhere real and panics the next time
8448    /// anything slices there.
8449    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
8450        let mut o = offset.min(self.source.len());
8451        while o > 0 && !self.source.is_char_boundary(o) {
8452            o -= 1;
8453        }
8454        o
8455    }
8456
8457    /// The lowest source offset the caret may occupy in the active view. In
8458    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
8459    /// the first rendered offset; the source view reaches everything, so it's 0.
8460    fn caret_floor(&self) -> usize {
8461        match self.view {
8462            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
8463            View::Source => 0,
8464        }
8465    }
8466
8467    /// Land in a table cell with its whole content selected — the anchor at the
8468    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
8469    /// like tabbing into a form field: the text comes up selected, so typing
8470    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
8471    /// end`) collapses to a plain caret home (an empty selection is no selection).
8472    fn select_cell(&mut self, start: usize, end: usize) {
8473        self.select_range(start, end);
8474    }
8475
8476    fn move_to(&mut self, offset: usize, extend: bool) {
8477        if extend {
8478            if self.anchor.is_none() {
8479                self.anchor = Some(self.caret);
8480            }
8481        } else {
8482            self.anchor = None;
8483        }
8484        self.caret = offset.min(self.source.len()).max(self.caret_floor());
8485        self.status = None;
8486        // A caret move ends the current typing/deletion run, so the next edit
8487        // starts a fresh undo group rather than coalescing across the gap.
8488        self.last_edit_kind = None;
8489        // Moving away disarms any sticky mark — "start bold" applies only where
8490        // it was asked for, not wherever the caret next lands.
8491        self.clear_pending();
8492    }
8493
8494    // In the source view, motion walks source bytes / source lines. In the
8495    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
8496    // what steps the caret cleanly over hidden delimiters.
8497
8498    pub fn move_left(&mut self, extend: bool) {
8499        self.goal_col = None;
8500        if !extend && let Some((s, _e)) = self.selection() {
8501            self.move_to(s, false);
8502            return;
8503        }
8504        let target = match self.view {
8505            View::Source => {
8506                if self.caret > 0 {
8507                    prev_boundary(&self.source, self.caret)
8508                } else {
8509                    0
8510                }
8511            }
8512            // Walks caret *stops*, not columns: decoration (a table border, a
8513            // cell's padding) is stepped over in one press, and a hidden
8514            // delimiter never holds the caret up — though the end of a mark's
8515            // content is a stop of its own (`VisualMap::mark_ends`), so
8516            // leaving `**bold**` from past its `**` is a press onto the end of
8517            // the bold and another onto the `d`.
8518            View::Wysiwyg => self
8519                .vmap
8520                .caret_stop_before(self.caret)
8521                .unwrap_or(self.caret),
8522        };
8523        let before = self.caret;
8524        self.move_to(target, extend);
8525        self.debug_assert_on_a_stop(before);
8526    }
8527
8528    pub fn move_right(&mut self, extend: bool) {
8529        self.goal_col = None;
8530        if !extend && let Some((_s, e)) = self.selection() {
8531            self.move_to(e, false);
8532            return;
8533        }
8534        let target = match self.view {
8535            View::Source => {
8536                if self.caret < self.source.len() {
8537                    next_boundary(&self.source, self.caret)
8538                } else {
8539                    self.caret
8540                }
8541            }
8542            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
8543        };
8544        let before = self.caret;
8545        self.move_to(target, extend);
8546        self.debug_assert_on_a_stop(before);
8547    }
8548
8549    /// Move to the start of the previous word (⌥← / Ctrl+←).
8550    pub fn move_word_left(&mut self, extend: bool) {
8551        self.goal_col = None;
8552        let before = self.caret;
8553        let target = self.word_left_from(self.caret);
8554        self.move_to(target, extend);
8555        self.debug_assert_on_a_stop(before);
8556    }
8557
8558    /// Move to the end of the next word (⌥→ / Ctrl+→).
8559    pub fn move_word_right(&mut self, extend: bool) {
8560        self.goal_col = None;
8561        let before = self.caret;
8562        let target = self.word_right_from(self.caret);
8563        self.move_to(target, extend);
8564        self.debug_assert_on_a_stop(before);
8565    }
8566
8567    // Word boundaries are found in the space the *view* is in. The source view
8568    // walks the source, because there the source is what's rendered. WYSIWYG
8569    // walks the rendered text instead: `**` is invisible to the user, so it has
8570    // to be invisible to word motion too — a caret parked inside one draws in
8571    // the column after `bold` and types two bytes earlier, and a word-delete
8572    // that stops there shreds the markup into `a ** c`.
8573
8574    /// The word boundary to the left of `off` in the active view's space.
8575    fn word_left_from(&self, off: usize) -> usize {
8576        match self.view {
8577            View::Source => prev_word(&self.source, off),
8578            View::Wysiwyg => self.glyph_word_left(off),
8579        }
8580    }
8581
8582    /// The word boundary to the right of `off` in the active view's space.
8583    fn word_right_from(&self, off: usize) -> usize {
8584        match self.view {
8585            View::Source => next_word(&self.source, off),
8586            View::Wysiwyg => self.glyph_word_right(off),
8587        }
8588    }
8589
8590    /// The character class of the glyph drawn at stop `off`.
8591    ///
8592    /// Read from the source, because a stop points at the source byte its glyph
8593    /// came from — the source *is* where the rendered character is written. What
8594    /// makes the walk glyph space rather than source space is that it only ever
8595    /// visits stops, and the hidden bytes between them have none.
8596    fn class_at(&self, off: usize) -> Class {
8597        self.source
8598            .get(off..)
8599            .and_then(|s| s.chars().next())
8600            .map_or(Class::Space, classify)
8601    }
8602
8603    /// [`next_word`] in glyph space: skip any leading separators, then consume
8604    /// the following word run, with the stop table standing in for the source's
8605    /// characters.
8606    fn glyph_word_right(&self, from: usize) -> usize {
8607        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
8608            return from;
8609        };
8610        let mut in_word = false;
8611        loop {
8612            match self.class_at(off) {
8613                Class::Word => in_word = true,
8614                _ if in_word => return off,
8615                _ => {}
8616            }
8617            match self.vmap.stop_after(off) {
8618                Some(next) => off = next,
8619                None => return off,
8620            }
8621        }
8622    }
8623
8624    /// [`prev_word`] in glyph space: skip separators walking left, then consume
8625    /// the preceding word run.
8626    fn glyph_word_left(&self, from: usize) -> usize {
8627        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
8628            return from;
8629        };
8630        let mut in_word = false;
8631        while let Some(prev) = self.vmap.stop_before(off) {
8632            match self.class_at(prev) {
8633                Class::Word => in_word = true,
8634                _ if in_word => return off,
8635                _ => {}
8636            }
8637            off = prev;
8638        }
8639        off
8640    }
8641
8642    /// After a motion that walks the visual map, the caret must be *on* the map.
8643    /// A stop is the only offset where the caret draws and edits in the same
8644    /// place, and it's the invariant both a caret parked inside an emoji and one
8645    /// parked inside a `**` were quietly breaking.
8646    ///
8647    /// Only when the caret actually moved: a walk with nowhere to go leaves it
8648    /// where it was, which is wherever the floor or a frontend put it rather
8649    /// than somewhere this motion chose.
8650    fn debug_assert_on_a_stop(&self, before: usize) {
8651        debug_assert!(
8652            self.view != View::Wysiwyg
8653                || self.vmap.num_rows() == 0
8654                || self.caret == before
8655                || self.vmap.is_stop(self.caret),
8656            "motion left the caret at {}, which is not a caret stop: it would draw in \
8657             one place and type in another",
8658            self.caret
8659        );
8660    }
8661
8662    // Up and Down run off the ends of the document rather than stopping dead at
8663    // them: Up from the first row lands at the document's start, Down from the
8664    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
8665    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
8666    // reaching the end of the text is what a reader means by it.
8667    //
8668    // The views used to disagree here by accident rather than by decision: the
8669    // source view fell into the edge behaviour through `row_col_to_offset`
8670    // clamping an out-of-range row to the end of the string, while WYSIWYG had
8671    // no row below to walk to and did nothing at all. They share the rule now,
8672    // each in its own space — the source view reaches every byte, WYSIWYG only
8673    // the offsets it draws.
8674
8675    pub fn move_up(&mut self, extend: bool) {
8676        let (row, col) = self.caret_pos();
8677        let goal = self.goal_col.unwrap_or(col);
8678        let target = match self.view {
8679            View::Source => match row.checked_sub(1) {
8680                Some(r) => row_col_to_offset(&self.source, r, goal),
8681                None => self.reachable_start(),
8682            },
8683            // A table's border rules are drawn but hold no caret, so Up steps
8684            // over them to the row that does.
8685            View::Wysiwyg => match self.vmap.navigable_above(row) {
8686                Some(r) => self.row_target(r, goal),
8687                None => self.reachable_start(),
8688            },
8689        };
8690        self.step_vertical(target, goal, extend);
8691    }
8692
8693    pub fn move_down(&mut self, extend: bool) {
8694        let (row, col) = self.caret_pos();
8695        let goal = self.goal_col.unwrap_or(col);
8696        let target = match self.view {
8697            View::Source => match self.source_row_below(row) {
8698                Some(r) => row_col_to_offset(&self.source, r, goal),
8699                None => self.reachable_end(),
8700            },
8701            View::Wysiwyg => match self.vmap.navigable_below(row) {
8702                Some(r) => self.row_target(r, goal),
8703                None => self.reachable_end(),
8704            },
8705        };
8706        self.step_vertical(target, goal, extend);
8707    }
8708
8709    /// Land a vertical motion at `target`, latching the `goal` column it aimed
8710    /// with so the rest of the run keeps aiming there.
8711    ///
8712    /// A motion with nowhere to go changes *nothing*, the goal column included:
8713    /// the latch used to run before the early return at the top of the document,
8714    /// so an Up that did nothing still armed a column, and the next Down aimed
8715    /// at one the caret had never been in.
8716    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
8717        let before = self.caret;
8718        if target == before {
8719            return;
8720        }
8721        self.goal_col = Some(goal);
8722        self.move_to(target, extend);
8723        self.debug_assert_on_a_stop(before);
8724    }
8725
8726    /// The source line below `row`, or `None` when `row` is the last one. Lines
8727    /// are counted by newline, so a trailing one leaves a real, empty last line
8728    /// for the caret to sit on — the document ends below it, not on it.
8729    fn source_row_below(&self, row: usize) -> Option<usize> {
8730        let last = self.source.bytes().filter(|&b| b == b'\n').count();
8731        (row < last).then_some(row + 1)
8732    }
8733
8734    /// Where a vertical motion aiming at the `goal` column lands on visual row
8735    /// `r`: the column clamped to the row, mapped to its offset, then held
8736    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
8737    /// column belongs to the row below, and a gutter's column 0 points at the
8738    /// block rather than at this row.
8739    fn row_target(&self, r: usize, goal: usize) -> usize {
8740        let (start, end) = self.row_bounds(r);
8741        self.vmap
8742            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
8743            .clamp(start, end)
8744    }
8745
8746    /// The first and last offsets the caret can reach in the active view.
8747    ///
8748    /// Not the same span in both: the source view shows every byte, so it can
8749    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
8750    /// sits below the first stop, and a document's trailing newline is drawn
8751    /// nowhere and so sits past the last.
8752    fn reachable_start(&self) -> usize {
8753        match self.view {
8754            View::Source => 0,
8755            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
8756        }
8757    }
8758
8759    fn reachable_end(&self) -> usize {
8760        match self.view {
8761            View::Source => self.source.len(),
8762            View::Wysiwyg => self
8763                .vmap
8764                .stop_at_or_before(self.source.len())
8765                .unwrap_or(self.caret),
8766        }
8767    }
8768
8769    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
8770    /// including the space a soft wrap ate off its end, which is drawn on this
8771    /// row however much the offset past it belongs to the next one.
8772    fn row_span(&self, r: usize) -> (usize, usize) {
8773        let start = self
8774            .vmap
8775            .row_start(r)
8776            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
8777        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
8778        (start.min(end), end)
8779    }
8780
8781    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
8782    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
8783    /// this row's last position is the one before it — the offset before the
8784    /// space the wrap ate, where the caret draws just past the row's last word
8785    /// and types there too.
8786    ///
8787    /// Aiming at the shared offset instead is what stalled End: it is the row's
8788    /// last *column*, so End pressed on the row reached it and then read back as
8789    /// the row below's start, where a second press ran on to that row's end and
8790    /// the next to the one after — End walking down the paragraph a row a press.
8791    fn row_bounds(&self, r: usize) -> (usize, usize) {
8792        let (start, end) = self.row_span(r);
8793        let wraps = self
8794            .vmap
8795            .navigable_below(r)
8796            .and_then(|b| self.vmap.row_start(b))
8797            .is_some_and(|off| off == end);
8798        match wraps {
8799            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
8800            false => (start, end),
8801        }
8802    }
8803
8804    /// The `[start, end]` of the line Home and End aim at: the visual row in
8805    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
8806    ///
8807    /// A soft-wrapped row is a line here, because it is one to the eye and the
8808    /// eye is what these keys are aimed by — a reader pressing End means the end
8809    /// of the line they can see. (`select_block_at` wants the opposite and reads
8810    /// the AST for it: a triple-click grabs the whole paragraph, however many
8811    /// rows it folds into.)
8812    fn line_bounds(&self) -> (usize, usize) {
8813        let (row, _) = self.caret_pos();
8814        match self.view {
8815            View::Source => {
8816                let start = line_start(&self.source, row);
8817                (start, line_end_from(&self.source, start))
8818            }
8819            View::Wysiwyg => self.row_bounds(row),
8820        }
8821    }
8822
8823    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
8824    /// *drawn* — what a kill takes.
8825    ///
8826    /// The two part only at a soft wrap, over the space the wrap ate: the caret
8827    /// can't stand after it (that offset opens the row below, and End stopping
8828    /// there would walk), but it is on this row, and a kill that spared it would
8829    /// leave a double space behind where the row's text had been. Deleting it
8830    /// joins nothing — a wrap is drawn, not written.
8831    fn line_span(&self) -> (usize, usize) {
8832        let (row, _) = self.caret_pos();
8833        match self.view {
8834            View::Source => self.line_bounds(),
8835            View::Wysiwyg => self.row_span(row),
8836        }
8837    }
8838
8839    /// The first offset in `[start, end]` holding something other than
8840    /// whitespace, or `end` when the line holds nothing else — where Home aims.
8841    ///
8842    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
8843    /// so a hidden delimiter is never taken for the line's first character (nor
8844    /// landed on), and the source view steps the source it is showing.
8845    fn first_non_space(&self, start: usize, end: usize) -> usize {
8846        let mut off = start;
8847        while off < end {
8848            if self.class_at(off) != Class::Space {
8849                return off;
8850            }
8851            off = match self.view {
8852                View::Source => next_boundary(&self.source, off),
8853                View::Wysiwyg => match self.vmap.stop_after(off) {
8854                    Some(next) => next,
8855                    None => return end,
8856                },
8857            };
8858        }
8859        end
8860    }
8861
8862    /// Home: to the first character on the line, or to column 0 when the caret
8863    /// is already on it — the two-press toggle every editor spells this way.
8864    /// The indentation is somewhere the caret has to be able to reach and almost
8865    /// never where a reader is headed, so it costs the second press.
8866    pub fn move_home(&mut self, extend: bool) {
8867        self.goal_col = None;
8868        let (start, end) = self.line_bounds();
8869        let text = self.first_non_space(start, end);
8870        let target = if self.caret == text { start } else { text };
8871        let before = self.caret;
8872        self.move_to(target, extend);
8873        self.debug_assert_on_a_stop(before);
8874    }
8875
8876    /// End: to the end of the line.
8877    pub fn move_end(&mut self, extend: bool) {
8878        self.goal_col = None;
8879        let (_, end) = self.line_bounds();
8880        let before = self.caret;
8881        self.move_to(end, extend);
8882        self.debug_assert_on_a_stop(before);
8883    }
8884
8885    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
8886    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
8887    /// when the caret isn't in a table, or is already in the last/first cell — the
8888    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
8889    /// meaning everywhere else.
8890    pub fn cell_hop(&mut self, forward: bool) -> bool {
8891        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8892            return false;
8893        };
8894        // Flatten to document (row-major) order and step one cell either way.
8895        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
8896        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
8897        let next = if forward {
8898            i.checked_add(1)
8899        } else {
8900            i.checked_sub(1)
8901        };
8902        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
8903            return false; // at the table's edge; leave Tab to the frontend
8904        };
8905        self.select_cell(start, end);
8906        true
8907    }
8908
8909    /// Move the caret to the cell directly above (`down == false`) or below in
8910    /// the same column, landing with the cell's whole content selected (see
8911    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
8912    /// when the caret isn't in a table), so the frontend can fall through — the
8913    /// vertical counterpart of [`Self::cell_hop`].
8914    ///
8915    /// A ragged row that is short a column clamps to its last cell, so Down never
8916    /// falls out of the table over a gap the row above happened to have.
8917    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
8918        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8919            return false;
8920        };
8921        let target = match down {
8922            true => r + 1,
8923            false if r == 0 => return false,
8924            false => r - 1,
8925        };
8926        let Some(row) = grid.get(target) else {
8927            return false;
8928        };
8929        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
8930            return false;
8931        };
8932        self.select_cell(start, end);
8933        true
8934    }
8935
8936    /// The table containing `off` as a row-major grid of `(start, end)` cell
8937    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
8938    /// isn't in a table. Read straight off the visual map's laid-out grid, so
8939    /// every cell (an empty one included, whose derived home twig gives no
8940    /// `content_span` for) is present and in the order Tab walks them.
8941    // Grid, row, column — three returns that only ever travel together, and a
8942    // named type for the pair of them would be read at one call site.
8943    #[allow(clippy::type_complexity)]
8944    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
8945        for t in &self.vmap.tables {
8946            let mut pos = None;
8947            let grid: Vec<Vec<(usize, usize)>> = t
8948                .grid
8949                .iter()
8950                .enumerate()
8951                .map(|(r, row)| {
8952                    row.cells
8953                        .iter()
8954                        .enumerate()
8955                        .map(|(c, cell)| {
8956                            if pos.is_none() && off >= cell.start && off <= cell.end {
8957                                pos = Some((r, c));
8958                            }
8959                            (cell.start, cell.end)
8960                        })
8961                        .collect()
8962                })
8963                .collect();
8964            if let Some((r, c)) = pos {
8965                return Some((grid, r, c));
8966            }
8967        }
8968        None
8969    }
8970
8971    // ── table key policy ──────────────────────────────────────────────────────
8972    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
8973    // as one policy every frontend shares, rather than each re-deriving it. Each
8974    // reports whether it acted *as a table key*; a `false` hands the key back to
8975    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
8976    // everywhere else.
8977
8978    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
8979    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
8980    /// back and simply stays put at the very first cell. `false` when the caret
8981    /// isn't in a table.
8982    pub fn cell_tab(&mut self, forward: bool) -> bool {
8983        if !self.caret_in_table() {
8984            return false;
8985        }
8986        if self.cell_hop(forward) {
8987            return true;
8988        }
8989        // Off the last cell: grow the table by a row and step into its first
8990        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
8991        if forward {
8992            self.append_row_and_enter(0);
8993        }
8994        true
8995    }
8996
8997    /// Return inside a table: drop to the cell below in the same column,
8998    /// appending a new row when the caret is already in the last one. `false`
8999    /// when the caret isn't in a table, so the frontend inserts a newline.
9000    pub fn cell_return(&mut self) -> bool {
9001        if !self.caret_in_table() {
9002            return false;
9003        }
9004        if self.cell_move_vertical(true) {
9005            return true;
9006        }
9007        // Already on the last row: grow one below and drop into the same column.
9008        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
9009        self.append_row_and_enter(col);
9010        true
9011    }
9012
9013    /// Append a row below the caret's (last) row and land in `col` of it. The
9014    /// caret is in the last row, so twig's "insert below" makes the fresh row the
9015    /// table's new last — but twig re-spells the whole table, moving every byte,
9016    /// so the destination is read back from the rebuilt grid by the table's
9017    /// position (stable across a row insert), not from the pre-edit caret.
9018    fn append_row_and_enter(&mut self, col: usize) {
9019        let table = self.caret_table_index();
9020        self.table_insert_row(true);
9021        self.rebuild_map();
9022        let Some((start, end)) = table
9023            .and_then(|ti| self.vmap.tables.get(ti))
9024            .and_then(|t| t.grid.last())
9025            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
9026            .map(|cell| (cell.start, cell.end))
9027        else {
9028            return;
9029        };
9030        self.select_cell(start, end);
9031    }
9032
9033    /// The index, among the document's tables, of the one the caret sits in —
9034    /// `None` when it's in none. Used to re-find a table after an edit re-spells
9035    /// it (a row insert leaves the table order unchanged).
9036    fn caret_table_index(&self) -> Option<usize> {
9037        let off = self.caret;
9038        self.vmap.tables.iter().position(|t| {
9039            t.grid
9040                .iter()
9041                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
9042        })
9043    }
9044
9045    /// Shift+Return inside a table: insert a hard line break *within* the current
9046    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
9047    /// table, so the frontend inserts an ordinary line break.
9048    ///
9049    /// A table row is a single source line, so the newline-spelled hard break
9050    /// can't live in a cell. twig spells the in-cell break the format's way
9051    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
9052    /// break round-trips as structure the renderer reads back as a line — not the
9053    /// opaque raw HTML the old raw-splice left behind.
9054    ///
9055    /// Djot has no idiomatic in-cell break, so twig refuses it
9056    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
9057    /// would render as the literal text `<br>`. The gesture is still *consumed*
9058    /// there — returning `false` would let the frontend insert a real newline,
9059    /// which splits the one-line row — it just leaves the cell unchanged and says
9060    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
9061    ///
9062    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
9063    /// have to be read together: djot is not the only `false`, and naming it in
9064    /// the message was already a guess that HTML — which spells the break as its
9065    /// own `<br>` — would have made wrong.
9066    pub fn cell_line_break(&mut self) -> bool {
9067        if self.read_only || !self.caret_in_table() {
9068            return false;
9069        }
9070        self.record_caret();
9071        match self.editor.insert_line_break(self.caret) {
9072            Ok(change) => {
9073                self.last_edit_kind = None;
9074                self.refresh();
9075                self.caret = change.new.end;
9076                self.anchor = None;
9077                self.goal_col = None;
9078                self.clamp_caret();
9079                self.dirty = self.source != self.clean_source;
9080                self.status = None;
9081                self.record_caret();
9082            }
9083            Err(twig::Error::UnsupportedFormat) => {
9084                self.status = Some(format!(
9085                    "in-cell line breaks aren't supported in {}",
9086                    self.format_name()
9087                ));
9088            }
9089            Err(_) => {}
9090        }
9091        true
9092    }
9093
9094    /// Rebuild the visual map at the width the last build used. A structural edit
9095    /// bumps the revision and swaps the source in, but leaves the *map* stale;
9096    /// when a single gesture edits and then moves over the result (Tab appending
9097    /// a row, then stepping into it), the move needs the map to already show the
9098    /// edit rather than waiting for the frontend's next frame.
9099    fn rebuild_map(&mut self) {
9100        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
9101        self.build_map(wrap);
9102    }
9103
9104    /// Move the caret to the very start of the document (⌘↑ on macOS,
9105    /// Ctrl+Home on Windows/Linux).
9106    pub fn move_doc_start(&mut self, extend: bool) {
9107        self.goal_col = None;
9108        self.move_to(0, extend);
9109    }
9110
9111    /// Move the caret to the very end of the document (⌘↓ on macOS,
9112    /// Ctrl+End on Windows/Linux).
9113    pub fn move_doc_end(&mut self, extend: bool) {
9114        self.goal_col = None;
9115        let end = self.source.len();
9116        self.move_to(end, extend);
9117    }
9118
9119    /// Point the caret at the body cell `(row, col)` the mouse landed on —
9120    /// `col` being a cell of the terminal grid, which is what a display column
9121    /// is. A click on the far cell of a wide character lands at that
9122    /// character's start; the mapping's own doc-comments carry the rule.
9123    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
9124        self.goal_col = None;
9125        let target = match self.view {
9126            View::Source => row_col_to_offset(&self.source, row, col),
9127            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
9128        };
9129        let before = self.caret;
9130        self.move_to(target, extend);
9131        self.debug_assert_on_a_stop(before);
9132    }
9133
9134    /// A click in the blank space under the document's last block.
9135    ///
9136    /// Not a click *on* anything, so it lands on nothing in particular: the
9137    /// caret goes onto an empty paragraph under the last block, wherever the
9138    /// pointer was horizontally — and if the document does not end with one,
9139    /// one is opened, which is the only way to get out from under a block Enter
9140    /// cannot leave. Enter inside a fenced code block is a literal newline (see
9141    /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
9142    /// way out at all; and a click under any last block used to land at the
9143    /// pointer's x on the block's last line, which is what a click on that line
9144    /// means and not what a click under it does.
9145    ///
9146    /// "Ends with an empty paragraph" is two trailing newlines: the first closes
9147    /// the last line and the second opens the blank line the visual map lays
9148    /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
9149    /// document ending inside an *unclosed* fence gets the fence closed first,
9150    /// since a newline written there would only be another line of code. An
9151    /// empty document has nothing to be under, and the caret simply goes to its
9152    /// start.
9153    ///
9154    /// In the source view the gesture is the ordinary one: the caret goes to the
9155    /// end of the source, and nothing is written. A read-only document likewise.
9156    pub fn click_past_end(&mut self) {
9157        self.goal_col = None;
9158        let len = self.source.len();
9159        if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
9160            self.move_to(len, false);
9161            return;
9162        }
9163        let mut tail = String::new();
9164        if let Some(fence) = self.unclosed_fence_at_end() {
9165            if !self.source.ends_with('\n') {
9166                tail.push('\n');
9167            }
9168            tail.push_str(&fence);
9169            tail.push('\n');
9170        }
9171        let joined = format!("{}{tail}", self.source);
9172        let trailing = joined.len() - joined.trim_end_matches('\n').len();
9173        for _ in trailing..2 {
9174            tail.push('\n');
9175        }
9176        if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
9177            return;
9178        }
9179        let end = self.source.len();
9180        self.move_to(end, false);
9181    }
9182
9183    /// The closing fence a document ending inside an unclosed fenced code block
9184    /// needs — the opening fence's own run, behind the quote prefix the block
9185    /// wears — or `None` when the last block is closed, indented, or not a code
9186    /// block at all.
9187    ///
9188    /// Unclosed is when twig's content span reaches the block's end: a closing
9189    /// fence line would lie between the two. `code_info_span`'s read of the
9190    /// fence is not used because it starts at the block's span, which inside a
9191    /// quote is the quote marker rather than the fence.
9192    fn unclosed_fence_at_end(&mut self) -> Option<String> {
9193        let content_end = self.source.trim_end_matches('\n').len();
9194        let block = self
9195            .nodes()
9196            .into_iter()
9197            .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
9198            .max_by_key(|n| n.span.start)?;
9199        if block.content_span.as_ref()?.end < block.span.end {
9200            return None;
9201        }
9202        let line = self.source[block.span.start..].lines().next()?;
9203        let opening = line.trim_start_matches(['>', ' ', '\t']);
9204        let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
9205        let run: String = opening.chars().take_while(|&c| c == fence).collect();
9206        let prefix = self.quote_prefix_at(block.span.start);
9207        Some(format!("{prefix}{run}"))
9208    }
9209
9210    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
9211    /// screen if it has moved since the last frame, and never scroll past the
9212    /// last of `rows`.
9213    ///
9214    /// Only if it has *moved* — that's the whole point. Revealing the caret on
9215    /// every frame ties the viewport to it, and a scroll wheel that fights the
9216    /// caret for the viewport loses: the view snaps back the instant it tries to
9217    /// pass the caret's row, so the document can't be scrolled beyond what's
9218    /// already on screen. A caret move is the frontend's cue to follow; a scroll
9219    /// with the caret sitting still is the reader's cue to leave it alone.
9220    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
9221        if self.drawn_caret != Some(self.caret) {
9222            if caret_row < self.scroll {
9223                self.scroll = caret_row;
9224            } else if height > 0 && caret_row >= self.scroll + height {
9225                self.scroll = caret_row + 1 - height;
9226            }
9227            self.drawn_caret = Some(self.caret);
9228        }
9229        self.scroll = self.scroll.min(rows.saturating_sub(1));
9230    }
9231
9232    /// The caret's screen position `(row, col)` in the active view's grid, with
9233    /// `col` a display column: the cell to draw the caret in, which on a line of
9234    /// `你好` or emoji is not the count of characters before it.
9235    pub fn caret_pos(&self) -> (usize, usize) {
9236        match self.view {
9237            View::Source => offset_to_row_col(&self.source, self.caret),
9238            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
9239        }
9240    }
9241
9242    fn clamp_caret(&mut self) {
9243        if self.caret > self.source.len() {
9244            self.caret = self.source.len();
9245        }
9246        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
9247        // any selection anchor) to the first rendered offset.
9248        let floor = self.caret_floor();
9249        if self.caret < floor {
9250            self.caret = floor;
9251        }
9252        if let Some(a) = self.anchor
9253            && a < floor
9254        {
9255            self.anchor = Some(floor);
9256        }
9257        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
9258            self.caret -= 1;
9259        }
9260    }
9261}
9262
9263// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
9264
9265// Left/right motion and backspace/delete step by *grapheme cluster*, not
9266// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
9267// marks moves and deletes as the single character a user sees. Grapheme
9268// boundaries are a superset of char boundaries, so the caret stays valid for twig.
9269
9270/// How an insert of `text` groups for undo: a single typed character folds into
9271/// the run of typing around it, while a newline or a multi-character insert is a
9272/// step of its own.
9273fn typed_edit_kind(text: &str) -> EditKind {
9274    if text.chars().take(2).count() == 1 && text != "\n" {
9275        EditKind::Insert
9276    } else {
9277        EditKind::Other
9278    }
9279}
9280
9281fn prev_boundary(s: &str, i: usize) -> usize {
9282    let mut cursor = GraphemeCursor::new(i, s.len(), true);
9283    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
9284}
9285
9286fn next_boundary(s: &str, i: usize) -> usize {
9287    let mut cursor = GraphemeCursor::new(i, s.len(), true);
9288    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
9289}
9290
9291// ── word boundaries ──────────────────────────────────────────────────────────
9292// The shared primitive behind word-wise motion, word deletion, and
9293// double-click-to-select-a-word. A "word" is a maximal run of one character
9294// class; whitespace and punctuation are their own classes, so motion skips
9295// cleanly between them the way native text fields do.
9296
9297#[derive(PartialEq, Eq, Clone, Copy)]
9298enum Class {
9299    Word,
9300    Space,
9301    Other,
9302}
9303
9304/// The source range of an inline node's own visible text — the part of it a
9305/// WYSIWYG caret can reach, as against the delimiters that only spell it.
9306/// `None` for a node with no interior to empty (a `str`, a break).
9307///
9308/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
9309/// one delimiter in from the span — the same place the renderer maps it to. A
9310/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
9311/// against the source rather than trusted: a range guessed wrong here is text
9312/// deleted wrong.
9313fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
9314    if let Some(span) = n.content_span.clone() {
9315        return Some(span);
9316    }
9317    match n.kind.as_str() {
9318        "verbatim" | "inline_math" => {
9319            let text = n.text.as_ref()?;
9320            let start = n.span.start + 1;
9321            let range = start..start + text.len();
9322            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
9323        }
9324        _ => None,
9325    }
9326}
9327
9328/// The `id` a node declares, or `None` for one that declares none — the
9329/// attribute djot writes for a `{#v1}` and mints for a heading.
9330///
9331/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
9332/// names nothing, so it reads as absent rather than as the empty string.
9333fn declared_id(n: &FlatNode) -> Option<&str> {
9334    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
9335}
9336
9337/// A heading's words reduced to the form a link fragment spells them in:
9338/// lowercase, runs of anything else collapsed to a single `-`, with none left
9339/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
9340///
9341/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
9342/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
9343/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
9344/// any other language is still a heading someone will link to. Underscores
9345/// survive for the same reason they do on the web: they are word characters
9346/// wherever identifiers are written.
9347fn slug(text: &str) -> String {
9348    let mut out = String::new();
9349    let mut pending = false;
9350    for c in text.chars() {
9351        if c.is_alphanumeric() || c == '_' {
9352            if pending && !out.is_empty() {
9353                out.push('-');
9354            }
9355            pending = false;
9356            out.extend(c.to_lowercase());
9357        } else {
9358            pending = true;
9359        }
9360    }
9361    out
9362}
9363
9364/// The text of the inline run starting at `first` and its siblings, markup
9365/// stripped: each leaf's payload in order, a line break as a space. `nodes` is
9366/// the arena `Doc::nodes` returns, indexed by id.
9367fn inline_text(nodes: &[FlatNode], first: Option<NodeId>, out: &mut String) {
9368    let mut next = first;
9369    while let Some(id) = next {
9370        let Some(n) = nodes.get(id.0 as usize) else {
9371            return;
9372        };
9373        match (&n.text, &n.kind) {
9374            (Some(text), _) => out.push_str(text),
9375            (None, Kind::SoftBreak | Kind::HardBreak) => out.push(' '),
9376            (None, _) => inline_text(nodes, n.first_child, out),
9377        }
9378        next = n.next_sibling;
9379    }
9380}
9381
9382fn is_block_container(kind: &Kind) -> bool {
9383    matches!(
9384        kind,
9385        Kind::Doc
9386            | Kind::Section
9387            | Kind::BlockQuote
9388            | Kind::BulletList
9389            | Kind::OrderedList
9390            | Kind::TaskList
9391            | Kind::ListItem
9392            | Kind::TaskListItem
9393            // Every `container` — a directive in any of its three forms, or a
9394            // promoted HTML element. A *text* directive is really inline, so
9395            // claiming it here is a small overreach, and the deliberate one this
9396            // function's kind-only peer `is_inline_kind` documents: the pair is
9397            // consulted together, and answering "block container" for something
9398            // inline is what keeps an ancestor walk from stopping short of the
9399            // paragraph that actually holds it.
9400            | Kind::Container
9401    )
9402}
9403
9404/// The `[start, end)` byte range of the source line containing `off` (newline
9405/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
9406/// line between paragraphs).
9407fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
9408    let off = off.min(s.len());
9409    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
9410    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
9411    start..end
9412}
9413
9414/// How many leading bytes an outdent takes off `line`: a whole indent level
9415/// where the line has one, and whatever it has where it has less.
9416///
9417/// A leading tab counts as a level on its own. It's indentation some other
9418/// editor wrote, and one tab is one level everywhere it came from — measuring it
9419/// in spaces it doesn't contain would leave it untouchable.
9420fn outdent_width(line: &str, unit: usize) -> usize {
9421    if line.starts_with('\t') {
9422        return 1;
9423    }
9424    line.bytes().take(unit).take_while(|b| *b == b' ').count()
9425}
9426
9427/// A list marker found at the head of a line, together with everything before it
9428/// that a sibling line has to repeat.
9429///
9430/// The three offsets differ only inside a block quote, where `>   - b` opens with
9431/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
9432/// `line_start == marker_start`, and `text` is the plain `"  - "`.
9433#[derive(Clone, Debug)]
9434struct ListMarker {
9435    /// The line's first byte.
9436    line_start: usize,
9437    /// Where the marker proper begins, past any quote prefix. The offset to hand
9438    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
9439    marker_start: usize,
9440    /// `line_start` through the marker's trailing space — quote prefix, indent
9441    /// and bullet together, which is what the next item's line opens with.
9442    text: String,
9443}
9444
9445impl ListMarker {
9446    /// Where the item's content starts — one past the marker's trailing space.
9447    fn content_start(&self) -> usize {
9448        self.line_start + self.text.len()
9449    }
9450}
9451
9452fn classify(c: char) -> Class {
9453    if c == '_' || c.is_alphanumeric() {
9454        Class::Word
9455    } else if c.is_whitespace() {
9456        Class::Space
9457    } else {
9458        Class::Other
9459    }
9460}
9461
9462/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
9463/// skip any leading separators, then consume the following word run.
9464fn next_word(s: &str, i: usize) -> usize {
9465    let mut off = i;
9466    let mut in_word = false;
9467    for c in s[i..].chars() {
9468        if classify(c) == Class::Word {
9469            in_word = true;
9470        } else if in_word {
9471            break;
9472        }
9473        off += c.len_utf8();
9474    }
9475    off
9476}
9477
9478/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
9479/// skip separators walking left, then consume the preceding word run.
9480fn prev_word(s: &str, i: usize) -> usize {
9481    let mut off = i;
9482    let mut in_word = false;
9483    for c in s[..i].chars().rev() {
9484        if classify(c) == Class::Word {
9485            in_word = true;
9486        } else if in_word {
9487            break;
9488        }
9489        off -= c.len_utf8();
9490    }
9491    off
9492}
9493
9494/// The `[start, end)` run of same-class characters surrounding `off` — the
9495/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
9496/// the run ending there is used.
9497fn word_range_at(s: &str, off: usize) -> (usize, usize) {
9498    if s.is_empty() {
9499        return (0, 0);
9500    }
9501    let off = off.min(s.len());
9502    let reference = if off < s.len() {
9503        s[off..].chars().next()
9504    } else {
9505        s[..off].chars().next_back()
9506    };
9507    let Some(rc) = reference else {
9508        return (off, off);
9509    };
9510    let class = classify(rc);
9511
9512    let mut start = off;
9513    for c in s[..start].chars().rev() {
9514        if classify(c) == class {
9515            start -= c.len_utf8();
9516        } else {
9517            break;
9518        }
9519    }
9520    let mut end = off;
9521    for c in s[end..].chars() {
9522        if classify(c) == class {
9523            end += c.len_utf8();
9524        } else {
9525            break;
9526        }
9527    }
9528    (start, end)
9529}
9530
9531/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
9532/// the line's start — terminal cells, not characters, so the column names the
9533/// cell the caret is drawn in even on a line of `你好` or emoji.
9534fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
9535    let off = off.min(s.len());
9536    let mut row = 0;
9537    let mut line_start = 0;
9538    for (i, &b) in s.as_bytes().iter().enumerate() {
9539        if i >= off {
9540            break;
9541        }
9542        if b == b'\n' {
9543            row += 1;
9544            line_start = i + 1;
9545        }
9546    }
9547    (row, wysiwyg::text_width(&s[line_start..off]))
9548}
9549
9550/// The byte offset at display column `col` of `row` (clamped to that line's
9551/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
9552///
9553/// A column landing *inside* a character — the second cell of `你`, or any cell
9554/// but the first of an emoji — resolves to that character's start, which is the
9555/// column the caret would have been drawn at to begin with. So both cells of a
9556/// wide character mean the character, and every offset survives the round trip
9557/// out to a column and back. The walk steps by grapheme cluster for the same
9558/// reason the caret does: a cluster is the character, and the cells belong to it
9559/// rather than to the codepoints spelling it.
9560fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
9561    let start = line_start(s, row);
9562    let end = line_end_from(s, start);
9563    let mut off = start;
9564    let mut at = 0; // the display column `off` sits at
9565    while off < end {
9566        let next = next_boundary(s, off).min(end);
9567        let cells = wysiwyg::text_width(&s[off..next]);
9568        if at + cells > col {
9569            break; // `col` is one of this cluster's own cells
9570        }
9571        at += cells;
9572        off = next;
9573    }
9574    off
9575}
9576
9577fn line_start(s: &str, row: usize) -> usize {
9578    if row == 0 {
9579        return 0;
9580    }
9581    let mut r = 0;
9582    for (i, &b) in s.as_bytes().iter().enumerate() {
9583        if b == b'\n' {
9584            r += 1;
9585            if r == row {
9586                return i + 1;
9587            }
9588        }
9589    }
9590    s.len()
9591}
9592
9593fn line_end_from(s: &str, start: usize) -> usize {
9594    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
9595}
9596
9597/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
9598/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
9599/// twig applies writing the mark out, so the toolbar can light the same button
9600/// that made the node.
9601///
9602/// `None` for every other kind, including the inline nodes that aren't marks at
9603/// all (`str`, `link`, `image`, the math and break kinds): they're things a
9604/// caret stands in, not formatting a button toggles.
9605/// Whether a match from an ancestor chain is an inline run whose delimiters
9606/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
9607/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
9608/// span is a [`Kind::Container`], which the kind alone cannot tell from a
9609/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
9610/// answer is the node's own. Every delete and caret step that walks over a
9611/// `**` walks over a span's tags by this test; without it Backspace after
9612/// `</span>` took the `>` and left the paragraph unparseable.
9613fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
9614    inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
9615}
9616
9617fn inline_kind(kind: &Kind) -> Option<InlineKind> {
9618    Some(match kind {
9619        Kind::Strong => InlineKind::Strong,
9620        Kind::Emph => InlineKind::Emph,
9621        Kind::Verbatim => InlineKind::Verbatim,
9622        Kind::Mark => InlineKind::Mark,
9623        Kind::Superscript => InlineKind::Superscript,
9624        Kind::Subscript => InlineKind::Subscript,
9625        Kind::Insert => InlineKind::Insert,
9626        Kind::Delete => InlineKind::Delete,
9627        _ => return None,
9628    })
9629}
9630
9631/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
9632/// a highlight's opening `==`.
9633///
9634/// Two enums for one closed vocabulary, and the duplication is the boundary
9635/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
9636/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
9637/// the editor writes. Spelled as a match rather than routed through the two
9638/// crates' name strings so that a colour added on either side is a compile
9639/// error here, where the pairing is decided, rather than a runtime `None` that
9640/// would read as "clear the colour".
9641fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
9642    match color {
9643        MarkColor::Red => twig::MarkColor::Red,
9644        MarkColor::Orange => twig::MarkColor::Orange,
9645        MarkColor::Yellow => twig::MarkColor::Yellow,
9646        MarkColor::Green => twig::MarkColor::Green,
9647        MarkColor::Blue => twig::MarkColor::Blue,
9648        MarkColor::Purple => twig::MarkColor::Purple,
9649        MarkColor::Brown => twig::MarkColor::Brown,
9650    }
9651}
9652
9653/// Where an offset lands after a splice it didn't make — twig's own rule, from
9654/// [`Change`]: shift anything at or past the replaced range's end by the length
9655/// the replacement gained or lost, and leave anything before it alone.
9656///
9657/// An offset *inside* the replaced range has no text of its own to ride any
9658/// more, and lands at the end of what replaced it: for
9659/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
9660/// the prefix is cleared, which then sits where the highlighted text begins.
9661/// One node's attribute list, twig's own `(key, value)` pairs owned — what
9662/// every presentation gesture reads, edits one key of, and passes back whole.
9663type Attrs = Vec<(String, Option<String>)>;
9664
9665/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
9666/// writes it and the walker draws it, and a frontend that paginates matches a
9667/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
9668/// stated once.
9669pub const PAGE_BREAK: &str = "page-break";
9670
9671/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
9672/// every other attribute kept in its place — the read-edit-write half of twig's
9673/// replace-not-merge contract for a `data-` key.
9674///
9675/// **A key that is already there is rewritten where it stands**, and only a key
9676/// the node did not have goes on the end. That is what makes the proposal's
9677/// worked example true: `class="lead center" id="intro"
9678/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
9679/// data-line-height="1.5"` — the same document with one token changed, and a
9680/// one-line diff. Removing the key and pushing it back would reorder the
9681/// author's attributes on every press, so a document that passed through the
9682/// editor came out shuffled even where nothing about it had changed.
9683///
9684/// A duplicate key — which no format leaf opens can spell, but twig reports
9685/// verbatim — collapses onto the first of its copies, since twig is handed one
9686/// value for one key either way.
9687fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
9688    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
9689    let mut written = false;
9690    for (k, v) in attrs {
9691        if k != key {
9692            out.push((k.clone(), v.clone()));
9693            continue;
9694        }
9695        if let Some(new) = value.filter(|_| !written) {
9696            out.push((k.clone(), Some(new.to_string())));
9697            written = true;
9698        }
9699    }
9700    if let Some(new) = value.filter(|_| !written) {
9701        out.push((key.to_string(), Some(new.to_string())));
9702    }
9703    out
9704}
9705
9706/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
9707/// `token` added, with the rest of the list kept in order.
9708///
9709/// `class` is a space-separated token list, and leaf owns three of the tokens in
9710/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
9711/// goes out as `class="lead right"`; one whose last owned token goes and which
9712/// carried nothing else loses the key, so a block that has lost its whole
9713/// vocabulary is spelled bare again. `class` itself keeps its place among the
9714/// attributes, because [`with_attr`] does the writing.
9715fn with_class_token(
9716    attrs: &[(String, Option<String>)],
9717    mine: impl Fn(&str) -> bool,
9718    token: Option<&str>,
9719) -> Attrs {
9720    let kept: Vec<&str> = attrs
9721        .iter()
9722        .find(|(k, _)| k == "class")
9723        .and_then(|(_, v)| v.as_deref())
9724        .unwrap_or_default()
9725        .split_whitespace()
9726        .filter(|t| !mine(t))
9727        .collect();
9728    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
9729    with_attr(
9730        attrs,
9731        "class",
9732        (!class.is_empty()).then_some(class.as_str()),
9733    )
9734}
9735
9736/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
9737///
9738/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
9739/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
9740/// (djot has no bare attribute, so no format reads one back everywhere), and
9741/// `hidden=""` is the same document where `hidden` is; dropping it instead
9742/// would lose what the author wrote, which is the one thing these gestures
9743/// promise not to do.
9744fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
9745    attrs
9746        .iter()
9747        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
9748        .collect()
9749}
9750
9751fn reanchor(off: usize, change: &Change) -> usize {
9752    if off < change.old.start {
9753        return off;
9754    }
9755    if off < change.old.end {
9756        return change.new.end;
9757    }
9758    (off + change.new.end).saturating_sub(change.old.end)
9759}
9760
9761/// [`reanchor`] for an edit that respells the markup *around* a block and
9762/// leaves the block's own bytes alone — which is every attribute gesture.
9763///
9764/// `block` is that block's content span before and after the splice, so an
9765/// offset standing in the text keeps its distance from the text's start and how
9766/// many bytes twig wrote above it never enters the arithmetic. That is the whole
9767/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
9768/// on the same div lengthens the attribute line, clearing the last one takes the
9769/// div away entirely, and both are the same sum. `None` where the splice named
9770/// no block at either end, which is every djot case — the `{…}` line is written
9771/// above the block, and the block itself only shifts past it.
9772///
9773/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
9774/// shifted by its delta after it, and at the splice's end for an offset that
9775/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
9776/// text to keep.
9777fn reanchor_in_block(
9778    off: usize,
9779    change: &Change,
9780    block: Option<(&Range<usize>, &Range<usize>)>,
9781) -> usize {
9782    if let Some((was, now)) = block
9783        && was.start <= off
9784        && off <= was.end
9785    {
9786        return now.start + (off - was.start).min(now.end - now.start);
9787    }
9788    reanchor(off, change)
9789}
9790
9791/// A watermark for a file's contents (see `Doc::disk_hash`).
9792///
9793/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
9794/// watermark is compared only against one taken by the same process moments
9795/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
9796/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
9797fn hash_bytes(bytes: &[u8]) -> u64 {
9798    use std::hash::{Hash, Hasher};
9799    let mut h = std::collections::hash_map::DefaultHasher::new();
9800    bytes.hash(&mut h);
9801    h.finish()
9802}
9803
9804#[cfg(feature = "fs")]
9805fn detect_format(path: &Path) -> Result<Format> {
9806    let ext = path
9807        .extension()
9808        .and_then(|e| e.to_str())
9809        .unwrap_or("")
9810        .to_ascii_lowercase();
9811    Ok(match ext.as_str() {
9812        "dj" | "djot" => Format::Djot,
9813        "md" | "markdown" => Format::Markdown,
9814        "xml" => Format::Xml,
9815        "html" | "htm" => Format::Html,
9816        other => return Err(anyhow!("unknown document extension: .{other}")),
9817    })
9818}
9819
9820/// The one span `from` and `to` differ in: `[start, end)` of `from`, and
9821/// what `to` holds there. The longest shared prefix, then the longest shared
9822/// suffix of what is left — never overlapping it — each ended on a character
9823/// boundary of both.
9824fn differing_span<'a>(from: &str, to: &'a str) -> (usize, usize, &'a str) {
9825    let (a, b) = (from.as_bytes(), to.as_bytes());
9826    let mut prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
9827    while !(from.is_char_boundary(prefix) && to.is_char_boundary(prefix)) {
9828        prefix -= 1;
9829    }
9830    let most = a.len().min(b.len()) - prefix;
9831    let mut suffix = a
9832        .iter()
9833        .rev()
9834        .zip(b.iter().rev())
9835        .take(most)
9836        .take_while(|(x, y)| x == y)
9837        .count();
9838    while !(from.is_char_boundary(a.len() - suffix) && to.is_char_boundary(b.len() - suffix)) {
9839        suffix -= 1;
9840    }
9841    (prefix, a.len() - suffix, &to[prefix..b.len() - suffix])
9842}
9843
9844#[cfg(test)]
9845mod tests {
9846    use super::*;
9847    use crate::style::{FontFamily, LineSpacing, SizeStep};
9848
9849    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
9850    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
9851    /// without one is a view no user is ever in.
9852    fn doc_in(view: View, name: &str, body: &str) -> Doc {
9853        // The fixture name doubles as the temp file's, so two tests picking the
9854        // same one raced under the parallel runner and read each other's body —
9855        // a green suite proving the wrong thing. The counter makes that
9856        // unreachable rather than asking every future caller to notice.
9857        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9858        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9859        let mut p = std::env::temp_dir();
9860        p.push(format!("leaf_test_{name}_{seq}.md"));
9861        std::fs::write(&p, body).unwrap();
9862        let mut d = Doc::open(p).unwrap();
9863        d.view = view;
9864        if view == View::Wysiwyg {
9865            d.build_visual(80);
9866        }
9867        d
9868    }
9869
9870    // Source-view document for the source-behaviour tests. `Doc::open` now
9871    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
9872    // `wysiwyg_doc` builds the rich-text variant on top of this.
9873    fn doc_with(name: &str, body: &str) -> Doc {
9874        doc_in(View::Source, name, body)
9875    }
9876
9877    /// Every visual row's drawn text — what the reader actually sees, which is
9878    /// the only thing the reveal preference is supposed to change.
9879    fn drawn_rows(d: &Doc) -> Vec<String> {
9880        d.vmap
9881            .rows
9882            .iter()
9883            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9884            .collect()
9885    }
9886
9887    /// Put the caret at the first byte of `needle` and rebuild, so the row under
9888    /// it becomes the revealed line.
9889    fn caret_at(d: &mut Doc, needle: &str) {
9890        d.caret = d.source.find(needle).expect("needle in source");
9891        d.build_visual(80);
9892    }
9893
9894    #[test]
9895    fn blockquote_after_a_list_is_not_bulleted() {
9896        // twig nests a following top-level block quote under the `bullet_list`
9897        // (a direct child, not a `list_item`). The map must render it de-nested —
9898        // `│ quote`, never `• │ quote` — with a blank separator, like any block
9899        // that follows a list. Regression for the "combined list + blockquote" bug.
9900        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
9901        d.build_visual(80);
9902        let rows: Vec<String> = d
9903            .vmap
9904            .rows
9905            .iter()
9906            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9907            .collect();
9908        assert!(
9909            rows.iter().any(|r| r == "│ quote"),
9910            "block quote should render on its own gutter, got rows: {rows:?}"
9911        );
9912        assert!(
9913            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
9914            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
9915        );
9916    }
9917
9918    // ── the map is built at most once per (revision, wrap) ───────────────────
9919    //
9920    // A frontend repaints for reasons that have nothing to do with the text — a
9921    // blinking caret, a scroll — and rebuilding the map is O(document). These
9922    // pin *that the cache fires*, which a passing suite can't tell you: a cache
9923    // that never hits is invisible to every other test in this file.
9924    //
9925    // The probe is to wreck the built map and ask for it again. A rebuild
9926    // repairs it; a cache hit hands the wreckage straight back. Nothing else
9927    // can distinguish the two from outside.
9928
9929    #[test]
9930    fn a_rebuild_with_nothing_changed_reuses_the_map() {
9931        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
9932        d.build_visual(80);
9933        assert!(!d.vmap.rows.is_empty());
9934        d.vmap.rows.clear(); // wreck it
9935        d.build_visual(80);
9936        assert!(
9937            d.vmap.rows.is_empty(),
9938            "the map was rebuilt though nothing changed — the cache never fired"
9939        );
9940    }
9941
9942    #[test]
9943    fn an_edit_rebuilds_the_map() {
9944        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
9945        d.build_visual(80);
9946        let before = d.revision();
9947        d.vmap.rows.clear();
9948        d.insert("x");
9949        d.build_visual(80);
9950        assert!(d.revision() > before, "an edit must move the revision");
9951        assert!(
9952            !d.vmap.rows.is_empty(),
9953            "an edited document must not paint from a stale map"
9954        );
9955    }
9956
9957    #[test]
9958    fn a_width_change_rebuilds_the_map() {
9959        // The map is a function of the wrap width too, so a resize is a miss
9960        // even though the text is untouched.
9961        let mut d = doc_in(
9962            View::Wysiwyg,
9963            "cache_width",
9964            "one two three four five six\n",
9965        );
9966        d.build_visual(80);
9967        d.vmap.rows.clear();
9968        d.build_visual(12);
9969        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
9970        // And the unwrapped map is its own key, not the same as any width.
9971        d.vmap.rows.clear();
9972        d.build_visual_unwrapped();
9973        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
9974    }
9975
9976    #[test]
9977    fn a_motion_does_not_rebuild_the_map() {
9978        // The whole point: moving the caret changes nothing the map is built
9979        // from. If a motion bumped the revision, every arrow key would cost a
9980        // full rebuild and the cache would be worthless.
9981        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
9982        d.build_visual(80);
9983        let rev = d.revision();
9984        d.move_right(false);
9985        d.move_right(true);
9986        d.move_down(false);
9987        assert_eq!(d.revision(), rev, "a motion must not move the revision");
9988        d.vmap.rows.clear();
9989        d.build_visual(80);
9990        assert!(
9991            d.vmap.rows.is_empty(),
9992            "a motion should not rebuild the map"
9993        );
9994    }
9995
9996    #[test]
9997    fn saving_does_not_rebuild_the_map() {
9998        // Saving changes `dirty`, not the text.
9999        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
10000        d.insert("x");
10001        d.build_visual(80);
10002        let rev = d.revision();
10003        d.save();
10004        assert_eq!(d.revision(), rev, "a save must not move the revision");
10005        assert!(!d.dirty, "the save should have cleaned the document");
10006    }
10007
10008    #[test]
10009    fn a_reload_rebuilds_the_map() {
10010        // Reload replaces the text without going through `refresh`, so it has to
10011        // move the revision itself — else the editor paints the old file.
10012        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
10013        d.build_visual(80);
10014        let rev = d.revision();
10015        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
10016        d.reload();
10017        assert!(d.revision() > rev, "a reload must move the revision");
10018        d.build_visual(80);
10019        let text: String = d
10020            .vmap
10021            .rows
10022            .iter()
10023            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
10024            .collect();
10025        assert!(
10026            text.contains("wholly new"),
10027            "the reloaded text should be on screen, got {text:?}"
10028        );
10029    }
10030
10031    // ── golden-case harness ──────────────────────────────────────────────────
10032    // The pattern the whole parity suite can reuse: write a fixture with the
10033    // caret marked by `|`, run one action, and compare the rendered result —
10034    // also caret-marked — against the expected string. One readable line per
10035    // behavior, and it exercises the exact `Doc` ops both frontends call.
10036
10037    /// Split a `|`-marked fixture into `(source, caret_offset)`.
10038    fn parse_caret(marked: &str) -> (String, usize) {
10039        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
10040        (marked.replacen('|', "", 1), caret)
10041    }
10042
10043    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
10044    /// selection) so a result reads like the fixtures.
10045    fn render_caret(d: &Doc) -> String {
10046        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
10047        // so the caret always renders inside its own selection.
10048        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
10049        if let Some((s, e)) = d.selection() {
10050            marks.push((s, 0, '['));
10051            marks.push((e, 2, ']'));
10052        }
10053        // Insert right-to-left: descending offset, then descending rank.
10054        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
10055        let mut out = d.source.clone();
10056        for (at, _, ch) in marks {
10057            out.insert(at, ch);
10058        }
10059        out
10060    }
10061
10062    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
10063    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
10064        golden_in(View::Source, name, marked, action)
10065    }
10066
10067    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
10068    /// the same fixture has to read the same way in both.
10069    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
10070        let (src, caret) = parse_caret(marked);
10071        let mut d = doc_in(view, name, &src);
10072        d.caret = caret;
10073        action(&mut d);
10074        render_caret(&d)
10075    }
10076
10077    #[test]
10078    fn word_motion_walks_word_by_word() {
10079        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
10080        assert_eq!(
10081            g("hello wor|ld", |d| d.move_word_left(false)),
10082            "hello |world"
10083        );
10084        assert_eq!(
10085            g("hello| world", |d| d.move_word_left(false)),
10086            "|hello world"
10087        );
10088        assert_eq!(
10089            g("hel|lo world", |d| d.move_word_right(false)),
10090            "hello| world"
10091        );
10092        assert_eq!(
10093            g("hello| world", |d| d.move_word_right(false)),
10094            "hello world|"
10095        );
10096        // Punctuation is its own class, so motion stops at the boundary.
10097        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
10098    }
10099
10100    #[test]
10101    fn word_motion_extends_the_selection_when_asked() {
10102        assert_eq!(
10103            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
10104            "hello [world|]"
10105        );
10106    }
10107
10108    #[test]
10109    fn delete_word_removes_a_whole_word() {
10110        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
10111        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
10112        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
10113        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
10114    }
10115
10116    // ── Home / End ───────────────────────────────────────────────────────────
10117
10118    #[test]
10119    fn home_toggles_between_the_line_s_text_and_its_margin() {
10120        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
10121        // indent to the markup it spells everywhere it means one, so the fixture
10122        // with whitespace left to walk is a code block, which is verbatim.
10123        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
10124        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
10125        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
10126        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
10127        // A line with no indentation has one place to go, so the toggle is a
10128        // no-op rather than a trip to nowhere.
10129        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
10130        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
10131
10132        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
10133        let indent = d.source.find("    indented").unwrap();
10134        d.caret = indent + 6; // inside "indented"
10135        d.move_home(false);
10136        assert_eq!(
10137            d.caret,
10138            indent + 4,
10139            "wysiwyg: Home aims at the code line's text"
10140        );
10141        d.move_home(false);
10142        assert_eq!(
10143            d.caret, indent,
10144            "wysiwyg: the second press takes the indent"
10145        );
10146        d.move_home(false);
10147        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
10148    }
10149
10150    #[test]
10151    fn end_takes_the_line_the_view_is_showing() {
10152        // The line differs by view for the same document, and that is the point:
10153        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
10154        // as a space on one row and the source view as two lines.
10155        let mut d = doc_with("end_src", "one two\nthree\n");
10156        d.caret = 1;
10157        d.move_end(false);
10158        assert_eq!(d.caret, 7, "source: the end of the source line");
10159
10160        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
10161        d.caret = 1;
10162        d.move_end(false);
10163        assert_eq!(
10164            d.caret, 13,
10165            "wysiwyg: the end of the row, soft break and all"
10166        );
10167    }
10168
10169    #[test]
10170    fn home_and_end_extend_the_selection_when_asked() {
10171        for (view, tag) in VIEWS {
10172            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
10173            d.caret = 6;
10174            d.move_end(true);
10175            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
10176            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
10177            d.caret = 6;
10178            d.move_home(true);
10179            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
10180        }
10181    }
10182
10183    // ── kill to the line's start / end ───────────────────────────────────────
10184
10185    #[test]
10186    fn kill_to_the_line_start_and_end_in_both_views() {
10187        for (view, tag) in VIEWS {
10188            // The gap that reads as a paragraph break in each view: the source
10189            // view's lines are the renderer's rows only where the source says so.
10190            let gap = if view == View::Source { "\n" } else { "\n\n" };
10191            let mut d = doc_in(
10192                view,
10193                &format!("kill_end_{tag}"),
10194                &format!("one two{gap}three\n"),
10195            );
10196            d.caret = 3;
10197            d.delete_to_line_end();
10198            assert_eq!(
10199                d.source,
10200                format!("one{gap}three\n"),
10201                "{tag}: ^K to the line's end"
10202            );
10203            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
10204
10205            let mut d = doc_in(
10206                view,
10207                &format!("kill_start_{tag}"),
10208                &format!("one two{gap}three\n"),
10209            );
10210            d.caret = 7; // the end of the first line
10211            d.delete_to_line_start();
10212            assert_eq!(
10213                d.source,
10214                format!("{gap}three\n"),
10215                "{tag}: ⌘⌫ to the line's start"
10216            );
10217            assert_eq!(d.caret, 0, "{tag}");
10218        }
10219    }
10220
10221    #[test]
10222    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
10223        // The decision: at the boundary both kills do nothing, rather than
10224        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
10225        // at a soft wrap as often as at a newline, where there is nothing
10226        // written to delete — and a source newline is only half of the blank
10227        // line between two paragraphs, so taking it leaves a soft break rather
10228        // than the join it looks like. Backspace and Delete are the keys for it.
10229        for (view, tag) in VIEWS {
10230            let gap = if view == View::Source { "\n" } else { "\n\n" };
10231            let src = format!("one{gap}three\n");
10232            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
10233            d.caret = 3; // the end of "one"
10234            d.delete_to_line_end();
10235            assert_eq!(
10236                d.source, src,
10237                "{tag}: ^K at the line's end joined it to the next"
10238            );
10239
10240            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
10241            d.caret = 3 + gap.len(); // the start of "three"
10242            d.delete_to_line_start();
10243            assert_eq!(
10244                d.source, src,
10245                "{tag}: ⌘⌫ at the line's start joined it to the last"
10246            );
10247        }
10248    }
10249
10250    #[test]
10251    fn a_kill_takes_the_selection_when_there_is_one() {
10252        // What every other delete here does with one, so these two as well.
10253        for (view, tag) in VIEWS {
10254            for (name, kill) in [
10255                (
10256                    "end",
10257                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
10258                ),
10259                ("start", |d: &mut Doc| d.delete_to_line_start()),
10260            ] {
10261                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
10262                d.anchor = Some(4);
10263                d.caret = 7; // "two"
10264                kill(&mut d);
10265                assert_eq!(
10266                    d.source, "one  three\n",
10267                    "{tag}: {name} ignored the selection"
10268                );
10269                assert_eq!(d.selection(), None, "{tag}: {name}");
10270            }
10271        }
10272    }
10273
10274    #[test]
10275    fn a_kill_takes_the_markup_it_empties_with_it() {
10276        // The same hazard a word-delete has: a WYSIWYG range covers what the
10277        // user can see, which for `**bold**` is the word and never the
10278        // delimiters, so a kill that stopped at the text would leave `a ****` —
10279        // markup wrapped around nothing.
10280        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
10281        d.caret = d.source.find("bold").unwrap();
10282        d.delete_to_line_end();
10283        assert_eq!(d.source, "a \n");
10284    }
10285
10286    #[test]
10287    fn a_kill_is_undone_in_one_step() {
10288        for (view, tag) in VIEWS {
10289            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
10290            d.caret = 3;
10291            d.delete_to_line_end();
10292            assert_eq!(d.source, "one\n", "{tag}");
10293            d.undo();
10294            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
10295        }
10296    }
10297
10298    #[test]
10299    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
10300        // Regression: triple-click used move_home/move_end over visual rows, so
10301        // it only worked on a paragraph's first row (a wrap-boundary offset maps
10302        // to the earlier row). select_block_at reads the AST, so every offset in
10303        // the paragraph selects the whole thing.
10304        let body = "one two three four five six seven eight\n";
10305        let mut d = doc_with("sel_block", body);
10306        d.view = View::Wysiwyg;
10307        d.build_visual(12); // force the paragraph to wrap into several rows
10308        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
10309        let para = (0, "one two three four five six seven eight".len());
10310        for off in [0usize, 8, 19, 28, 38] {
10311            d.caret = 0;
10312            d.anchor = None;
10313            d.select_block_at(off);
10314            assert_eq!(
10315                d.selection(),
10316                Some(para),
10317                "offset {off} should select the paragraph"
10318            );
10319        }
10320    }
10321
10322    #[test]
10323    fn select_block_uses_content_span_for_a_heading() {
10324        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
10325        d.select_block_at(4); // inside "Title"
10326        // content_span excludes the "# " marker.
10327        assert_eq!(d.selected_text(), Some("Title"));
10328        d.select_block_at(10); // inside "body"
10329        assert_eq!(d.selected_text(), Some("body"));
10330    }
10331
10332    #[test]
10333    fn select_all_spans_the_document() {
10334        let mut d = doc_with("sel_all", "abc\n\ndef\n");
10335        d.select_all();
10336        assert_eq!(d.selection(), Some((0, d.source.len())));
10337    }
10338
10339    #[test]
10340    fn select_word_at_picks_the_surrounding_word() {
10341        let mut d = doc_with("sel_word", "hello world\n");
10342        d.select_word_at(8); // inside "world"
10343        assert_eq!(d.selection(), Some((6, 11)));
10344        // Double-clicking at end-of-word still grabs the word to its left.
10345        d.select_word_at(5); // the space between the words
10346        assert_eq!(d.selection(), Some((5, 6)));
10347    }
10348
10349    #[test]
10350    fn word_helpers_respect_utf8_boundaries() {
10351        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
10352        assert_eq!(
10353            golden("utf8", "|café ok", |d| d.move_word_right(false)),
10354            "café| ok"
10355        );
10356        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
10357    }
10358
10359    #[test]
10360    fn typing_inserts_at_the_caret_and_advances_it() {
10361        let mut d = doc_with("type", "hello\n");
10362        d.insert("Hi ");
10363        assert_eq!(d.source, "Hi hello\n");
10364        assert_eq!(d.caret, 3);
10365        assert!(d.dirty);
10366    }
10367
10368    #[test]
10369    fn backspace_deletes_the_char_before_the_caret() {
10370        let mut d = doc_with("bs", "hello\n");
10371        d.caret = 3; // after "hel"
10372        d.backspace();
10373        assert_eq!(d.source, "helo\n");
10374        assert_eq!(d.caret, 2);
10375    }
10376
10377    #[test]
10378    fn typing_replaces_the_selection() {
10379        let mut d = doc_with("replace", "a word b\n");
10380        d.anchor = Some(2);
10381        d.caret = 6; // "word" selected
10382        d.insert("X");
10383        assert_eq!(d.source, "a X b\n");
10384        assert_eq!(d.caret, 3);
10385        assert_eq!(d.anchor, None);
10386    }
10387
10388    #[test]
10389    fn toggle_bold_wraps_then_unwraps_the_selection() {
10390        let mut d = doc_with("bold", "a word b\n");
10391        d.anchor = Some(2);
10392        d.caret = 6;
10393        d.toggle(InlineKind::Strong);
10394        assert_eq!(d.source, "a **word** b\n");
10395        // The toggled region stays selected, so a second toggle reverses it.
10396        d.toggle(InlineKind::Strong);
10397        assert_eq!(d.source, "a word b\n");
10398        d.toggle(InlineKind::Strong);
10399        assert_eq!(d.source, "a **word** b\n");
10400    }
10401
10402    #[test]
10403    fn toggle_code_wraps_then_unwraps_the_selection() {
10404        let mut d = doc_with("code_rt", "a word b\n");
10405        d.anchor = Some(2);
10406        d.caret = 6;
10407        d.toggle(InlineKind::Verbatim);
10408        assert_eq!(d.source, "a `word` b\n");
10409        d.toggle(InlineKind::Verbatim);
10410        assert_eq!(d.source, "a word b\n");
10411    }
10412
10413    #[test]
10414    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
10415        // ⌘b at a bare caret, then type: the text comes out bold with no
10416        // selection ever made — the word-processor "start bold here" gesture.
10417        let mut d = doc_with("sticky_wrap", "xy\n");
10418        d.caret = 1; // between x and y
10419        d.toggle(InlineKind::Strong);
10420        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
10421        d.insert("A");
10422        assert_eq!(d.source, "x**A**y\n");
10423    }
10424
10425    #[test]
10426    fn sticky_bold_lights_the_toolbar_before_any_typing() {
10427        // The button must light the instant ⌘b is pressed, or the mode is
10428        // invisible until the first character lands.
10429        let mut d = doc_with("sticky_light", "xy\n");
10430        d.caret = 1;
10431        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10432        d.toggle(InlineKind::Strong);
10433        assert!(d.active_inline_marks().contains(InlineKind::Strong));
10434    }
10435
10436    #[test]
10437    fn sticky_bold_toggled_off_types_normally_again() {
10438        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
10439        // in the flow of typing, the exact sequence the user described.
10440        let mut d = doc_with("sticky_off", "\n");
10441        d.caret = 0;
10442        d.toggle(InlineKind::Strong);
10443        d.insert("a");
10444        d.insert("b"); // continues inside the run, no re-arming
10445        assert_eq!(d.source, "**ab**\n");
10446        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
10447        d.insert("c");
10448        assert_eq!(d.source, "**ab**c\n");
10449    }
10450
10451    #[test]
10452    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
10453        // Once a mark is realised the caret sits inside the run, so plain typing
10454        // extends it rather than starting a second, adjacent bold span.
10455        let mut d = doc_with("sticky_cont", "\n");
10456        d.caret = 0;
10457        d.toggle(InlineKind::Emph);
10458        d.insert("h");
10459        d.insert("i");
10460        assert_eq!(d.source, "*hi*\n");
10461    }
10462
10463    #[test]
10464    fn moving_the_caret_disarms_a_sticky_mark() {
10465        // Arming a mark and then moving away must not style text elsewhere.
10466        let mut d = doc_with("sticky_disarm", "xy\n");
10467        d.caret = 0;
10468        d.toggle(InlineKind::Strong);
10469        d.move_right(false); // caret 0 → 1, disarms
10470        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10471        d.insert("A");
10472        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
10473    }
10474
10475    #[test]
10476    fn stacked_sticky_marks_apply_together() {
10477        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
10478        let mut d = doc_with("sticky_stack", "\n");
10479        d.caret = 0;
10480        d.toggle(InlineKind::Strong);
10481        d.toggle(InlineKind::Emph);
10482        d.insert("x");
10483        // Land the caret on the styled character and confirm both marks are live.
10484        d.anchor = Some(d.source.find('x').unwrap());
10485        d.caret = d.anchor.unwrap() + 1;
10486        let marks = d.active_inline_marks();
10487        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
10488        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
10489    }
10490
10491    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
10492
10493    #[test]
10494    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
10495        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
10496        // The space inside the run made `**bold **`, which is *not* bold — four
10497        // literal asterisks — so the rich view drew them, correctly and
10498        // uselessly, until the next character happened to close the run again.
10499        let mut d = wysiwyg_doc("edge_typing", "a \n");
10500        d.caret = 2;
10501        d.toggle(InlineKind::Strong);
10502        for c in "bold".chars() {
10503            d.insert(&c.to_string());
10504        }
10505        assert_eq!(d.source, "a **bold**\n");
10506        d.insert(" ");
10507        assert_eq!(
10508            d.source, "a **bold** \n",
10509            "the space belongs outside the run"
10510        );
10511        assert!(
10512            d.active_inline_marks().contains(InlineKind::Strong),
10513            "bold is still what's being typed, so the button stays lit"
10514        );
10515        // What the writer is looking at while all this happens: their words.
10516        d.build_visual(80);
10517        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
10518        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
10519        for c in "hey".chars() {
10520            d.insert(&c.to_string());
10521        }
10522        assert_eq!(
10523            d.source, "a **bold hey**\n",
10524            "one bold phrase, not two runs"
10525        );
10526    }
10527
10528    #[test]
10529    fn typing_past_a_space_can_still_leave_the_bold_behind() {
10530        // The other half: the marks stay armed across the space, so ⌘b turns
10531        // them off again there and the next word is plain — the run isn't
10532        // rejoined by a caret that was told not to.
10533        let mut d = wysiwyg_doc("edge_shed", "\n");
10534        d.caret = 0;
10535        d.toggle(InlineKind::Strong);
10536        for c in "bold ".chars() {
10537            d.insert(&c.to_string());
10538        }
10539        assert_eq!(d.source, "**bold** \n");
10540        d.toggle(InlineKind::Strong);
10541        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10542        d.insert("x");
10543        assert_eq!(d.source, "**bold** x\n");
10544    }
10545
10546    #[test]
10547    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
10548        // ⌘b and then a space before any word: the space is not marked (nothing
10549        // is), and the word after it is.
10550        let mut d = wysiwyg_doc("edge_space_first", "a\n");
10551        d.caret = 1;
10552        d.toggle(InlineKind::Strong);
10553        d.insert(" ");
10554        assert_eq!(d.source, "a \n");
10555        assert!(d.active_inline_marks().contains(InlineKind::Strong));
10556        d.insert("b");
10557        assert_eq!(d.source, "a **b**\n");
10558    }
10559
10560    #[test]
10561    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
10562        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
10563        d.caret = 8; // the caret's home at the end of the run's text
10564        d.insert(" ");
10565        assert_eq!(
10566            d.source, "x **bold** \n",
10567            "the space lands past the delimiters"
10568        );
10569        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
10570
10571        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
10572        d.caret = 4; // in front of the "b"
10573        d.insert(" ");
10574        assert_eq!(d.source, "x  **bold** y\n");
10575        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
10576    }
10577
10578    #[test]
10579    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
10580        // Backspace over the last letter of a bold phrase.
10581        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
10582        d.caret = 10; // past the "h"
10583        d.backspace();
10584        assert_eq!(d.source, "a **bold** \n");
10585        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
10586        assert!(d.active_inline_marks().contains(InlineKind::Strong));
10587        d.insert("x");
10588        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
10589    }
10590
10591    #[test]
10592    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
10593        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
10594        // mark, which is only text. The marks live on in the caret instead.
10595        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
10596        d.caret = 5;
10597        d.backspace();
10598        assert_eq!(d.source, "a  c\n");
10599        assert!(d.active_inline_marks().contains(InlineKind::Strong));
10600        d.insert("x");
10601        assert_eq!(d.source, "a **x** c\n");
10602    }
10603
10604    #[test]
10605    fn typing_over_a_whole_bold_word_keeps_it_bold() {
10606        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
10607        d.anchor = Some(4);
10608        d.caret = 8; // the word, not its delimiters
10609        d.insert("x");
10610        assert_eq!(d.source, "a **x** c\n");
10611    }
10612
10613    #[test]
10614    fn a_code_span_keeps_the_space_it_is_given() {
10615        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
10616        // is still verbatim, so nothing is re-spelt. The repair asks the parser
10617        // rather than a table of kinds, and this is the answer it gets.
10618        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
10619        d.caret = 7;
10620        d.insert(" ");
10621        assert_eq!(d.source, "a `code ` c\n");
10622    }
10623
10624    #[test]
10625    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
10626        // A run's closing delimiter has a caret home on each side of it, one
10627        // column apart on screen — and a plain ← off the space after a bold word
10628        // lands on the outer one. The character drawn behind the caret there is
10629        // still the last letter of the phrase, so that is what Backspace takes;
10630        // the byte behind it is a `*` nobody can see.
10631        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
10632        d.caret = 9;
10633        d.move_left(false);
10634        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
10635        d.backspace();
10636        assert_eq!(
10637            d.source, "**bol** x\n",
10638            "a letter of the phrase, not its `*`"
10639        );
10640        assert_eq!(d.caret, 5);
10641
10642        // And the mirror in front of the opening delimiter, where Delete's
10643        // character is the first letter of the run.
10644        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
10645        d.caret = 1;
10646        d.delete_forward();
10647        assert_eq!(d.source, "x**old**\n");
10648        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
10649    }
10650
10651    #[test]
10652    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
10653        // The byte beside the caret at either edge of a bold word is a `*` the
10654        // rich view draws nothing for. Taking it is not the character delete the
10655        // key was pressed for — it unspells the run and puts a literal asterisk
10656        // on screen (`a *bold** c`). The visible character is the one that goes.
10657        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
10658        d.caret = 4; // in front of the "b"
10659        d.backspace();
10660        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
10661
10662        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
10663        d.caret = 8; // past the "d"
10664        d.delete_forward();
10665        assert_eq!(d.source, "a **bold**c\n");
10666        assert_eq!(d.caret, 8, "and the caret stays inside the run");
10667        d.insert("x");
10668        assert_eq!(d.source, "a **boldx**c\n");
10669
10670        // A code span's backticks are hidden the same way, so they are covered
10671        // by the same rule and not by a list of kinds.
10672        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
10673        d.caret = 3;
10674        d.backspace();
10675        assert_eq!(d.source, "a`code` c\n");
10676    }
10677
10678    #[test]
10679    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
10680        // The asterisks are on the screen there and the caret can stand between
10681        // them, so a delete takes exactly the byte it is aimed at.
10682        let mut d = doc_with("edge_open_src", "a **bold** c\n");
10683        d.caret = 4;
10684        d.backspace();
10685        assert_eq!(d.source, "a *bold** c\n");
10686
10687        let mut d = doc_with("edge_close_src", "a **bold** c\n");
10688        d.caret = 8;
10689        d.delete_forward();
10690        assert_eq!(d.source, "a **bold* c\n");
10691    }
10692
10693    #[test]
10694    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
10695        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
10696        // The space had stepped outside the run (the mark-edge rule), taking the
10697        // caret with it, so the delete put it back down on the far side of the
10698        // closing `**` — one place on screen, and the wrong side of it. Typing
10699        // came out plain and the toolbar went dark, with nothing to see.
10700        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
10701        d.caret = 0;
10702        d.toggle(InlineKind::Strong);
10703        for c in "bold".chars() {
10704            d.insert(&c.to_string());
10705        }
10706        d.insert(" ");
10707        assert_eq!(d.source, "**bold** \n");
10708        d.backspace();
10709        assert_eq!(
10710            d.source, "**bold**\n",
10711            "the space goes, the delimiters stay"
10712        );
10713        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
10714        assert!(
10715            d.active_inline_marks().contains(InlineKind::Strong),
10716            "so the button is still lit"
10717        );
10718        d.insert("x");
10719        assert_eq!(
10720            d.source, "**boldx**\n",
10721            "and the next character is still bold"
10722        );
10723    }
10724
10725    #[test]
10726    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
10727        // What the stranded caret did next: the byte behind it was the closing
10728        // `*`, so a second press took that instead of a letter — `**bold*`, the
10729        // styling gone and an asterisk on the screen where the word had been.
10730        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
10731        d.caret = 0;
10732        d.toggle(InlineKind::Strong);
10733        for c in "bold ".chars() {
10734            d.insert(&c.to_string());
10735        }
10736        assert_eq!(d.source, "**bold** \n");
10737        d.backspace();
10738        d.backspace();
10739        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
10740        assert_eq!(d.caret, 5);
10741    }
10742
10743    #[test]
10744    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
10745        // `***both***` closes two runs with one stack of asterisks: the caret has
10746        // to walk in through all of them, or it lands between the emph and the
10747        // strong and types half-marked.
10748        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
10749        d.caret = 11;
10750        d.backspace();
10751        assert_eq!(d.source, "***both***\n");
10752        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
10753        d.insert("x");
10754        assert_eq!(d.source, "***bothx***\n");
10755    }
10756
10757    #[test]
10758    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
10759        // The settle only moves a caret a run actually closed over. Ordinary
10760        // deletes — inside a run, or in plain prose — are untouched.
10761        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
10762        d.caret = 8;
10763        d.backspace();
10764        assert_eq!(d.source, "a **bol** c\n");
10765        assert_eq!(d.caret, 7);
10766
10767        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
10768        d.caret = 5;
10769        d.backspace();
10770        assert_eq!(d.source, "plai\n");
10771        assert_eq!(d.caret, 4);
10772    }
10773
10774    #[test]
10775    fn the_source_view_leaves_a_delete_where_it_landed() {
10776        // The delimiters are on the screen there, so the offset past them is a
10777        // place the caret can be seen to be — nothing to settle.
10778        let mut d = doc_with("edge_bksp_src", "**bold** \n");
10779        d.caret = 9;
10780        d.backspace();
10781        assert_eq!(d.source, "**bold**\n");
10782        assert_eq!(d.caret, 8);
10783    }
10784
10785    #[test]
10786    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
10787        // `***both***` closes two runs with one stack of asterisks; a space that
10788        // clears only the inner one lands against the outer's and breaks that
10789        // instead.
10790        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
10791        d.caret = 9;
10792        d.insert(" ");
10793        assert_eq!(d.source, "a ***both*** \n");
10794        assert_eq!(d.caret, 13);
10795        d.insert("x");
10796        assert_eq!(d.source, "a ***both x***\n");
10797    }
10798
10799    #[test]
10800    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
10801        // The delimiter shuffle is not an edit the writer made, so it is not a
10802        // step they have to undo past.
10803        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
10804        d.caret = 8;
10805        d.insert(" ");
10806        assert_eq!(d.source, "a **bold** \n");
10807        d.undo();
10808        assert_eq!(d.source, "a **bold**\n");
10809    }
10810
10811    #[test]
10812    fn the_source_view_types_the_space_where_it_was_asked_to() {
10813        // The rule is a rich-view courtesy. In the source view the delimiters are
10814        // on the screen and the user is editing the bytes they can see.
10815        let mut d = doc_with("edge_src", "a **bold** c\n");
10816        d.caret = 8;
10817        d.insert(" ");
10818        assert_eq!(d.source, "a **bold ** c\n");
10819    }
10820
10821    #[test]
10822    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
10823        // Double-clicking a word takes the space after it; bolding that must not
10824        // spell `**word **`, which is not bold at all.
10825        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
10826        d.anchor = Some(2);
10827        d.caret = 7; // "word "
10828        d.toggle(InlineKind::Strong);
10829        assert_eq!(d.source, "a **word** b\n");
10830        d.toggle(InlineKind::Strong);
10831        assert_eq!(d.source, "a word b\n");
10832        d.toggle(InlineKind::Strong);
10833        assert_eq!(
10834            d.source, "a **word** b\n",
10835            "reapplying the mark must not wrap stale delimiter offsets"
10836        );
10837        // And a selection of nothing but whitespace has no word to mark.
10838        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
10839        d.anchor = Some(6);
10840        d.caret = 7;
10841        d.toggle(InlineKind::Strong);
10842        assert_eq!(d.source, "a word b\n");
10843        assert!(d.status.is_some());
10844    }
10845
10846    #[test]
10847    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
10848        let mut d = doc_with("head_set", "hello\n");
10849        d.caret = 2; // caret inside the paragraph, no selection
10850        d.set_block(BlockKind::Heading(1));
10851        assert_eq!(d.source, "# hello\n");
10852    }
10853
10854    #[test]
10855    fn set_block_heading_works_in_wysiwyg_view() {
10856        // The app defaults to WYSIWYG; the caret is a source offset either way.
10857        let mut d = wysiwyg_doc("head_wys", "hello\n");
10858        d.caret = 2;
10859        d.set_block(BlockKind::Heading(1));
10860        assert_eq!(d.source, "# hello\n");
10861    }
10862
10863    #[test]
10864    fn toggle_heading_applies_switches_and_reverts() {
10865        let mut d = doc_with("head_toggle", "hello\n");
10866        d.caret = 2;
10867        d.toggle_heading(1);
10868        assert_eq!(d.source, "# hello\n"); // paragraph → H1
10869        d.toggle_heading(2);
10870        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
10871        d.toggle_heading(2);
10872        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
10873    }
10874
10875    #[test]
10876    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
10877        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
10878        // the blank line but the caret rendered on the *next* line, because the
10879        // separator was a non-navigable decoration row. In Preserve flow that
10880        // blank line is a real caret home — the caret must resolve onto it, and
10881        // typing there makes the soft break that continues the paragraph.
10882        let src = "line one:\nsecond line\n";
10883        let mut d = wysiwyg_doc("pre_enter_lineend", src);
10884        d.set_line_flow(LineFlow::Preserve);
10885        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
10886        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
10887        d.newline();
10888        d.build_visual_unwrapped();
10889        assert_eq!(d.source, "line one:\n\nsecond line\n");
10890        assert_eq!(
10891            d.caret, 10,
10892            "caret sits on the new blank line, not the next line"
10893        );
10894        // The blank line is row 1, and the caret resolves onto it — not row 2.
10895        assert_eq!(
10896            d.vmap.pos_of_offset(10),
10897            (1, 0),
10898            "caret renders on the blank row"
10899        );
10900        assert!(
10901            !d.vmap.rows[1].decoration,
10902            "the blank line is navigable in Preserve"
10903        );
10904        // Typing there makes a soft break: one paragraph, three lines.
10905        d.insert("new clause,");
10906        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
10907    }
10908
10909    #[test]
10910    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
10911        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
10912        // that keeps it one paragraph — where Fold would open a second paragraph.
10913        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
10914        d.set_line_flow(LineFlow::Preserve);
10915        d.caret = 3;
10916        d.newline();
10917        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
10918
10919        // End-of-paragraph: Enter then typing continues the same paragraph on a
10920        // new line (a soft break), not a fresh paragraph.
10921        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
10922        d.set_line_flow(LineFlow::Preserve);
10923        d.caret = 3;
10924        d.newline();
10925        d.insert("def");
10926        assert_eq!(
10927            d.source, "abc\ndef\n",
10928            "end-of-line Enter + typing is a soft break"
10929        );
10930    }
10931
10932    #[test]
10933    fn preserve_double_enter_still_makes_a_paragraph() {
10934        // Two Enters in a row promote to a real paragraph break: the second lands
10935        // on the blank line the first opened and takes the empty-line branch.
10936        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
10937        d.set_line_flow(LineFlow::Preserve);
10938        d.caret = 3;
10939        d.newline();
10940        d.newline();
10941        d.insert("def");
10942        assert_eq!(
10943            d.source, "abc\n\ndef\n",
10944            "double Enter is a paragraph break"
10945        );
10946    }
10947
10948    #[test]
10949    fn preserve_backspace_joins_across_a_soft_break() {
10950        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
10951        // soft break it deletes the single newline and joins the two lines.
10952        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
10953        d.set_line_flow(LineFlow::Preserve);
10954        d.build_visual(80);
10955        d.caret = 4; // start of "def", just past the soft break
10956        d.backspace();
10957        assert_eq!(
10958            d.source, "abcdef\n",
10959            "Backspace joins across the soft break"
10960        );
10961        assert_eq!(d.caret, 3, "caret lands where the lines meet");
10962    }
10963
10964    #[test]
10965    fn fold_enter_still_starts_a_new_paragraph() {
10966        // The default flow is unchanged: a lone `\n` would render as an invisible
10967        // space, so Enter keeps opening the paragraph break that actually shows.
10968        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
10969        d.caret = 3;
10970        d.newline();
10971        assert_eq!(
10972            d.source, "abc\n\ndef\n",
10973            "Fold mid-line Enter is a paragraph break"
10974        );
10975    }
10976
10977    #[test]
10978    fn wysiwyg_one_enter_starts_a_new_paragraph() {
10979        // Regression: one Enter left the caret between the two newlines, so typing
10980        // made a soft break (one paragraph) and you needed a second Enter.
10981        let mut d = wysiwyg_doc("wys_enter", "abc\n");
10982        d.caret = 3;
10983        d.newline();
10984        d.insert("def");
10985        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
10986    }
10987
10988    #[test]
10989    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
10990        // Regression: Enter at the caret's natural End-of-line resting place
10991        // after a bold run with nothing following it (on screen: right after
10992        // "bold", before the hidden closing "**") spliced the paragraph break
10993        // at that very byte offset — which sits *before* the closing "**" in
10994        // the source, since the delimiter is hidden and emits no glyph of its
10995        // own for `push_row`'s "end of row" fallback to count. That severed the
10996        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
10997        // "**" alone on the new line instead of leaving "**bold**" intact with
10998        // a fresh empty paragraph after it.
10999        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
11000        d.move_end(false); // the WYSIWYG End key, from caret 0
11001        assert_eq!(
11002            d.caret, 6,
11003            "caret rests right after \"bold\", before the hidden \"**\""
11004        );
11005        d.newline();
11006        assert!(
11007            d.source.starts_with("**bold**"),
11008            "the closing ** must stay attached to \"bold\": got {:?}",
11009            d.source
11010        );
11011        assert_eq!(
11012            d.source, "**bold**\n\n\n",
11013            "a fresh empty paragraph follows the still-intact bold run"
11014        );
11015    }
11016
11017    #[test]
11018    fn source_view_enter_is_a_single_newline() {
11019        let mut d = doc_with("src_enter", "abc\n");
11020        d.caret = 3;
11021        d.newline();
11022        assert_eq!(d.source, "abc\n\n");
11023    }
11024
11025    #[test]
11026    fn heading_applies_at_the_end_of_a_paragraph() {
11027        // The caret at a line end sits at the doc level; set_block must still find
11028        // the block on that line.
11029        let mut d = doc_with("head_end", "abc\n");
11030        d.caret = 3; // end of "abc"
11031        d.toggle_heading(1);
11032        assert_eq!(d.source, "# abc\n");
11033    }
11034
11035    #[test]
11036    fn heading_on_an_empty_new_paragraph_creates_one() {
11037        let mut d = wysiwyg_doc("head_empty", "abc\n");
11038        d.caret = 3;
11039        d.newline(); // caret now on a fresh, empty paragraph
11040        d.toggle_heading(1);
11041        d.insert("Title");
11042        assert!(d.source.contains("# Title"), "got {:?}", d.source);
11043    }
11044
11045    #[test]
11046    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
11047        // The reported bug, end to end: click a blank line with another one under
11048        // it, press H1, type. The text landed in the heading and the caret's
11049        // offset was right (the source view drew it there), but the rich view
11050        // drew it two rows lower, on the trailing blank line — the empty `# `
11051        // heading had left every row below it short by the marker's two bytes,
11052        // and the blank line ended up claiming the heading's own end offset.
11053        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
11054        d.build_visual_unwrapped();
11055        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
11056        d.toggle_heading(1);
11057        for c in "title".chars() {
11058            d.insert(&c.to_string());
11059            d.build_visual_unwrapped(); // as a frontend does, one frame per key
11060        }
11061        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
11062        assert_eq!(
11063            d.caret_pos(),
11064            (4, 5),
11065            "the caret draws at the end of the heading"
11066        );
11067    }
11068
11069    #[test]
11070    fn clicking_an_empty_heading_types_after_its_marker() {
11071        // The same anchor from the other side: the empty heading's row is its own
11072        // caret home, so a click on it must land past the hidden `# `. Landing in
11073        // front of the hashes made the first keystroke un-heading the line.
11074        let mut d = wysiwyg_doc("head_click", "# \n");
11075        d.build_visual_unwrapped();
11076        d.caret = d.vmap.offset_of_pos(0, 0);
11077        d.insert("x");
11078        assert_eq!(d.source, "# x\n");
11079    }
11080
11081    #[test]
11082    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
11083        let mut d = wysiwyg_doc("head_enter", "# Title\n");
11084        d.caret = 7; // end of the heading
11085        d.newline();
11086        d.insert("body");
11087        assert_eq!(d.source, "# Title\n\nbody\n");
11088    }
11089
11090    #[test]
11091    fn wysiwyg_enter_continues_a_bullet_list() {
11092        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
11093        d.caret = 6; // end of "item"
11094        d.newline();
11095        d.insert("two");
11096        assert_eq!(d.source, "- item\n- two\n");
11097    }
11098
11099    #[test]
11100    fn wysiwyg_enter_increments_an_ordered_list() {
11101        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
11102        d.caret = 6; // end of "one"
11103        d.newline();
11104        d.insert("two");
11105        assert_eq!(d.source, "1. one\n2. two\n");
11106    }
11107
11108    #[test]
11109    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
11110        // Regression for the "extra newline" left between a list and the paragraph
11111        // below it. Enter, Enter leaves the list on a fresh empty paragraph
11112        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
11113        // Backspace should then take the caret cleanly back to the end of the list
11114        // item, `- item\n\nnext`, not delete a single newline and strand it on the
11115        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
11116        // no caret can land on. The map is rebuilt between keystrokes exactly as a
11117        // frontend does, since Backspace reads the stop table to place the delete.
11118        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
11119        d.caret = 6; // end of "item"
11120        d.newline();
11121        d.build_visual(80);
11122        d.newline(); // leave the list onto a fresh empty paragraph
11123        d.build_visual(80);
11124        assert_eq!(
11125            d.source, "- item\n\n\n\nnext\n",
11126            "double-Enter opens the empty paragraph"
11127        );
11128        d.backspace();
11129        assert_eq!(
11130            d.source, "- item\n\nnext\n",
11131            "one Backspace collapses the whole gap"
11132        );
11133        assert_eq!(
11134            d.caret, 6,
11135            "and lands the caret back at the end of the list item"
11136        );
11137    }
11138
11139    #[test]
11140    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
11141        // The stop-wise delete must not over-reach when there is no block boundary
11142        // to cross: two blank lines in a row are one caret stop apart, so pressing
11143        // Enter on an empty line and then Backspace removes exactly the one newline
11144        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
11145        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
11146        d.caret = 5; // the empty paragraph the first Enter already opened
11147        d.build_visual(80);
11148        d.newline();
11149        d.build_visual(80);
11150        assert_eq!(
11151            d.source, "abc\n\n\n\n",
11152            "Enter on the blank line adds one newline"
11153        );
11154        d.backspace();
11155        assert_eq!(
11156            d.source, "abc\n\n\n",
11157            "Backspace takes back exactly that one newline"
11158        );
11159    }
11160
11161    #[test]
11162    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
11163        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
11164        d.caret = 6; // end of the empty "- " item
11165        d.newline();
11166        d.insert("p");
11167        assert_eq!(d.source, "- a\n\np\n");
11168    }
11169
11170    #[test]
11171    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
11172        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
11173        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
11174        // not take the list-exit path (which would splice the `- ` away as if
11175        // leaving an empty item); the AST guard sends it to a normal break and
11176        // leaves the underline intact.
11177        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
11178        assert!(
11179            d.nodes().iter().any(|n| n.kind == Kind::Heading),
11180            "precondition: twig parses this as a heading, not a list",
11181        );
11182        d.caret = 7; // on the `- ` underline line
11183        d.newline();
11184        assert!(
11185            d.source.contains("- "),
11186            "the setext underline survives, not spliced away as a list item: {:?}",
11187            d.source,
11188        );
11189    }
11190
11191    #[test]
11192    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
11193        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
11194        d.caret = 7; // end of "abc" inside the fence
11195        d.newline();
11196        d.insert("def");
11197        assert_eq!(d.source, "```\nabc\ndef\n```\n");
11198    }
11199
11200    #[test]
11201    fn wysiwyg_enter_continues_a_block_quote() {
11202        // Enter opens a new *paragraph* inside the quote, not a second line of
11203        // the same one. `> quote\n> more` is a soft break, which under
11204        // `LineFlow::Fold` renders as a space — the keystroke would look like it
11205        // did nothing. The quoted blank line is what makes the break visible, and
11206        // it's the same thing Enter does in running prose.
11207        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
11208        d.caret = 7; // end of "quote"
11209        d.newline();
11210        d.insert("more");
11211        assert_eq!(d.source, "> quote\n>\n> more\n");
11212        // Still one quote, now holding two paragraphs — not a quote and a stray
11213        // line that fell out of it.
11214        let quotes = d
11215            .nodes()
11216            .iter()
11217            .filter(|n| n.kind == Kind::BlockQuote)
11218            .count();
11219        assert_eq!(quotes, 1);
11220    }
11221
11222    #[test]
11223    fn set_block_makes_a_heading_at_the_caret() {
11224        let mut d = doc_with("head", "Title\n\nbody\n");
11225        d.caret = 0;
11226        d.set_block(BlockKind::Heading(2));
11227        assert_eq!(d.source, "## Title\n\nbody\n");
11228        d.set_block(BlockKind::Paragraph);
11229        assert_eq!(d.source, "Title\n\nbody\n");
11230    }
11231
11232    // ── block containers (quote / list) ──────────────────────────────────────
11233
11234    #[test]
11235    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
11236        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
11237        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
11238        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
11239        // A caret at a line end sits at the doc level; the block is still found.
11240        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
11241    }
11242
11243    #[test]
11244    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
11245        // Every source line of the paragraph gets its own `> `, so a caret left
11246        // on its old byte offset falls one prefix per line above it too far
11247        // back — inside the markup it just asked for rather than in its word.
11248        assert_eq!(
11249            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
11250            "> aaa\n> b|bb\n> ccc\n"
11251        );
11252    }
11253
11254    #[test]
11255    fn toggle_blockquote_works_in_wysiwyg_view() {
11256        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
11257        assert_eq!(
11258            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
11259            "> hel|lo\n"
11260        );
11261        assert_eq!(
11262            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
11263            "hel|lo\n"
11264        );
11265    }
11266
11267    #[test]
11268    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
11269        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
11270        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
11271        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
11272        // The *other* kind converts in place instead of nesting, which is what
11273        // makes the two buttons one three-state control.
11274        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
11275        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
11276        // Its own kind, over the only item the list holds, takes it off.
11277        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
11278    }
11279
11280    #[test]
11281    fn toggle_list_works_in_wysiwyg_view() {
11282        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
11283        assert_eq!(
11284            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
11285            "1. hel|lo\n"
11286        );
11287        assert_eq!(
11288            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
11289            "- hel|lo\n"
11290        );
11291        assert_eq!(
11292            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
11293            "hel|lo\n"
11294        );
11295    }
11296
11297    #[test]
11298    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
11299        // The selection has to grow with the markup: twig takes a container off
11300        // only a range covering every block it holds, so the second press can
11301        // reverse the first only if the result is what's selected.
11302        let mut d = doc_with("list_sel", "abc\n\ndef\n");
11303        d.select_all();
11304        d.toggle_list(true);
11305        assert_eq!(d.source, "1. abc\n\n2. def\n");
11306        assert_eq!(d.selection(), Some((0, d.source.len())));
11307        d.toggle_list(true);
11308        assert_eq!(d.source, "abc\n\ndef\n");
11309    }
11310
11311    #[test]
11312    fn toggle_blockquote_nests_a_partly_covered_quote() {
11313        // twig's rule: covering only some of a container's blocks nests, because
11314        // taking the quote off would drag its uncovered siblings out with it.
11315        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
11316        d.caret = 2; // in the first quoted paragraph only
11317        d.toggle_blockquote();
11318        assert_eq!(d.source, "> > a\n>\n> b\n");
11319    }
11320
11321    #[test]
11322    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
11323        // A blank line used to be no block for twig to wrap —
11324        // `toggle_block_container` answered `NotFound` — so Quote and the list
11325        // buttons did nothing on the very line the H1 button works on, and leaf
11326        // lent twig a scratch paragraph to wrap and took it back out again.
11327        // twig 3.2.0 opens an empty container there itself, so what is left here
11328        // is where the caret lands: inside the marker that was just written.
11329        let mut d = doc_with("quote_blank", "\nabc\n");
11330        d.caret = 0;
11331        d.toggle_blockquote();
11332        assert_eq!(d.source, "> \nabc\n");
11333        assert_eq!(
11334            d.caret, 2,
11335            "the caret belongs inside the quote it just opened"
11336        );
11337        assert!(d.status.is_none(), "{:?}", d.status);
11338        assert!(d.dirty);
11339
11340        // And the paragraph below is still its own block: an empty container one
11341        // soft break from `abc` would take that paragraph into the quote with it.
11342        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
11343        d.caret = 0;
11344        d.toggle_blockquote();
11345        d.build_visual(80);
11346        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
11347
11348        // The same from the other side: a blank line directly under a paragraph
11349        // earns the blank line an empty block needs, rather than being read as a
11350        // soft break inside that paragraph.
11351        let mut d = doc_with("list_blank_below", "abc\n");
11352        d.caret = 4;
11353        d.toggle_list(false);
11354        assert_eq!(d.source, "abc\n\n- ");
11355        assert_eq!(d.caret, 7);
11356    }
11357
11358    #[test]
11359    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
11360        // The gesture the rendering fix is for. `newline` inside a quote already
11361        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
11362        // spelling — but the two marker lines it adds belonged to no node until
11363        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
11364        // just made drew as plain prose under the quote.
11365        let mut d = wysiwyg_doc("quote_enter", "> a\n");
11366        d.caret = 3; // past `a`, at the end of the quoted line
11367        d.newline();
11368        assert_eq!(d.source, "> a\n>\n> \n");
11369        d.build_visual(80);
11370        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
11371        // And the caret is on the new line, not stranded on the old one.
11372        assert_eq!(d.caret, 8);
11373    }
11374
11375    #[test]
11376    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
11377        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
11378        // now it is twig's single edit. Either way one ⌘z has to put the blank
11379        // line back rather than undoing into a half-built document.
11380        for open in [
11381            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
11382            &|d: &mut Doc| d.toggle_list(false),
11383            &|d: &mut Doc| d.toggle_list(true),
11384        ] {
11385            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
11386            d.caret = 3;
11387            open(&mut d);
11388            assert_ne!(d.source, "a\n\n\n\nb\n");
11389            d.undo();
11390            assert_eq!(d.source, "a\n\n\n\nb\n");
11391        }
11392    }
11393
11394    #[test]
11395    fn a_container_toggle_is_one_undo_step() {
11396        let mut d = doc_with("quote_undo", "hello\n");
11397        d.caret = 3;
11398        d.insert("X"); // a typing run the structural edit must not fold into
11399        d.toggle_blockquote();
11400        assert_eq!(d.source, "> helXlo\n");
11401        d.undo();
11402        assert_eq!(d.source, "helXlo\n");
11403    }
11404
11405    // ── links ────────────────────────────────────────────────────────────────
11406
11407    #[test]
11408    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
11409        let mut d = doc_with("link_sel", "word here\n");
11410        d.anchor = Some(0);
11411        d.caret = 4;
11412        d.insert_link("http://x.dev");
11413        assert_eq!(d.source, "[word](http://x.dev) here\n");
11414        // The text, not the destination — so a second press re-points the link
11415        // the first one made rather than nesting one inside it.
11416        assert_eq!(d.selected_text(), Some("word"));
11417        d.insert_link("http://y.dev");
11418        assert_eq!(d.source, "[word](http://y.dev) here\n");
11419        assert_eq!(d.selected_text(), Some("word"));
11420    }
11421
11422    #[test]
11423    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
11424        let mut d = doc_with("img_caret", "before after\n");
11425        d.caret = 7; // between "before " and "after"
11426        d.insert_image("cat.png", "a cat");
11427        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
11428        // The caret sits just past the inserted image, nothing selected.
11429        assert_eq!(d.selection(), None);
11430        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
11431    }
11432
11433    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
11434    /// destination at the first space, so the `format!` this used to be wrote
11435    /// something that was not an image at all — and the reader saw the markup as
11436    /// text. twig owns the spelling now, and moves it into the angle form.
11437    #[test]
11438    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
11439        let mut d = doc_with("img_space", "x\n");
11440        d.caret = 0;
11441        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
11442        assert_eq!(
11443            d.source,
11444            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
11445        );
11446        // And it reads back as an image pointing at the unescaped path — the angle
11447        // brackets are spelling, not part of the destination.
11448        d.caret = 2;
11449        assert_eq!(
11450            d.image_destination_at_caret(),
11451            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
11452        );
11453    }
11454
11455    /// A `)` in a caption or a filename must not close the image early.
11456    #[test]
11457    fn insert_image_escapes_a_paren_in_either_half() {
11458        let mut d = doc_with("img_paren", "x\n");
11459        d.caret = 0;
11460        d.insert_image("a)b.png", "");
11461        assert_eq!(d.source, "![](a\\)b.png)x\n");
11462        d.caret = 2;
11463        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
11464    }
11465
11466    #[test]
11467    fn insert_image_uses_the_selection_as_alt_text() {
11468        let mut d = doc_with("img_sel", "caption here\n");
11469        d.anchor = Some(0);
11470        d.caret = 7; // "caption"
11471        d.insert_image("p.png", "ignored fallback");
11472        assert_eq!(d.source, "![caption](p.png) here\n");
11473    }
11474
11475    #[test]
11476    fn insert_image_with_no_alt_leaves_empty_brackets() {
11477        let mut d = doc_with("img_noalt", "\n");
11478        d.caret = 0;
11479        d.insert_image("logo.svg", "");
11480        assert_eq!(d.source, "![](logo.svg)\n");
11481    }
11482
11483    // ── move_block ─────────────────────────────────────────────────────────────
11484
11485    /// A move, then its undo: one step takes the whole thing back.
11486    fn moved(name: &str, body: &str, from: usize, to: usize) -> (String, Doc) {
11487        let mut d = doc_with(name, body);
11488        d.caret = from;
11489        let steps = d.undo_steps;
11490        d.move_block(from, to);
11491        let after = d.source.clone();
11492        assert_eq!(d.undo_steps, steps + 1, "one undo step");
11493        assert!(d.dirty);
11494        d.undo();
11495        assert_eq!(d.source, body, "one undo restores the original");
11496        (after, d)
11497    }
11498
11499    #[test]
11500    fn move_block_carries_a_paragraph_between_two_paragraphs_and_to_the_end() {
11501        let body = "a\n\nb\n\nc\n";
11502        assert_eq!(moved("mv_p1", body, 6, 3).0, "a\n\nc\n\nb\n");
11503        assert_eq!(moved("mv_p2", body, 0, body.len()).0, "b\n\nc\n\na\n");
11504        assert_eq!(moved("mv_p3", body, 3, 0).0, "b\n\na\n\nc\n");
11505    }
11506
11507    #[test]
11508    fn move_block_carries_an_image_block() {
11509        let body = "a\n\n![p](x.png)\n\nc\n";
11510        assert_eq!(moved("mv_img1", body, 4, 0).0, "![p](x.png)\n\na\n\nc\n");
11511        assert_eq!(
11512            moved("mv_img2", body, 4, body.len()).0,
11513            "a\n\nc\n\n![p](x.png)\n"
11514        );
11515    }
11516
11517    #[test]
11518    fn move_block_carries_a_table() {
11519        let body = "a\n\n| h |\n|---|\n| c |\n\nc\n";
11520        assert_eq!(
11521            moved("mv_tbl1", body, 5, 0).0,
11522            "| h |\n|---|\n| c |\n\na\n\nc\n"
11523        );
11524        assert_eq!(
11525            moved("mv_tbl2", body, 5, body.len()).0,
11526            "a\n\nc\n\n| h |\n|---|\n| c |\n"
11527        );
11528    }
11529
11530    #[test]
11531    fn move_block_carries_a_code_block() {
11532        let body = "a\n\n```rs\nx\n```\n\nc\n";
11533        assert_eq!(moved("mv_code1", body, 8, 0).0, "```rs\nx\n```\n\na\n\nc\n");
11534        assert_eq!(
11535            moved("mv_code2", body, 8, body.len()).0,
11536            "a\n\nc\n\n```rs\nx\n```\n"
11537        );
11538    }
11539
11540    #[test]
11541    fn move_block_onto_its_own_boundary_is_a_quiet_no_op() {
11542        let mut d = doc_with("mv_noop", "a\n\nb\n");
11543        let steps = d.undo_steps;
11544        d.move_block(0, 0);
11545        d.move_block(0, 3);
11546        assert_eq!(d.source, "a\n\nb\n");
11547        assert_eq!(d.undo_steps, steps);
11548        assert_eq!(d.status, None);
11549        assert!(!d.dirty);
11550    }
11551
11552    #[test]
11553    fn move_block_into_a_fence_is_refused_with_a_status() {
11554        let mut d = doc_with("mv_fence", "a\n\n```\nx\ny\n```\n");
11555        d.move_block(0, 7);
11556        assert_eq!(d.source, "a\n\n```\nx\ny\n```\n");
11557        assert!(
11558            d.status
11559                .as_deref()
11560                .is_some_and(|s| s.starts_with("move block"))
11561        );
11562    }
11563
11564    #[test]
11565    fn move_block_rides_the_caret_with_the_block() {
11566        // Down: "second" is line 0 of its block, caret 3 bytes from its end.
11567        let mut d = doc_with("mv_caret1", "first\n\nsecond\n\nthird\n");
11568        d.caret = 10; // "sec|ond"
11569        d.move_block(10, d.source.len());
11570        assert_eq!(d.source, "first\n\nthird\n\nsecond\n");
11571        assert_eq!(&d.source[d.caret..], "ond\n");
11572        // Up, across a wrapped paragraph's second line.
11573        let mut d = doc_with("mv_caret2", "first\n\nsecond\nline two\n");
11574        d.caret = 16; // "li|ne two"
11575        d.move_block(16, 0);
11576        assert_eq!(d.source, "second\nline two\n\nfirst\n");
11577        assert_eq!(&d.source[d.caret..], "ne two\n\nfirst\n");
11578        assert_eq!(d.selection(), None);
11579    }
11580
11581    #[test]
11582    fn move_block_keeps_the_caret_on_its_line_through_a_quotes_prefix() {
11583        let mut d = doc_with("mv_quote_caret", "para\n\n> a\n");
11584        d.caret = 2; // "pa|ra"
11585        d.move_block(2, 9); // after a, inside the quote
11586        assert_eq!(d.source, "> a\n>\n> para\n");
11587        assert_eq!(&d.source[d.caret..], "ra\n");
11588    }
11589
11590    #[test]
11591    fn move_block_up_and_down_step_over_siblings() {
11592        let mut d = doc_with("mv_updown", "a\n\nb\n\nc\n");
11593        d.caret = 3;
11594        d.move_block_down();
11595        assert_eq!(d.source, "a\n\nc\n\nb\n");
11596        assert_eq!(&d.source[d.caret..], "b\n");
11597        d.move_block_down();
11598        assert_eq!(d.source, "a\n\nc\n\nb\n", "nothing below the last block");
11599        assert_eq!(d.status.as_deref(), Some("move block: nothing below"));
11600        d.move_block_up();
11601        d.move_block_up();
11602        assert_eq!(d.source, "b\n\na\n\nc\n");
11603        assert_eq!(&d.source[d.caret..], "b\n\na\n\nc\n");
11604        d.move_block_up();
11605        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
11606    }
11607
11608    #[test]
11609    fn move_block_up_and_down_reorder_list_items_with_their_children() {
11610        let mut d = doc_with("mv_items", "- a\n  - x\n- b\n- c\n");
11611        d.caret = 12; // in "b"
11612        d.move_block_up();
11613        assert_eq!(d.source, "- b\n- a\n  - x\n- c\n");
11614        assert_eq!(&d.source[d.caret..], "b\n- a\n  - x\n- c\n");
11615        d.caret = 6; // in "a"
11616        d.move_block_down();
11617        assert_eq!(d.source, "- b\n- c\n- a\n  - x\n");
11618        assert_eq!(&d.source[d.caret..], "a\n  - x\n");
11619        // A nested item leaves its list upward, as an item of the outer one.
11620        d.caret = 16; // in "x"
11621        d.move_block_up();
11622        assert_eq!(d.source, "- b\n- c\n- x\n- a\n");
11623    }
11624
11625    #[test]
11626    fn move_block_on_a_lone_item_that_stays_a_bullet_is_no_step() {
11627        let mut d = doc_with("mv_lone_item", "x\n\n- b\n\ny\n");
11628        d.caret = 5;
11629        let steps = d.undo_steps;
11630        d.move_block_up();
11631        assert_eq!(d.source, "x\n\n- b\n\ny\n");
11632        assert_eq!(
11633            d.undo_steps, steps,
11634            "a move that rewrote nothing is no undo step"
11635        );
11636        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
11637    }
11638
11639    #[test]
11640    fn move_block_up_leaves_a_container_at_its_first_block_and_down_at_its_last() {
11641        let mut d = doc_with("mv_leave", "x\n\n> a\n>\n> b\n\ny\n");
11642        d.caret = 5; // "a"
11643        d.move_block_up();
11644        assert_eq!(d.source, "x\n\na\n\n> b\n\ny\n");
11645        d.caret = 8; // "b"
11646        d.move_block_down();
11647        assert_eq!(d.source, "x\n\na\n\nb\n\ny\n");
11648        // A tail block leaves its item into the next item's tail, then the list.
11649        let mut d = doc_with("mv_tail", "- a\n\n  t\n- b\n");
11650        d.caret = 7;
11651        d.move_block_down();
11652        assert_eq!(d.source, "- a\n- b\n\n  t\n");
11653        d.move_block_down();
11654        assert_eq!(d.source, "- a\n- b\n\nt\n");
11655        // And a paragraph after a quote steps over the whole quote, not into it.
11656        let mut d = doc_with("mv_over", "x\n\n> a\n>\n> b\n\ny\n");
11657        d.caret = 14;
11658        d.move_block_up();
11659        assert_eq!(d.source, "x\n\ny\n\n> a\n>\n> b\n");
11660        assert_eq!(d.caret, 3);
11661        d.move_block_down();
11662        assert_eq!(d.status, None);
11663        assert_eq!(d.source, "x\n\n> a\n>\n> b\n\ny\n");
11664        // Above a quote that opens the document is offset 0 — before the
11665        // quote, not inside it.
11666        let mut d = doc_with("mv_over_top", "> a\n\ny\n");
11667        d.caret = 5;
11668        d.move_block_up();
11669        assert_eq!(d.source, "y\n\n> a\n");
11670        assert_eq!(d.caret, 0);
11671    }
11672
11673    #[test]
11674    fn move_block_up_from_the_first_block_of_a_quote_that_opens_the_document_leaves_it() {
11675        let mut d = doc_with("mv_top_quote", "> a\n>\n> b\n");
11676        d.caret = 2;
11677        d.move_block_up();
11678        assert_eq!(d.source, "a\n\n> b\n");
11679        assert_eq!(d.caret, 0);
11680        assert_eq!(d.status, None);
11681    }
11682
11683    #[test]
11684    fn move_block_on_a_blank_line_or_read_only_does_nothing() {
11685        let mut d = doc_with("mv_blank", "a\n\nb\n");
11686        d.caret = 2;
11687        d.move_block_down();
11688        assert_eq!(d.source, "a\n\nb\n");
11689        assert_eq!(d.status.as_deref(), Some("move block: no block here"));
11690        d.read_only = true;
11691        d.caret = 0;
11692        d.move_block_down();
11693        d.move_block(0, 5);
11694        assert_eq!(d.source, "a\n\nb\n");
11695    }
11696
11697    #[test]
11698    fn move_block_never_lands_above_hidden_frontmatter() {
11699        let mut d = wysiwyg_doc("mv_fm", "---\nt: x\n---\n\na\n\nb\n");
11700        let b = d.source.find('b').unwrap();
11701        d.move_block(b, 0);
11702        assert_eq!(d.source, "---\nt: x\n---\n\nb\n\na\n");
11703    }
11704
11705    #[test]
11706    fn block_range_at_is_the_block_a_move_picks_up() {
11707        let mut d = doc_with("mv_range", "a\n\n- b\n  - c\n\nd\n");
11708        assert_eq!(d.block_range_at(0), Some(0..1));
11709        assert_eq!(d.block_range_at(5), Some(3..12), "the item with its child");
11710        assert_eq!(d.block_range_at(10), Some(7..12), "the nested item alone");
11711        assert_eq!(d.block_range_at(2), None, "a blank line");
11712    }
11713
11714    #[test]
11715    fn drop_target_at_splits_a_block_at_its_middle_row_and_ends_below_everything() {
11716        let long = "two ".repeat(40).trim_end().to_string(); // wraps to three rows at 80
11717        let mut d = wysiwyg_doc("drop", &format!("one\n\n{long} four\n\nfive\n"));
11718        let rows = d.vmap.rows.len();
11719        assert_eq!(rows, 7, "one, gap, three wrapped rows, gap, five");
11720        assert_eq!(d.drop_target_at(0), Some(DropTarget { offset: 0, row: 0 }));
11721        let two = d.source.find("two").unwrap();
11722        assert_eq!(
11723            d.drop_target_at(1),
11724            Some(DropTarget {
11725                offset: two,
11726                row: 2
11727            }),
11728            "the gap resolves to the block under it"
11729        );
11730        assert_eq!(
11731            d.drop_target_at(2),
11732            Some(DropTarget {
11733                offset: two,
11734                row: 2
11735            })
11736        );
11737        assert_eq!(
11738            d.drop_target_at(3),
11739            Some(DropTarget {
11740                offset: two,
11741                row: 2
11742            })
11743        );
11744        let four_end = d.source.find("four").unwrap() + 4;
11745        assert_eq!(
11746            d.drop_target_at(4),
11747            Some(DropTarget {
11748                offset: four_end,
11749                row: 5
11750            })
11751        );
11752        assert_eq!(
11753            d.drop_target_at(rows + 3),
11754            Some(DropTarget {
11755                offset: d.source.len(),
11756                row: rows
11757            })
11758        );
11759        // And the offsets are ones move_block takes.
11760        let five = d.source.find("five").unwrap();
11761        let t = d.drop_target_at(2).unwrap();
11762        d.move_block(five, t.offset);
11763        assert_eq!(d.source, format!("one\n\nfive\n\n{long} four\n"));
11764    }
11765
11766    #[test]
11767    fn append_media_lands_at_the_end_as_its_own_block() {
11768        let mut d = doc_with("append_mid", "first word and more\n");
11769        d.caret = 0; // an editor nobody has tapped: the caret is at the start
11770        d.append_media(MediaKind::Image, "cat.png", "");
11771        assert_eq!(d.source, "first word and more\n\n![](cat.png)");
11772        assert_eq!(d.selection(), None);
11773        assert_eq!(d.caret, "first word and more\n\n![](cat.png)".len());
11774    }
11775
11776    #[test]
11777    fn append_media_needs_no_separator_after_a_blank_line_or_in_an_empty_document() {
11778        let mut d = doc_with("append_blank", "para\n\n");
11779        d.append_media(MediaKind::Image, "a.png", "");
11780        assert_eq!(d.source, "para\n\n![](a.png)");
11781
11782        let mut e = doc_with("append_empty", "");
11783        e.append_media(MediaKind::Image, "b.png", "");
11784        assert_eq!(e.source, "![](b.png)");
11785
11786        let mut f = doc_with("append_noeol", "no newline at end");
11787        f.anchor = Some(0);
11788        f.caret = 2; // a selection, which the verb ignores
11789        f.append_media(MediaKind::Video, "clip.mp4", "");
11790        assert_eq!(
11791            f.source,
11792            "no newline at end\n\n<video src=\"clip.mp4\" controls></video>"
11793        );
11794    }
11795
11796    #[test]
11797    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
11798        // The round trip is the point: it's no use writing markup the reader
11799        // can't pick up again. This is the pair that only holds from twig 2.5.1
11800        // on — before it, the one-line form went in fine and came back as a
11801        // paragraph of raw tags, publishing no media at all.
11802        let mut d = doc_with("vid_rt", "\n");
11803        d.caret = 0;
11804        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
11805        assert_eq!(
11806            d.source,
11807            "<video src=\"clip.mp4\" controls>a clip</video>\n"
11808        );
11809
11810        d.build_visual(80);
11811        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
11812        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
11813        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
11814        assert_eq!(d.vmap.media[0].alt, "a clip");
11815    }
11816
11817    #[test]
11818    fn insert_media_spells_audio_with_its_own_tag() {
11819        let mut d = doc_with("aud_rt", "\n");
11820        d.caret = 0;
11821        d.insert_media(MediaKind::Audio, "take.mp3", "");
11822        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
11823        d.build_visual(80);
11824        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
11825    }
11826
11827    #[test]
11828    fn insert_media_uses_the_selection_as_fallback_text() {
11829        // The same courtesy `insert_image` does with alt: select a caption,
11830        // insert, and the caption labels the thing rather than being replaced.
11831        let mut d = doc_with("vid_sel", "the talk here\n");
11832        d.anchor = Some(0);
11833        d.caret = 8; // "the talk"
11834        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
11835        assert_eq!(
11836            d.source,
11837            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
11838        );
11839    }
11840
11841    #[test]
11842    fn insert_media_with_an_image_kind_is_just_insert_image() {
11843        let mut d = doc_with("img_via_media", "\n");
11844        d.caret = 0;
11845        d.insert_media(MediaKind::Image, "logo.svg", "x");
11846        assert_eq!(d.source, "![x](logo.svg)\n");
11847    }
11848
11849    // ── thematic breaks ─────────────────────────────────────────────────────
11850
11851    /// The node the source parses as at `caret` — what confirms an inserted
11852    /// `---` actually reads back as a rule, not stray text or a setext heading.
11853    ///
11854    /// The *narrowest* node covering the offset. Every ancestor covers it too,
11855    /// and since twig 2.8 that includes the `doc` root, which now carries a real
11856    /// span (it reported none before, so taking the first match used to land on
11857    /// the block by luck and now always answers `"doc"`).
11858    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
11859        d.nodes()
11860            .into_iter()
11861            .filter(|n| n.span.start <= caret && caret < n.span.end)
11862            .min_by_key(|n| n.span.end - n.span.start)
11863            .map(|n| n.kind)
11864    }
11865
11866    #[test]
11867    fn a_task_box_toggles_at_the_caret_and_reads_back() {
11868        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
11869        d.caret = 8; // inside "todo"
11870        assert_eq!(d.task_checked_at_caret(), Some(false));
11871        d.toggle_task_checked();
11872        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
11873        assert_eq!(d.task_checked_at_caret(), Some(true));
11874        d.toggle_task_checked();
11875        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
11876    }
11877
11878    #[test]
11879    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
11880        // The whole reason `toggle_task_at` exists apart from the caret form:
11881        // ticking a box elsewhere must not move the cursor out of what's being
11882        // typed.
11883        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
11884        d.caret = 8; // inside "first"
11885        let second = d.source.find("second").unwrap();
11886        d.toggle_task_at(second);
11887        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
11888        assert_eq!(d.caret, 8, "the caret stayed in the first item");
11889    }
11890
11891    #[test]
11892    fn a_paragraph_becomes_a_task_item_in_one_undo_step() {
11893        let mut d = doc_with("task_para", "first\n\nplain para\n");
11894        d.caret = d.source.find("para").unwrap();
11895        d.toggle_task_item();
11896        assert_eq!(d.source, "first\n\n- [ ] plain para\n");
11897        assert_eq!(d.task_checked_at_caret(), Some(false));
11898        assert_eq!(d.status, None);
11899        d.undo();
11900        assert_eq!(
11901            d.source, "first\n\nplain para\n",
11902            "one step takes both back"
11903        );
11904    }
11905
11906    #[test]
11907    fn a_blank_line_becomes_an_empty_task_item() {
11908        let mut d = doc_with("task_blank", "first\n\n");
11909        d.caret = d.source.len();
11910        d.toggle_task_item();
11911        assert_eq!(d.task_checked_at_caret(), Some(false), "{:?}", d.source);
11912        assert_eq!(d.status, None);
11913    }
11914
11915    #[test]
11916    fn a_plain_item_gains_and_loses_a_box() {
11917        let mut d = doc_with("task_mint", "- plain\n");
11918        d.caret = 4;
11919        assert_eq!(d.task_checked_at_caret(), None);
11920        d.toggle_task_item();
11921        assert_eq!(d.source, "- [ ] plain\n");
11922        assert_eq!(
11923            d.task_checked_at_caret(),
11924            Some(false),
11925            "a new box arrives unticked"
11926        );
11927        d.toggle_task_item();
11928        assert_eq!(d.source, "- plain\n");
11929    }
11930
11931    #[test]
11932    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
11933        // `set checked` must not silently convert a bullet into a task — that is
11934        // `toggle_task_item`'s job, and twig refuses it here.
11935        let mut d = doc_with("task_none", "- plain\n");
11936        d.caret = 4;
11937        d.toggle_task_checked();
11938        assert_eq!(d.source, "- plain\n", "nothing written");
11939        assert!(
11940            d.status.is_some(),
11941            "the refusal should reach the status line"
11942        );
11943    }
11944
11945    #[test]
11946    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
11947        let mut d = doc_with("task_quote", "> - [ ] nested\n");
11948        d.caret = d.source.find("nested").unwrap();
11949        assert_eq!(d.task_checked_at_caret(), Some(false));
11950        d.toggle_task_checked();
11951        assert_eq!(d.source, "> - [x] nested\n");
11952    }
11953
11954    #[test]
11955    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
11956        // A rule is a block, so twig's `insert_thematic_break` alone lands it
11957        // after the whole paragraph. `split_block` parts the paragraph first and
11958        // the rule is aimed at the *first* half, which is what a rule button is
11959        // understood to do — and what leaf spelled by hand until twig grew both
11960        // halves of the gesture.
11961        let mut d = doc_with("hr_mid", "before after\n");
11962        d.caret = 7; // between "before " and "after"
11963        d.insert_thematic_break();
11964        assert_eq!(d.source, "before \n\n---\n\nafter\n");
11965        assert_eq!(d.selection(), None);
11966        assert_eq!(
11967            kind_at(&mut d, "before \n\n".len()),
11968            Some(Kind::ThematicBreak)
11969        );
11970    }
11971
11972    #[test]
11973    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
11974        // At the end there is nothing to part, and a split there writes the
11975        // separator anyway — a blank line and the empty slot the next paragraph
11976        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
11977        // two blank lines nothing fills. Now the rule lands after the paragraph,
11978        // where the split-and-aim was sending it regardless. Both formats, and
11979        // both shapes of a last line — terminated, and still being typed —
11980        // because the two reach the split through different doors: Markdown's
11981        // paragraph span stops before its newline, so `para\n` at 4 never split
11982        // there, but `para` at 4 did.
11983        //
11984        // What stands under the rule is the line the caret goes on to, not a
11985        // slot: nothing above the rule but the one blank line, and the caret on
11986        // the line beneath it.
11987        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
11988            for src in ["para\n", "para"] {
11989                let mut d = Doc::from_source(src.into(), fmt).unwrap();
11990                d.caret = 4;
11991                d.insert_thematic_break();
11992                assert_eq!(d.source, format!("para\n\n{rule}\n\n"), "{fmt:?} {src:?}");
11993                assert_eq!(d.caret, d.source.len());
11994            }
11995            // Mid-document the slot sat between the rule and the next block;
11996            // the caret's line does now, a blank line either side of it.
11997            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
11998            d.caret = 4;
11999            d.insert_thematic_break();
12000            assert_eq!(d.source, format!("para\n\n{rule}\n\n\n\nnext\n"), "{fmt:?}");
12001            // Trailing whitespace is nothing to part either.
12002            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
12003            d.caret = 4;
12004            d.insert_thematic_break();
12005            assert_eq!(d.source, format!("para  \n\n{rule}\n\n"), "{fmt:?}");
12006        }
12007    }
12008
12009    #[test]
12010    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
12011        // The split at the start parts nothing, but it is kept on purpose:
12012        // `|para` becomes `\npara` with the caret on a blank line, and twig
12013        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
12014        // way "before the paragraph" is reachable through a gesture that only
12015        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
12016        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
12017            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
12018            d.caret = 0;
12019            d.insert_thematic_break();
12020            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
12021            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
12022            d.caret = 6;
12023            d.insert_thematic_break();
12024            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
12025        }
12026    }
12027
12028    #[test]
12029    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
12030        // The gap between two blocks is where a click lands the caret; the
12031        // rule goes on the blank, one blank each side, and the caret on the
12032        // line opened under it.
12033        let mut d = doc_with("hr_gap", "a\n\nb\n");
12034        d.caret = 2;
12035        d.insert_thematic_break();
12036        assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12037        assert_eq!(d.caret, "a\n\n---\n\n".len());
12038    }
12039
12040    #[test]
12041    fn insert_thematic_break_leaves_the_caret_on_a_line_under_the_rule() {
12042        // The caret used to be left where twig's splice ended, the home past
12043        // the rule, which is drawn on the rule's own row: the writer saw the
12044        // caret beside the rule, and mid-document what they typed next ran
12045        // into the block below. Now it goes on to an empty row under the
12046        // rule's gap from every place a rule is asked for — the end of a
12047        // paragraph, the empty paragraph Enter opened, a list and the line
12048        // Enter leaves a list for, closing the document and before another
12049        // block — and what is typed there is a paragraph of its own.
12050        for (body, caret, typed) in [
12051            ("para\n", 4, "para\n\n---\n\nX"),
12052            ("para\n\n\n", 6, "para\n\n---\n\nX"),
12053            ("- one\n- two\n", 11, "- one\n- two\n\n---\n\nX"),
12054            ("- one\n- two\n\n\n", 13, "- one\n- two\n\n---\n\nX"),
12055            ("a\n\nb\n", 1, "a\n\n---\n\nX\n\nb\n"),
12056            ("a\n\n\n\nb\n", 3, "a\n\n---\n\nX\n\nb\n"),
12057        ] {
12058            let mut d = wysiwyg_doc("hr_caret", body);
12059            d.build_visual(80);
12060            d.caret = caret;
12061            d.insert_thematic_break();
12062            d.build_visual(80);
12063            let rule = d.vmap.pos_of_offset(d.source.find("---").unwrap()).0;
12064            let (row, col) = d.vmap.pos_of_offset(d.caret);
12065            assert_eq!((row, col), (rule + 2, 0), "{body:?} → {:?}", d.source);
12066            assert!(d.vmap.rows[rule + 1].decoration, "the gap under the rule");
12067            assert!(d.vmap.rows[row].glyphs.is_empty(), "an empty line");
12068            d.insert("X");
12069            assert_eq!(d.source, typed, "{body:?}");
12070            let rule_at = d.source.find("---").unwrap();
12071            assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
12072        }
12073        // djot's rule takes in its newline; the line under it is the same.
12074        let mut d = Doc::from_source("a\n\nb\n".into(), Format::Djot).unwrap();
12075        d.view = View::Wysiwyg;
12076        d.caret = 1;
12077        d.insert_thematic_break();
12078        d.build_visual(80);
12079        assert_eq!(d.source, "a\n\n* * *\n\n\n\nb\n");
12080        assert_eq!(d.vmap.pos_of_offset(d.caret), (4, 0));
12081    }
12082
12083    #[test]
12084    fn a_rule_parting_a_paragraph_leaves_the_caret_before_its_second_half() {
12085        // The line under the rule is the paragraph's second half, so that is
12086        // where the caret goes: the text it was standing in front of is still
12087        // in front of it.
12088        let mut d = wysiwyg_doc("hr_parted", "before after\n");
12089        d.caret = 7;
12090        d.insert_thematic_break();
12091        assert_eq!(d.source, "before \n\n---\n\nafter\n");
12092        assert_eq!(d.caret, d.source.find("after").unwrap());
12093        // At the start of a paragraph too, the rule landing above it.
12094        let mut d = wysiwyg_doc("hr_parted_start", "prev\n\npara\n");
12095        d.caret = 6;
12096        d.insert_thematic_break();
12097        assert_eq!(d.caret, d.source.find("para").unwrap());
12098    }
12099
12100    #[test]
12101    fn the_line_under_a_new_rule_is_one_undo_step_with_it() {
12102        let mut d = wysiwyg_doc("hr_undo", "a\n\nb\n");
12103        d.caret = 1;
12104        d.insert_thematic_break();
12105        assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12106        d.undo();
12107        assert_eq!(d.source, "a\n\nb\n");
12108        assert!(!d.can_undo());
12109        d.redo();
12110        assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12111    }
12112
12113    #[test]
12114    fn a_rule_in_preserve_flow_leaves_the_caret_on_the_first_line_under_it() {
12115        // Preserve draws every blank line as somewhere to type, the first under
12116        // the rule included, so that is the caret's line and the one written.
12117        let mut d = wysiwyg_doc("hr_preserve", "para\n");
12118        d.set_line_flow(LineFlow::Preserve);
12119        d.caret = 4;
12120        d.insert_thematic_break();
12121        assert_eq!(d.source, "para\n\n---\n\n");
12122        assert_eq!(d.caret, "para\n\n---\n".len());
12123        d.build_visual(80);
12124        let rule = d.vmap.pos_of_offset(d.source.find("---").unwrap()).0;
12125        assert_eq!(d.vmap.pos_of_offset(d.caret), (rule + 1, 0));
12126        // Before another block, one blank line more keeps it off that block.
12127        let mut d = wysiwyg_doc("hr_preserve_mid", "a\n\nb\n");
12128        d.set_line_flow(LineFlow::Preserve);
12129        d.caret = 1;
12130        d.insert_thematic_break();
12131        assert_eq!(d.source, "a\n\n---\n\n\nb\n");
12132        assert_eq!(d.caret, "a\n\n---\n".len());
12133    }
12134
12135    #[test]
12136    fn insert_page_break_leaves_the_caret_on_a_line_under_it() {
12137        // The rule button's placement, for the other block the toolbar writes:
12138        // the caret was left at the end of twig's splice, beside the break at
12139        // the end of the document and on the next block's first letter before
12140        // one. djot spells the break as a fence of two lines; the line under
12141        // it is under the closing `:::`.
12142        for (fmt, body, want, typed) in [
12143            (
12144                Format::Markdown,
12145                "a\n\nb\n",
12146                "a\n\n::page-break\n\n\n\nb\n",
12147                "a\n\n::page-break\n\nX\n\nb\n",
12148            ),
12149            (
12150                Format::Markdown,
12151                "a\n",
12152                "a\n\n::page-break\n\n",
12153                "a\n\n::page-break\n\nX",
12154            ),
12155            (
12156                Format::Djot,
12157                "a\n\nb\n",
12158                "a\n\n::: page-break\n:::\n\n\n\nb\n",
12159                "a\n\n::: page-break\n:::\n\nX\n\nb\n",
12160            ),
12161        ] {
12162            let mut d = Doc::from_source(body.into(), fmt).unwrap();
12163            d.view = View::Wysiwyg;
12164            d.caret = 1;
12165            d.insert_page_break();
12166            assert_eq!(d.source, want, "{fmt:?}");
12167            d.build_visual(80);
12168            let (row, col) = d.vmap.pos_of_offset(d.caret);
12169            assert_eq!(col, 0);
12170            assert!(d.vmap.rows[row].glyphs.is_empty(), "{fmt:?}: an empty line");
12171            assert!(
12172                d.vmap.rows[row - 2].leaf_directive.is_some(),
12173                "{fmt:?}: under the break"
12174            );
12175            d.insert("X");
12176            assert_eq!(d.source, typed, "{fmt:?}");
12177            d.undo();
12178            d.undo();
12179            assert_eq!(
12180                d.source, body,
12181                "{fmt:?}: the break and its line are one step"
12182            );
12183        }
12184    }
12185
12186    #[test]
12187    fn a_block_parting_a_paragraph_is_one_undo_step() {
12188        // The split went to twig without leaf counting it, so the first Undo
12189        // took back the block, left the paragraph parted, and reported nothing
12190        // more to undo.
12191        let mut rule = wysiwyg_doc("part_undo_rule", "before after\n");
12192        rule.caret = 7;
12193        rule.insert_thematic_break();
12194        let mut table = wysiwyg_doc("part_undo_table", "before after\n");
12195        table.caret = 7;
12196        table.insert_table(1, 1);
12197        let mut page = wysiwyg_doc("part_undo_page", "before after\n");
12198        page.caret = 7;
12199        page.insert_page_break();
12200        for (name, d) in [
12201            ("rule", &mut rule),
12202            ("table", &mut table),
12203            ("page break", &mut page),
12204        ] {
12205            assert_ne!(d.source, "before after\n", "{name}");
12206            d.undo();
12207            assert_eq!(d.source, "before after\n", "{name}: one Undo");
12208            assert_eq!(d.caret, 7, "{name}: the caret back where it was");
12209            assert!(!d.can_undo(), "{name}");
12210            assert!(d.can_redo(), "{name}");
12211            d.redo();
12212            assert!(!d.source.starts_with("before after"), "{name}: one Redo");
12213            assert!(!d.can_redo(), "{name}");
12214        }
12215        // A table twig would refuse parts nothing: the refusal comes first.
12216        let mut d = wysiwyg_doc("part_undo_zero", "before after\n");
12217        d.caret = 7;
12218        d.insert_table(0, 1);
12219        assert_eq!(d.source, "before after\n");
12220        assert!(!d.can_undo());
12221    }
12222
12223    #[test]
12224    fn enter_at_a_paragraph_s_start_opens_an_empty_paragraph_above_it() {
12225        // twig's split at a paragraph's start writes one newline ahead of it,
12226        // which Fold draws as one more gap, so the first Enter showed nothing.
12227        // A paragraph break opens a line above, the caret staying before the
12228        // text. Past a mark's opening delimiters — where a tap before the
12229        // first letter lands — the split used to cut the mark in two
12230        // (`**\n\nb**`); the break goes in front of them, as it goes in
12231        // front of a heading's `#`, where the heading's own Enter left an
12232        // empty heading over its text as a paragraph. A paragraph that
12233        // opens a div gets its line outside the div, where it draws.
12234        for (body, caret, want, want_caret) in [
12235            ("a\n\nb\n", 3, "a\n\n\n\nb\n", 5),
12236            ("# H\n\nb\n", 5, "# H\n\n\n\nb\n", 7),
12237            ("a\n\n# H\n", 5, "a\n\n\n\n# H\n", 7),
12238            ("a\n\n**b** c\n", 3, "a\n\n\n\n**b** c\n", 5),
12239            ("a\n\n**b** c\n", 5, "a\n\n\n\n**b** c\n", 7),
12240            (
12241                "a\n\n<span data-color=\"orange\">b</span>\n",
12242                29,
12243                "a\n\n\n\n<span data-color=\"orange\">b</span>\n",
12244                31,
12245            ),
12246            (
12247                "a\n\n<div class=\"center\">\n\nb\n\n</div>\n",
12248                25,
12249                "a\n\n\n\n<div class=\"center\">\n\nb\n\n</div>\n",
12250                27,
12251            ),
12252        ] {
12253            let mut d = wysiwyg_doc("enter_para_start", body);
12254            d.build_visual(80);
12255            d.caret = caret;
12256            d.newline();
12257            assert_eq!(d.source, want, "{body:?}");
12258            assert_eq!(d.caret, want_caret, "{body:?}");
12259            d.build_visual(80);
12260            let (row, _) = d.vmap.pos_of_offset(d.caret);
12261            assert!(
12262                d.vmap.rows[row - 2].glyphs.is_empty(),
12263                "{body:?}: an empty line above"
12264            );
12265            assert!(
12266                !d.vmap.rows[row - 2].decoration,
12267                "{body:?}: one the caret can stand on"
12268            );
12269        }
12270        // Past the first letter it is an ordinary split.
12271        let mut d = wysiwyg_doc("enter_para_mid", "a\n\n**b** c\n");
12272        d.caret = 8;
12273        d.newline();
12274        assert_eq!(d.source, "a\n\n**b**\n\nc\n");
12275    }
12276
12277    #[test]
12278    fn enter_again_at_a_paragraph_s_start_opens_one_more_line() {
12279        // The first Enter's paragraph break leaves a gap either side of the
12280        // empty line. A second break drew two lines more; a newline draws one.
12281        let mut d = wysiwyg_doc("enter_para_again", "a\n\nb\n");
12282        d.build_visual(80);
12283        d.caret = 3;
12284        for (want, lines) in [("a\n\n\n\nb\n", 1), ("a\n\n\n\n\nb\n", 2)] {
12285            d.newline();
12286            assert_eq!(d.source, want);
12287            assert_eq!(&d.source[d.caret..], "b\n");
12288            d.build_visual(80);
12289            let empty = d
12290                .vmap
12291                .rows
12292                .iter()
12293                .filter(|r| r.glyphs.is_empty() && !r.decoration)
12294                .count();
12295            assert_eq!(empty, lines, "{want:?}");
12296        }
12297    }
12298
12299    #[test]
12300    fn enter_at_the_first_block_s_start_pushes_it_down() {
12301        // No row was drawn for a blank line above the first block, so Enter
12302        // there wrote a line and the text stayed where it was. Each Enter now
12303        // draws one empty line above it, the caret staying with the text;
12304        // past frontmatter, the line conventionally under it still draws
12305        // nothing until Enter adds to it.
12306        for (body, caret, wants) in [
12307            ("b\n", 0, ["\n\nb\n", "\n\n\nb\n"]),
12308            ("**b** c\n", 2, ["\n\n**b** c\n", "\n\n\n**b** c\n"]),
12309            ("# H\n", 2, ["\n\n# H\n", "\n\n\n# H\n"]),
12310            (
12311                "---\nt: x\n---\n\nb\n",
12312                14,
12313                ["---\nt: x\n---\n\n\nb\n", "---\nt: x\n---\n\n\n\nb\n"],
12314            ),
12315        ] {
12316            let mut d = wysiwyg_doc("enter_first_block", body);
12317            d.build_visual(80);
12318            let empty = |d: &Doc| {
12319                d.vmap
12320                    .rows
12321                    .iter()
12322                    .filter(|r| r.glyphs.is_empty() && !r.decoration)
12323                    .count()
12324            };
12325            assert_eq!(empty(&d), 0, "{body:?}: nothing above the text yet");
12326            d.caret = caret;
12327            for (lines, want) in wants.into_iter().enumerate() {
12328                d.newline();
12329                assert_eq!(d.source, want, "{body:?}");
12330                assert!(d.source[d.caret..].starts_with(&body[caret..]), "{body:?}");
12331                d.build_visual(80);
12332                assert_eq!(empty(&d), lines + 1, "{want:?}");
12333            }
12334            // The caret can go up onto the lines it opened.
12335            d.move_up(false);
12336            let (row, _) = d.vmap.pos_of_offset(d.caret);
12337            assert!(d.vmap.rows[row].glyphs.is_empty(), "{body:?}");
12338            assert!(!d.vmap.rows[row].decoration, "{body:?}");
12339        }
12340    }
12341
12342    /// What a reader sees of a map: each row's text, whether it is a gap,
12343    /// and the presentation it is drawn with. Offsets are left out, since an
12344    /// edit shifts them without anything on screen changing.
12345    fn drawn(d: &Doc) -> Vec<String> {
12346        d.vmap
12347            .rows
12348            .iter()
12349            .map(|r| {
12350                let text: String = r.glyphs.iter().map(|g| g.ch).collect();
12351                format!(
12352                    "{}{text:?} h={:?} lh={:?} a={:?}",
12353                    if r.decoration { "~" } else { "" },
12354                    r.heading,
12355                    r.line_height,
12356                    r.align
12357                )
12358            })
12359            .collect()
12360    }
12361
12362    #[test]
12363    fn enter_and_backspace_anywhere_show_what_they_write_and_undo_in_one_step() {
12364        // Enter that writes what the view does not draw looks like a key that
12365        // did nothing, so it gets pressed again, and the lines pile up unseen
12366        // until something redraws them all at once. The rule, the page break,
12367        // a paragraph's start, the first block's start and the blank page
12368        // were each that bug once. So: at every place the caret can stand, in
12369        // both flows, Enter either changes what is drawn or writes nothing,
12370        // and one Undo takes back whatever it wrote. Enter and Backspace both
12371        // leave the view a fresh open of the text would draw, so nothing
12372        // moves when the view is next rebuilt from scratch.
12373        let cases: &[(Format, &str)] = &[
12374            (
12375                Format::Markdown,
12376                "I talk to myself\n\nI laugh\n\nI cry\n\nknees to nose\n\nlet me go\n",
12377            ),
12378            (Format::Markdown, "I talk to myself\nI laugh\nI cry\n"),
12379            (
12380                Format::Markdown,
12381                "<div data-line-height=\"1.5\">\n\nI laugh\n\nI cry\n\nknees to nose\n\n</div>\n",
12382            ),
12383            (
12384                Format::Markdown,
12385                "<div data-line-height=\"1.15\">\n\nI cry\n\n</div>\n\n<div data-line-height=\"1.15\">\n\nknees\n\n</div>\n",
12386            ),
12387            (
12388                Format::Markdown,
12389                "<div class=\"center\">\n\nmiddle\n\n</div>\n\nafter\n",
12390            ),
12391            (
12392                Format::Markdown,
12393                "---\ntitle: x\n---\n\n# Title\n\nbody **bold** and <span data-color=\"red\">red</span>\n",
12394            ),
12395            (Format::Markdown, "\n\n\nafter blank lines\n\n\n\nmid\n\n\n"),
12396            (
12397                Format::Markdown,
12398                "- one\n- two\n\n1. a\n2. b\n\n- [ ] task\n",
12399            ),
12400            (Format::Markdown, "> quoted\n> more\n\n> - in a quote\n"),
12401            (Format::Markdown, "a\n\n---\n\nb\n\n::page-break\n\nc\n"),
12402            (Format::Markdown, "Title\n=====\n\nSub\n---\n\ntext\n"),
12403            (
12404                Format::Markdown,
12405                "```\ncode\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
12406            ),
12407            (Format::Djot, "I laugh\n\nI cry\n\nknees to nose\n"),
12408            (
12409                Format::Djot,
12410                "{data-line-height=\"2\"}\nI cry\n\n{data-line-height=\"2\"}\nknees to nose\n",
12411            ),
12412            (Format::Djot, "# Title\n\na\n\n* * *\n\nb\n"),
12413            // HTML is not here: Enter writes blank lines there, which the
12414            // view draws and HTML does not keep. See
12415            // docs/tasks/enter-in-html-writes-whitespace.md.
12416        ];
12417        let mut failures = Vec::new();
12418        for &(format, body) in cases {
12419            for flow in [LineFlow::Fold, LineFlow::Preserve] {
12420                let open = || {
12421                    let mut d = Doc::from_source(body.into(), format).unwrap();
12422                    d.view = View::Wysiwyg;
12423                    d.set_line_flow(flow);
12424                    d.build_visual_unwrapped();
12425                    d
12426                };
12427                // The map a fresh open of the same text would draw. The
12428                // edited map differing from it is a view that changes on the
12429                // next full rebuild, seconds after the key, with no key.
12430                let fresh = |source: &str| {
12431                    let mut ed =
12432                        twig::Editor::new_ext(source.as_bytes(), format, parse_extensions())
12433                            .unwrap();
12434                    let nodes = ed.nodes().unwrap();
12435                    crate::wysiwyg::build(
12436                        &nodes,
12437                        source,
12438                        None,
12439                        flow == LineFlow::Preserve,
12440                        &wysiwyg::Surface::default(),
12441                        None,
12442                    )
12443                };
12444                let probe = open();
12445                let stops: Vec<usize> = (0..=body.len())
12446                    .filter(|&o| probe.vmap.is_stop(o))
12447                    .collect();
12448                for at in stops {
12449                    for enter in [true, false] {
12450                        let mut d = open();
12451                        d.caret = at;
12452                        d.anchor = None;
12453                        let before = drawn(&d);
12454                        if enter {
12455                            d.newline();
12456                        } else {
12457                            d.backspace();
12458                        }
12459                        d.build_visual_unwrapped();
12460                        let key = if enter { "enter" } else { "backspace" };
12461                        let what = format!(
12462                            "{key} {format:?} {flow:?} caret {at} in {body:?} -> {:?}",
12463                            d.source
12464                        );
12465                        if maps_differ(&d.vmap, &fresh(&d.source)) {
12466                            failures.push(format!("redraws differently: {what}"));
12467                        }
12468                        if d.source == body {
12469                            continue;
12470                        }
12471                        if enter && drawn(&d) == before {
12472                            failures.push(format!("drew nothing: {what}"));
12473                        }
12474                        d.undo();
12475                        if d.source != body {
12476                            failures.push(format!("undo left {:?}: {what}", d.source));
12477                        }
12478                    }
12479                }
12480            }
12481        }
12482        assert!(
12483            failures.is_empty(),
12484            "{} failures:\n{}",
12485            failures.len(),
12486            failures.join("\n")
12487        );
12488    }
12489
12490    #[test]
12491    fn enter_opens_a_line_that_draws_inside_a_div_a_quote_and_preserve_flow() {
12492        // Each of these wrote a line the view did not draw, so Enter looked
12493        // dead and every press left one more.
12494        let enter = |format, flow, body: &str, at: usize| {
12495            let mut d = Doc::from_source(body.into(), format).unwrap();
12496            d.view = View::Wysiwyg;
12497            d.set_line_flow(flow);
12498            d.build_visual(80);
12499            d.caret = at;
12500            d.newline();
12501            d.build_visual(80);
12502            d
12503        };
12504        let div = "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\n</div>\n";
12505        let end = div.find("knees").unwrap() + "knees".len();
12506
12507        // At the end of a div's last paragraph, the empty paragraph opens
12508        // inside the div, drawn with its line height, and what is typed
12509        // there stays in it.
12510        let mut d = enter(Format::Markdown, LineFlow::Fold, div, end);
12511        let (row, _) = d.vmap.pos_of_offset(d.caret);
12512        let r = &d.vmap.rows[row];
12513        assert!(r.glyphs.is_empty() && !r.decoration, "{:?}", drawn(&d));
12514        assert!(
12515            r.line_height.is_some(),
12516            "the new line keeps the div's spacing"
12517        );
12518        d.insert("x");
12519        assert_eq!(
12520            d.source,
12521            "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\nx\n\n</div>\n"
12522        );
12523        // Preserve flow's soft line there draws too.
12524        let d = enter(Format::Markdown, LineFlow::Preserve, div, end);
12525        assert_eq!(
12526            d.source,
12527            "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\n\n</div>\n"
12528        );
12529        assert_eq!(d.vmap.rows.len(), 4, "{:?}", drawn(&d));
12530        // Between two of the div's paragraphs, the empty line takes its
12531        // spacing as well.
12532        let d = enter(
12533            Format::Markdown,
12534            LineFlow::Fold,
12535            div,
12536            div.find("knees").unwrap(),
12537        );
12538        let (row, _) = d.vmap.pos_of_offset(d.caret);
12539        assert!(
12540            d.vmap.rows[row - 2].line_height.is_some(),
12541            "{:?}",
12542            drawn(&d)
12543        );
12544
12545        // Under `</div>` the blank line is the one Markdown needs to end the
12546        // div. Preserve flow drew it as a line to type on, and Backspace
12547        // there glued the next paragraph to the tag (`</div>after`).
12548        let below = "<div class=\"center\">\n\nmiddle\n\n</div>\n\nafter\n";
12549        let mut d = Doc::from_source(below.into(), Format::Markdown).unwrap();
12550        d.view = View::Wysiwyg;
12551        d.set_line_flow(LineFlow::Preserve);
12552        d.build_visual(80);
12553        assert_eq!(d.vmap.rows.len(), 2, "{:?}", drawn(&d));
12554        d.caret = below.find("after").unwrap();
12555        d.backspace();
12556        assert!(!d.source.contains("</div>after"), "{:?}", d.source);
12557
12558        // At a quote's first line, the line opens above the quote; twig's
12559        // split wrote a quoted blank line above the text, which drew nothing.
12560        let d = enter(
12561            Format::Markdown,
12562            LineFlow::Fold,
12563            "a\n\n> quoted\n> more\n",
12564            5,
12565        );
12566        assert_eq!(d.source, "a\n\n\n\n> quoted\n> more\n");
12567        assert_eq!(&d.source[d.caret..], "quoted\n> more\n");
12568
12569        // Preserve flow at a paragraph's start: past a mark's delimiters its
12570        // newline cut the mark (`**\nb**`), and in djot a heading ran on
12571        // over it unseen.
12572        let d = enter(Format::Markdown, LineFlow::Preserve, "a\n\n**b** c\n", 5);
12573        assert_eq!(d.source, "a\n\n\n**b** c\n");
12574        let d = enter(Format::Djot, LineFlow::Preserve, "a\n\n# Title\n", 5);
12575        assert_eq!(d.source, "a\n\n\n# Title\n");
12576        // Inside a heading it parts the heading, as in Fold flow.
12577        let d = enter(Format::Djot, LineFlow::Preserve, "a\n\n# Title\n", 7);
12578        assert_eq!(d.source, "a\n\n# Ti\n\ntle\n");
12579    }
12580
12581    #[test]
12582    fn enter_on_a_blank_page_writes_nothing() {
12583        // With no block to push down, Fold flow draws no line for Enter to
12584        // open, and the newline it wrote was an edit and an undo step that
12585        // showed nothing. Preserve flow draws every line, so there it writes.
12586        for body in ["", "\n", "\n\n  \n", "---\nt: x\n---\n"] {
12587            let mut d = wysiwyg_doc("enter_blank_page", body);
12588            d.build_visual(80);
12589            d.caret = d.source.len();
12590            d.newline();
12591            assert_eq!(d.source, body);
12592            assert!(!d.can_undo(), "{body:?}");
12593            assert!(!d.dirty, "{body:?}");
12594        }
12595        let mut d = wysiwyg_doc("enter_blank_page_preserve", "");
12596        d.set_line_flow(LineFlow::Preserve);
12597        d.build_visual(80);
12598        d.newline();
12599        assert_eq!(d.source, "\n");
12600    }
12601
12602    #[test]
12603    fn enter_beside_a_rule_opens_a_line_under_it() {
12604        // Beside a rule the caret stands at the home past it, which in source is
12605        // the start of the blank line under the rule, and Enter took it for an
12606        // empty paragraph: a lone newline, drawn as one more gap, and the caret
12607        // put on the next block's first line — where typing joined that block.
12608        // It goes on under the rule instead, as the rule button leaves it.
12609        let mut d = wysiwyg_doc("enter_rule", "a\n\n---\n\nb\n");
12610        d.build_visual(80);
12611        d.caret = "a\n\n---\n".len();
12612        d.newline();
12613        assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12614        assert_eq!(d.caret, "a\n\n---\n\n".len());
12615        d.insert("X");
12616        assert_eq!(d.source, "a\n\n---\n\nX\n\nb\n");
12617
12618        // Closing the document.
12619        let mut d = wysiwyg_doc("enter_rule_end", "a\n\n---\n");
12620        d.caret = d.source.len();
12621        d.newline();
12622        assert_eq!(d.source, "a\n\n---\n\n");
12623        assert_eq!(d.caret, d.source.len());
12624
12625        // In a quote, where the lines it writes keep the quote's prefix.
12626        let mut d = wysiwyg_doc("enter_rule_quote", "> a\n>\n> ---\n>\n> b\n");
12627        d.caret = "> a\n>\n> ---\n".len();
12628        d.newline();
12629        assert_eq!(d.source, "> a\n>\n> ---\n>\n> \n>\n> b\n");
12630        d.insert("X");
12631        assert_eq!(d.source, "> a\n>\n> ---\n>\n> X\n>\n> b\n");
12632
12633        // Preserve draws the blank line under a rule as a line, and the caret
12634        // on it: Enter there is a blank line's, one line down.
12635        let mut d = wysiwyg_doc("enter_rule_preserve", "a\n\n---\n\nb\n");
12636        d.set_line_flow(LineFlow::Preserve);
12637        d.caret = "a\n\n---\n".len();
12638        d.newline();
12639        assert_eq!(d.source, "a\n\n---\n\n\nb\n");
12640        assert_eq!(d.caret, "a\n\n---\n\n".len());
12641    }
12642
12643    #[test]
12644    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
12645        // The same door as the rule's, through the placement they share.
12646        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12647        d.caret = 4;
12648        d.insert_table(1, 1);
12649        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
12650        let mut d = doc_with("table_end_typed", "para");
12651        d.caret = 4;
12652        d.insert_table(1, 1);
12653        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
12654        assert!(d.caret_in_table());
12655    }
12656
12657    #[test]
12658    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
12659        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
12660        // and leaf wrote the first into both until twig started spelling it.
12661        let mut md = doc_with("hr_md", "para\n");
12662        md.caret = 2;
12663        md.insert_thematic_break();
12664        assert_eq!(md.source, "pa\n\n---\n\nra\n");
12665
12666        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12667        dj.caret = 2;
12668        dj.insert_thematic_break();
12669        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
12670    }
12671
12672    #[test]
12673    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
12674        // The table goes *at* the caret the way the rule does: the paragraph is
12675        // parted first, and twig writes the grid after its first half. The
12676        // caret then sits in the first header cell — selected, as Tab would
12677        // leave it — so the next keystroke is the heading.
12678        let mut d = doc_with("table_mid", "before after\n");
12679        d.caret = 7;
12680        d.insert_table(2, 3);
12681        assert_eq!(
12682            d.source,
12683            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
12684        );
12685        assert!(d.caret_in_table());
12686        let first_bar = d.source.find('|').unwrap();
12687        assert!(
12688            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
12689            "caret {} is not in the header row",
12690            d.caret
12691        );
12692        d.insert("Name");
12693        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
12694        // And the grid the table was written into is one the table keys walk
12695        // (over the map a frontend rebuilds after every edit).
12696        d.build_visual(80);
12697        assert!(d.cell_tab(true));
12698        d.insert("Qty");
12699        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
12700    }
12701
12702    #[test]
12703    fn insert_table_spells_the_grid_the_format_s_own_way() {
12704        // Djot's delimiter row is unpadded, and leaf never has to know that.
12705        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12706        dj.caret = 2;
12707        dj.insert_table(1, 2);
12708        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
12709        assert!(dj.caret_in_table());
12710    }
12711
12712    #[test]
12713    fn insert_table_refuses_where_the_format_spells_no_table() {
12714        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
12715        d.caret = 4;
12716        d.insert_table(1, 1);
12717        assert_eq!(d.source, "<p>ab</p>\n");
12718        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
12719        assert!(!d.capabilities().table);
12720    }
12721
12722    #[test]
12723    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
12724        let mut d = doc_with("table_zero", "para\n");
12725        d.caret = 2;
12726        d.insert_table(0, 2);
12727        assert_eq!(d.source, "para\n");
12728        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
12729    }
12730
12731    #[test]
12732    fn clicking_below_a_final_thematic_break_can_type_after_it() {
12733        let mut d = wysiwyg_doc("hr_final_click", "---\n");
12734        d.build_visual(80);
12735        d.click(d.vmap.num_rows() + 2, 0, false);
12736        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
12737        d.insert("after");
12738        assert_eq!(d.source, "---\nafter");
12739    }
12740
12741    #[test]
12742    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
12743        // The same bytes are two documents. In Markdown `  - b` is a nested item
12744        // and the next one belongs beside it, at its indent. In Djot a list
12745        // marker can't interrupt a paragraph, so those bytes are literal text in
12746        // item `a` and there is only one item — writing `  - ` under it would add
12747        // no item at all, just more text, and the new sibling has to go to
12748        // column zero. Both spellings come out of the *enclosing item's* line.
12749        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
12750        md.caret = "- a\n  - b".len();
12751        md.newline();
12752        assert_eq!(md.source, "- a\n  - b\n  - \n");
12753        assert_eq!(list_items(&mut md), 3);
12754
12755        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
12756        dj.view = View::Wysiwyg;
12757        dj.build_visual(80);
12758        dj.caret = "- a\n  - b".len();
12759        dj.newline();
12760        assert_eq!(dj.source, "- a\n  - b\n- \n");
12761        assert_eq!(list_items(&mut dj), 2);
12762
12763        // Where Djot's nesting is real — opened by a blank line — the indent is
12764        // reproduced there too, and the two formats agree again.
12765        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
12766        dj.view = View::Wysiwyg;
12767        dj.build_visual(80);
12768        dj.caret = "- a\n\n  - b".len();
12769        dj.newline();
12770        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
12771        assert_eq!(list_items(&mut dj), 3);
12772    }
12773
12774    #[test]
12775    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
12776        // Tab replaces the line's whole prefix with the one twig spells, so the
12777        // quote markers, the parent's indent and an ordered marker's extra
12778        // column are all its answer rather than leaf's arithmetic.
12779        for (name, body, caret, want) in [
12780            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
12781            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
12782            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
12783            // A checkbox is markup the item's own text wraps past, but a nested
12784            // list may only open at the *list* marker's column — four in from
12785            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
12786            // parses as one item, not two.
12787            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
12788            (
12789                "quoted task",
12790                "> - [ ] a\n> - [ ] b\n",
12791                18,
12792                "> - [ ] a\n>   - [ ] b\n",
12793            ),
12794        ] {
12795            let mut doc = wysiwyg_doc(name, body);
12796            doc.caret = caret;
12797            doc.indent();
12798            assert_eq!(doc.source, want, "{name}");
12799            // The nesting is real, not just indented text.
12800            assert_eq!(list_items(&mut doc), 2, "{name}");
12801        }
12802    }
12803
12804    #[test]
12805    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
12806        // The same bytes, the two formats disagreeing, and a gesture that used
12807        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
12808        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
12809        // so those bytes are literal text inside item `a` — there is nothing to
12810        // outdent, and treating them as a marker turned one item into two, a
12811        // structural edit from a keystroke that should delete one character.
12812        //
12813        // twig's `line_prefix` is what tells them apart: it reports the marker
12814        // on the Markdown line and nothing on the Djot one, which is a
12815        // continuation. No byte scan can reach that answer.
12816        let src = "- a\n  - b\n";
12817        let at = "- a\n  - ".len();
12818
12819        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
12820        md.view = View::Wysiwyg;
12821        md.build_visual(80);
12822        md.caret = at;
12823        md.backspace();
12824        assert_eq!(md.source, "- a\n- b\n");
12825        assert_eq!(list_items(&mut md), 2);
12826
12827        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
12828        dj.view = View::Wysiwyg;
12829        dj.build_visual(80);
12830        dj.caret = at;
12831        dj.backspace();
12832        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
12833        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
12834    }
12835
12836    #[test]
12837    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
12838        // Leaf used to spell the next item from the marker bytes it scanned, and
12839        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
12840        // and dropped out of the checklist. twig reproduces the whole
12841        // continuation, and a fresh item is always unticked however the one above
12842        // it stands.
12843        for (name, body, want) in [
12844            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
12845            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
12846        ] {
12847            let mut doc = wysiwyg_doc(name, body);
12848            doc.caret = body.trim_end_matches('\n').len();
12849            doc.newline();
12850            assert_eq!(doc.source, want, "{name}");
12851            // Both items are checklist items — the new one is a box, not the
12852            // plain bullet the old marker scan left behind — and it is unticked
12853            // whichever way the one above it faces.
12854            let boxes: Vec<Option<bool>> = doc
12855                .nodes()
12856                .iter()
12857                .filter(|n| n.kind == Kind::TaskListItem)
12858                .map(|n| n.checked)
12859                .collect();
12860            assert_eq!(boxes.len(), 2, "{name}");
12861            assert_eq!(boxes[1], Some(false), "{name}");
12862        }
12863    }
12864
12865    #[test]
12866    fn a_split_takes_the_space_the_caret_was_in_front_of() {
12867        // Splicing a break at the caret strands the space the words were parted
12868        // at on the head of the second block, where it reads as an indent nobody
12869        // typed. twig's split consumes it.
12870        for (name, body, caret, want) in [
12871            ("para", "one two\n", 3, "one\n\ntwo\n"),
12872            ("item", "- one two\n", 5, "- one\n- two\n"),
12873            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
12874            // A heading takes leaf's own path, which has to match.
12875            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
12876        ] {
12877            let mut doc = wysiwyg_doc(name, body);
12878            doc.caret = caret;
12879            doc.newline();
12880            assert_eq!(doc.source, want, "{name}");
12881        }
12882    }
12883
12884    #[test]
12885    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
12886        // The one place leaf keeps its own break: `split_block` repeats the `#`,
12887        // and Enter after a title is how the body under it is asked for.
12888        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
12889        doc.caret = "# Title".len();
12890        doc.newline();
12891        doc.insert("body");
12892        assert_eq!(doc.source, "# Title\n\nbody\n");
12893        assert_eq!(
12894            doc.nodes()
12895                .iter()
12896                .filter(|n| n.kind == Kind::Heading)
12897                .count(),
12898            1
12899        );
12900    }
12901
12902    #[test]
12903    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
12904        // A quoted item's marker doesn't open its line, so a scan that starts at
12905        // column zero finds a `>` where it wanted a bullet, calls the line "not a
12906        // list" and hands Enter to the plain-quote branch — which writes `> ` and
12907        // drops the list. The next item has to carry the whole prefix.
12908        for (name, body, want) in [
12909            ("flat", "> - a\n", "> - a\n> - \n"),
12910            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
12911            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
12912            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
12913            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
12914        ] {
12915            let mut doc = wysiwyg_doc(name, body);
12916            doc.caret = body.trim_end_matches('\n').len();
12917            doc.newline();
12918            assert_eq!(doc.source, want, "{name}");
12919            // The marker isn't just spelled right, it parses as an item.
12920            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
12921        }
12922    }
12923
12924    #[test]
12925    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
12926        // Double-Enter exits the list. Unquoted that means a blank line, but a
12927        // *bare* blank line would end the quote too and drop the caret out of it,
12928        // so the separator keeps its `>` and the caret's line keeps its `> `.
12929        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
12930        doc.caret = "> - a\n> - ".len();
12931        doc.newline();
12932        assert_eq!(doc.source, "> - a\n>\n> \n");
12933        assert_eq!(list_items(&mut doc), 1);
12934        // What "still in the quote" means for the next keystroke: the caret sits
12935        // behind the prefix, and what's typed there lands inside the quote as a
12936        // paragraph of its own — not as more of item `a`.
12937        doc.insert("x");
12938        assert_eq!(doc.source, "> - a\n>\n> x\n");
12939        assert!(
12940            doc.editor
12941                .ancestors_at(doc.caret - 1)
12942                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
12943        );
12944    }
12945
12946    #[test]
12947    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
12948        // The marker is hidden block markup, so Backspace over it is structural —
12949        // but only the marker is the list's. Splicing from the line start would
12950        // take the `>` with it and silently unquote the line.
12951        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
12952        doc.caret = "> - ".len();
12953        doc.backspace();
12954        assert_eq!(doc.source, "> a\n");
12955        assert_eq!(list_items(&mut doc), 0);
12956
12957        // A nested one outdents instead, moving the bullet within the quote
12958        // rather than moving the quote.
12959        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
12960        doc.caret = "> - a\n>   - ".len();
12961        doc.backspace();
12962        assert_eq!(doc.source, "> - a\n> - b\n");
12963        assert_eq!(list_items(&mut doc), 2);
12964    }
12965
12966    #[test]
12967    fn only_a_bare_paragraph_is_parted_around_the_caret() {
12968        // The split is deliberately narrow. Parting a fenced block would leave
12969        // two fences with a rule between them, and parting a list item would
12970        // mint an item nobody asked for on the way to a rule that lands after
12971        // the list either way — so both keep the whole block intact and take the
12972        // rule after it. A caret in a quote is likewise left alone.
12973        for (name, body, caret, want) in [
12974            (
12975                "code",
12976                "```\nfn x() {}\n```\n",
12977                8,
12978                "```\nfn x() {}\n```\n\n---\n\n",
12979            ),
12980            ("list", "- one two\n", 6, "- one two\n\n---\n\n"),
12981            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n>\n> \n"),
12982        ] {
12983            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
12984            d.caret = caret;
12985            d.insert_thematic_break();
12986            assert_eq!(d.source, want, "{name}: the block should stay whole");
12987        }
12988    }
12989
12990    #[test]
12991    fn insert_thematic_break_replaces_the_selection() {
12992        // Now that the rule lands *at* the caret again, replacing the selection
12993        // is coherent once more: the text goes, and the rule takes its place.
12994        // The space the deletion left leading the second half is consumed by the
12995        // split rather than opening the new paragraph with it.
12996        let mut d = doc_with("hr_sel", "one two three\n");
12997        d.anchor = Some(4);
12998        d.caret = 7; // "two"
12999        d.insert_thematic_break();
13000        assert_eq!(d.source, "one \n\n---\n\nthree\n");
13001        assert_eq!(d.selection(), None);
13002    }
13003
13004    #[test]
13005    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
13006        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
13007        // because writing `---` into one is code, not a rule — twig now walks out
13008        // to the block that owns the caret's line, so there is nothing to refuse.
13009        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
13010        code.caret = 5; // inside the fenced code
13011        code.insert_thematic_break();
13012        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n\n");
13013        assert_eq!(code.status, None, "no refusal to report any more");
13014
13015        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
13016        table.caret = 3; // in the header row
13017        table.insert_thematic_break();
13018        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\n");
13019    }
13020
13021    #[test]
13022    fn insert_thematic_break_in_a_list_item_ends_the_list() {
13023        // The un-indented rule cannot continue the list, so it closes the list
13024        // and lands at the top level rather than nested inside it.
13025        let mut d = doc_with("hr_list", "- one\n- two\n");
13026        d.caret = "- one\n- tw".len(); // mid "two"
13027        d.insert_thematic_break();
13028        d.build_visual(80);
13029        let rule_at = d.source.find("---").unwrap();
13030        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
13031        assert!(
13032            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
13033                && n.span.start <= rule_at
13034                && rule_at < n.span.end),
13035            "the rule must not be nested inside the list"
13036        );
13037    }
13038
13039    #[test]
13040    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
13041        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
13042        // which is the document the gesture was actually asked for — and the
13043        // line the caret goes on to under it wears the prefix too, as the line
13044        // Enter opens in a quote does.
13045        let mut d = doc_with("hr_quote", "> hello\n");
13046        d.caret = 4; // inside the quoted text
13047        d.insert_thematic_break();
13048        assert_eq!(d.source, "> hello\n>\n> ---\n>\n> \n");
13049        assert_eq!(d.caret, "> hello\n>\n> ---\n>\n> ".len());
13050        d.build_visual(80);
13051        let rule_at = d.source.find("---").unwrap();
13052        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
13053        assert!(
13054            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
13055                && n.span.start <= rule_at
13056                && rule_at < n.span.end),
13057            "the rule belongs to the quote it was asked for"
13058        );
13059    }
13060
13061    // ── typing against a block picture ────────────────────────────────────────
13062
13063    /// A rendered-view document with the caret parked on one of the picture's two
13064    /// stops, and the map already built — the state a frontend is in between
13065    /// drawing a frame and the next keystroke.
13066    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
13067        let mut d = doc_in(View::Wysiwyg, name, src);
13068        d.build_visual_unwrapped();
13069        let start = src.find("![").unwrap();
13070        d.caret = match side {
13071            MediaStop::Before => start,
13072            MediaStop::After => start + "![](p.png)".len(),
13073        };
13074        d
13075    }
13076
13077    /// The block media the map publishes, after rebuilding it — "is this still a
13078    /// picture, or has it become a line of text with an image in it?"
13079    fn media_count(d: &mut Doc) -> usize {
13080        d.build_visual_unwrapped();
13081        d.vmap.media.len()
13082    }
13083
13084    #[test]
13085    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
13086        // The accident this prevents: tap the blank page under a photo (which
13087        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
13088        // paragraph with an *inline* image — the photo stops being drawn.
13089        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
13090        d.insert("xy");
13091        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
13092        assert_eq!(media_count(&mut d), 1, "still a picture");
13093    }
13094
13095    #[test]
13096    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
13097        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
13098        d.insert("xy");
13099        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
13100        assert_eq!(media_count(&mut d), 1);
13101    }
13102
13103    #[test]
13104    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
13105        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
13106        d.insert("x");
13107        assert_eq!(d.source, "x\n\n![](p.png)\n");
13108        assert_eq!(media_count(&mut d), 1);
13109    }
13110
13111    #[test]
13112    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
13113        // The opened paragraph is part of the keystroke, not an edit the writer
13114        // made — so it undoes with the character, not a step later.
13115        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
13116        d.insert("x");
13117        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
13118        d.undo();
13119        assert_eq!(d.source, "hi\n\n![](p.png)\n");
13120    }
13121
13122    #[test]
13123    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
13124        // ⌘V dissolves the picture exactly as a keystroke does.
13125        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
13126        d.paste("pasted");
13127        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
13128        assert_eq!(media_count(&mut d), 1);
13129    }
13130
13131    #[test]
13132    fn typing_beside_an_inline_image_is_ordinary_editing() {
13133        // An inline image has no placeholder row and no stops of its own. Opening
13134        // a paragraph mid-sentence would be the bug, not the fix.
13135        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
13136        d.build_visual_unwrapped();
13137        d.caret = "see ![](p.png)".len();
13138        d.insert("!");
13139        assert_eq!(d.source, "see ![](p.png)! here\n");
13140    }
13141
13142    #[test]
13143    fn source_view_types_raw_markup_against_an_image_untouched() {
13144        // Source view is for writing the markup itself; a break inserted behind
13145        // the writer's back there would be the editor arguing with them.
13146        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
13147        d.caret = "![](p.png)".len();
13148        d.insert("x");
13149        assert_eq!(d.source, "![](p.png)x\n");
13150    }
13151
13152    #[test]
13153    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
13154        // A selection is replaced, not joined into, so there is nothing to
13155        // protect: the range takes the picture with it.
13156        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
13157        d.anchor = Some(d.caret);
13158        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
13159        d.insert("x");
13160        assert_eq!(d.source, "hi\n\nx\n");
13161    }
13162
13163    #[test]
13164    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
13165        // What this actually cost: a real vault's photo, to one stray Backspace.
13166        // The caret past `![](p.png)` was deleting the closing paren — invisible
13167        // in the rendered view — and the photo became the text `![](p.png`.
13168        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
13169        d.backspace();
13170        assert_eq!(d.source, "hi\n");
13171        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
13172        d.undo();
13173        assert_eq!(
13174            d.source, "hi\n\n![](p.png)\n",
13175            "and comes back in one piece"
13176        );
13177    }
13178
13179    #[test]
13180    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
13181        // Deleting the break here would join the picture to the paragraph above,
13182        // where it is an *inline* image and stops being drawn. Step over the
13183        // boundary; the next press deletes in the paragraph the caret reached.
13184        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
13185        d.backspace();
13186        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
13187        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
13188        d.backspace();
13189        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
13190        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
13191    }
13192
13193    #[test]
13194    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
13195        // The mirror. A byte-step here eats the `!` and leaves a link.
13196        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
13197        d.delete_forward();
13198        assert_eq!(d.source, "hi\n\nbye\n");
13199        assert_eq!(media_count(&mut d), 0);
13200    }
13201
13202    #[test]
13203    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
13204        let mut d = doc_at_picture(
13205            "pic_del_after",
13206            "hi\n\n![](p.png)\n\nbye\n",
13207            MediaStop::After,
13208        );
13209        d.delete_forward();
13210        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
13211        assert_eq!(
13212            d.caret,
13213            d.source.find("bye").unwrap(),
13214            "the caret stepped down to `bye`"
13215        );
13216    }
13217
13218    #[test]
13219    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
13220        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
13221        d.backspace();
13222        assert_eq!(d.source, "\n");
13223        assert_eq!(media_count(&mut d), 0);
13224    }
13225
13226    #[test]
13227    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
13228        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
13229        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
13230        d.delete_word_back();
13231        assert_eq!(d.source, "hi there\n");
13232
13233        // And in front of one it runs *through* the paragraph break into the
13234        // prose above, which merges the picture inline — so it steps out first,
13235        // and the second press deletes the word it was aimed at.
13236        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
13237        d.delete_word_back();
13238        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
13239        d.delete_word_back();
13240        assert_eq!(
13241            d.source, "hi \n\n![](p.png)\n",
13242            "the word above went, the picture stayed"
13243        );
13244        assert_eq!(media_count(&mut d), 1);
13245    }
13246
13247    #[test]
13248    fn source_view_deletes_raw_markup_against_an_image_untouched() {
13249        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
13250        d.caret = "![](p.png)".len();
13251        d.backspace();
13252        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
13253    }
13254
13255    // ── typing against a block leaf directive ─────────────────────────────────
13256
13257    /// A rendered-view `fmt` document with the caret on one of the stops of
13258    /// the directive spelled `mark`, and the map built — [`doc_at_picture`]
13259    /// for a directive, in either format.
13260    fn doc_at_directive(src: &str, fmt: Format, mark: &str, side: MediaStop) -> Doc {
13261        let mut d = Doc::from_source(src.into(), fmt).unwrap();
13262        d.view = View::Wysiwyg;
13263        d.build_visual_unwrapped();
13264        let start = src.find(mark).unwrap();
13265        d.caret = match side {
13266            MediaStop::Before => start,
13267            MediaStop::After => start + mark.len(),
13268        };
13269        assert!(d.vmap.is_stop(d.caret), "{src:?} {side:?}");
13270        d
13271    }
13272
13273    /// The leaf directives the map publishes, by name, after rebuilding it —
13274    /// "is this still a directive, or has it become a paragraph of source?"
13275    fn directive_names(d: &mut Doc) -> Vec<String> {
13276        d.build_visual_unwrapped();
13277        d.vmap.directives.iter().map(|i| i.name.clone()).collect()
13278    }
13279
13280    /// Markdown's `::embed{…}` and djot's empty fence, each with the source
13281    /// typing at its before and after stops should leave.
13282    const DIRECTIVE_CASES: [(Format, &str, &str, &str, &str); 3] = [
13283        (
13284            Format::Markdown,
13285            "hi\n\n::embed{src=x}\n",
13286            "::embed{src=x}",
13287            "hi\n\nB\n\n::embed{src=x}\n",
13288            "hi\n\n::embed{src=x}\n\nB\n",
13289        ),
13290        (
13291            Format::Markdown,
13292            "hi\n\n::page-break\n\nbye\n",
13293            "::page-break",
13294            "hi\n\nB\n\n::page-break\n\nbye\n",
13295            "hi\n\n::page-break\n\nB\n\nbye\n",
13296        ),
13297        (
13298            Format::Djot,
13299            "hi\n\n::: page-break\n:::\n\nbye\n",
13300            "::: page-break\n:::",
13301            "hi\n\nB\n\n::: page-break\n:::\n\nbye\n",
13302            "hi\n\n::: page-break\n:::\n\nB\n\nbye\n",
13303        ),
13304    ];
13305
13306    #[test]
13307    fn typing_past_a_leaf_directive_opens_a_paragraph_under_it() {
13308        // The task's accident: `::page[…]{…}TWO` is a paragraph of raw source.
13309        for (fmt, src, mark, _, after) in DIRECTIVE_CASES {
13310            let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13311            let names = directive_names(&mut d);
13312            d.insert("B");
13313            assert_eq!(d.source, after, "{fmt:?}");
13314            assert_eq!(directive_names(&mut d), names, "{fmt:?}: still a directive");
13315        }
13316    }
13317
13318    #[test]
13319    fn typing_in_front_of_a_leaf_directive_opens_a_paragraph_above_it() {
13320        // Found in leaf-tui: `B` typed here made `B::embed{src=…}`.
13321        for (fmt, src, mark, before, _) in DIRECTIVE_CASES {
13322            let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13323            let names = directive_names(&mut d);
13324            d.insert("B");
13325            assert_eq!(d.source, before, "{fmt:?}");
13326            assert_eq!(directive_names(&mut d), names, "{fmt:?}: still a directive");
13327        }
13328        // And one that opens the document takes a paragraph above it too.
13329        let mut d = doc_at_directive(
13330            "::embed{src=x}\n",
13331            Format::Markdown,
13332            "::",
13333            MediaStop::Before,
13334        );
13335        d.insert("B");
13336        assert_eq!(d.source, "B\n\n::embed{src=x}\n");
13337        assert_eq!(directive_names(&mut d), ["embed"]);
13338    }
13339
13340    #[test]
13341    fn two_directives_in_a_row_take_a_paragraph_between_them_from_either_side() {
13342        // The task's document: no paragraph between the marks to click into, so
13343        // the end of the first mark's row, or the front of the second's, is the
13344        // only way in.
13345        let md = "::page[Page 1 of 2]{card=\"a\"}\n\n::page[Page 2 of 2]{card=\"b\"}\n";
13346        let md_want = "::page[Page 1 of 2]{card=\"a\"}\n\nTWO\n\n::page[Page 2 of 2]{card=\"b\"}\n";
13347        let dj = "::: page-break\n:::\n\n::: x-card\n:::\n";
13348        let dj_want = "::: page-break\n:::\n\nTWO\n\n::: x-card\n:::\n";
13349        for (fmt, src, first, second, want) in [
13350            (
13351                Format::Markdown,
13352                md,
13353                "::page[Page 1 of 2]{card=\"a\"}",
13354                "::page[Page 2",
13355                md_want,
13356            ),
13357            (
13358                Format::Djot,
13359                dj,
13360                "::: page-break\n:::",
13361                "::: x-card",
13362                dj_want,
13363            ),
13364        ] {
13365            for (mark, side) in [(first, MediaStop::After), (second, MediaStop::Before)] {
13366                let mut d = doc_at_directive(src, fmt, mark, side);
13367                let names = directive_names(&mut d);
13368                assert_eq!(names.len(), 2);
13369                d.insert("T");
13370                d.insert("W");
13371                d.insert("O");
13372                assert_eq!(d.source, want, "{fmt:?} {side:?}");
13373                assert_eq!(directive_names(&mut d), names, "{fmt:?} {side:?}");
13374                let labels: Vec<_> = d.vmap.directives.iter().map(|i| i.label.clone()).collect();
13375                if fmt == Format::Markdown {
13376                    assert_eq!(labels, ["Page 1 of 2", "Page 2 of 2"]);
13377                    assert_eq!(d.vmap.directives[1].attr("card"), Some("b"));
13378                }
13379            }
13380        }
13381    }
13382
13383    #[test]
13384    fn a_djot_directive_s_attribute_line_stays_with_it() {
13385        // The `{…}` line above the fence is the directive's markup: a paragraph
13386        // opened between the two would take the attributes, and a delete that
13387        // left the line would hand them to the paragraph below.
13388        let src = "hi\n\n{.wide src=\"x\"}\n::: x-card\n:::\n\nbye\n";
13389        let mark = "::: x-card\n:::";
13390        let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13391        d.insert("B");
13392        assert_eq!(
13393            d.source,
13394            "hi\n\nB\n\n{.wide src=\"x\"}\n::: x-card\n:::\n\nbye\n"
13395        );
13396        assert_eq!(directive_names(&mut d), ["x-card"]);
13397        assert_eq!(d.vmap.directives[0].attr("src"), Some("x"));
13398
13399        let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::After);
13400        d.backspace();
13401        assert_eq!(
13402            d.source, "hi\n\nbye\n",
13403            "the directive went with its attributes"
13404        );
13405        let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13406        d.delete_forward();
13407        assert_eq!(d.source, "hi\n\nbye\n");
13408
13409        // A djot block picture's attribute line is the same markup, kept the
13410        // same way.
13411        let pic = "hi\n\n{.wide}\n![](p.png)\n";
13412        let mut d = doc_at_directive(pic, Format::Djot, "![](p.png)", MediaStop::Before);
13413        d.insert("B");
13414        assert_eq!(d.source, "hi\n\nB\n\n{.wide}\n![](p.png)\n");
13415        assert_eq!(media_count(&mut d), 1);
13416        let mut d = doc_at_directive(pic, Format::Djot, "![](p.png)", MediaStop::After);
13417        d.backspace();
13418        assert_eq!(d.source, "hi\n");
13419
13420        // Where the attribute line opens the document, too.
13421        let src = "{.wide}\n::: x-card\n:::\n";
13422        let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13423        d.insert("B");
13424        assert_eq!(d.source, "B\n\n{.wide}\n::: x-card\n:::\n");
13425        assert_eq!(directive_names(&mut d), ["x-card"]);
13426    }
13427
13428    #[test]
13429    fn a_directive_drawn_taller_still_takes_a_paragraph_under_it() {
13430        // Reserved filler rows are decoration: the after-stop is still the
13431        // directive's end, and a click on the drawing's lower part lands there.
13432        let src = "hi\n\n::embed{src=x}\n";
13433        let mut d = doc_at_directive(src, Format::Markdown, "::embed{src=x}", MediaStop::After);
13434        let key = d.vmap.directives[0].key();
13435        d.set_directive_rows(HashMap::from([(key, 4)]));
13436        d.build_visual_unwrapped();
13437        assert_eq!(d.vmap.directives[0].rows_span.len(), 4);
13438        d.insert("B");
13439        assert_eq!(d.source, "hi\n\n::embed{src=x}\n\nB\n");
13440    }
13441
13442    #[test]
13443    fn pasting_against_a_leaf_directive_opens_a_paragraph_too() {
13444        for (fmt, src, mark, before, after) in DIRECTIVE_CASES {
13445            for (side, want) in [(MediaStop::Before, before), (MediaStop::After, after)] {
13446                let mut d = doc_at_directive(src, fmt, mark, side);
13447                d.paste("pasted");
13448                assert_eq!(d.source, want.replace("B", "pasted"), "{fmt:?} {side:?}");
13449            }
13450        }
13451    }
13452
13453    #[test]
13454    fn one_undo_puts_a_directive_back_the_way_it_was_found() {
13455        for (fmt, src, mark, ..) in DIRECTIVE_CASES {
13456            for side in [MediaStop::Before, MediaStop::After] {
13457                let mut d = doc_at_directive(src, fmt, mark, side);
13458                d.insert("x");
13459                d.insert("y");
13460                assert_ne!(d.source, src);
13461                d.undo();
13462                assert_eq!(d.source, src, "{fmt:?} {side:?}");
13463            }
13464        }
13465    }
13466
13467    #[test]
13468    fn delete_keys_take_a_leaf_directive_whole_or_step_out_of_it() {
13469        // The picture's rule: the key aimed at the directive takes it whole, the
13470        // key aimed away steps over the boundary — never a byte of its markup.
13471        for (fmt, src, mark, ..) in DIRECTIVE_CASES {
13472            let gone = src
13473                .replace(&format!("{mark}\n\n"), "")
13474                .replace(&format!("\n\n{mark}"), "");
13475            let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13476            d.backspace();
13477            assert_eq!(d.source, gone, "{fmt:?}: Backspace past it");
13478            d.undo();
13479            assert_eq!(d.source, src, "{fmt:?}: and back in one piece");
13480
13481            let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13482            d.delete_forward();
13483            assert_eq!(d.source, gone, "{fmt:?}: Delete in front of it");
13484
13485            let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13486            d.backspace();
13487            assert_eq!(d.source, src, "{fmt:?}: Backspace in front deletes nothing");
13488            assert_eq!(d.caret, 2, "{fmt:?}: it steps up to the end of `hi`");
13489
13490            let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13491            let at = d.caret;
13492            d.delete_forward();
13493            assert_eq!(d.source, src, "{fmt:?}: Delete past it deletes nothing");
13494            assert!(
13495                d.caret > at || src.ends_with(&format!("{mark}\n")),
13496                "{fmt:?}"
13497            );
13498        }
13499    }
13500
13501    #[test]
13502    fn source_view_types_raw_markup_against_a_directive_untouched() {
13503        let mut d = doc_in(View::Source, "dir_src", "::embed{src=x}\n");
13504        d.caret = "::embed{src=x}".len();
13505        d.insert("x");
13506        assert_eq!(d.source, "::embed{src=x}x\n");
13507    }
13508
13509    #[test]
13510    fn image_destination_at_caret_reads_the_image_under_the_caret() {
13511        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
13512        d.caret = 3; // inside the image markup
13513        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
13514        // Past the image, the caret is in no image.
13515        d.caret = "![a cat](cat.png)".len();
13516        assert_eq!(d.image_destination_at_caret(), None);
13517    }
13518
13519    #[test]
13520    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
13521        // The image is one placeholder row by default, and `set_media_rows` grows
13522        // it to the height the frontend measured: the label row plus blank
13523        // `decoration` fillers that hold the vertical space a raster is drawn into.
13524        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
13525        assert_eq!(d.vmap.media.len(), 1);
13526        let img_row = d.vmap.media[0].rows_span.start;
13527        assert_eq!(
13528            d.vmap.media[0].rows_span,
13529            img_row..img_row + 1,
13530            "default is one row"
13531        );
13532
13533        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
13534        d.build_visual(80);
13535        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
13536        let span = d.vmap.media[0].rows_span.clone();
13537        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
13538        // The label row carries the mark and its glyphs; the three below are blank
13539        // decoration — drawn, but no caret and no text.
13540        assert!(
13541            d.vmap.rows[span.start].media.is_some(),
13542            "mark rides the first row"
13543        );
13544        for r in (span.start + 1)..span.end {
13545            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
13546            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
13547            assert!(
13548                d.vmap.rows[r].media.is_none(),
13549                "only the first row is marked"
13550            );
13551        }
13552    }
13553
13554    #[test]
13555    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
13556        // The extra rows are pure spacers: the caret's only homes stay the stop in
13557        // front of the image and the one just past it, so walking the document top
13558        // to bottom visits the same offsets whether the image is 1 row or 5.
13559        let body = "ab\n\n![x](p.png)\n\ncd\n";
13560        let stops_at = |rows: usize| -> Vec<usize> {
13561            let mut d = wysiwyg_doc("img_stops", body);
13562            if rows > 1 {
13563                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
13564                d.build_visual(80);
13565            }
13566            d.caret = 0;
13567            let mut seen = vec![d.caret];
13568            loop {
13569                d.move_right(false);
13570                if *seen.last().unwrap() == d.caret {
13571                    break;
13572                }
13573                seen.push(d.caret);
13574            }
13575            seen
13576        };
13577        assert_eq!(
13578            stops_at(1),
13579            stops_at(5),
13580            "reserving rows must not add stops"
13581        );
13582    }
13583
13584    #[test]
13585    fn insert_link_repoints_the_link_at_a_bare_caret() {
13586        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
13587        d.caret = 3; // in the link's text, nothing selected
13588        d.insert_link("http://y.dev");
13589        assert_eq!(d.source, "[word](http://y.dev)\n");
13590        assert_eq!(d.selected_text(), Some("word"));
13591    }
13592
13593    #[test]
13594    fn insert_link_on_an_empty_range_autolinks_a_url() {
13595        // A link with no text of its own is an autolink, and twig spells it —
13596        // `<…>` is the canonical form and needs no text typed into it, so the
13597        // caret lands after it rather than selecting a finished link.
13598        let mut d = doc_with("link_empty", "\n");
13599        d.caret = 0;
13600        d.insert_link("http://x.dev");
13601        assert_eq!(d.source, "<http://x.dev>\n");
13602        assert_eq!(d.selection(), None);
13603        assert_eq!(d.caret, 14);
13604    }
13605
13606    #[test]
13607    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
13608        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
13609        // in Markdown, so a destination that can't autolink doubles as the text
13610        // instead — which is then selected, ready to be typed over.
13611        let mut d = doc_with("link_rel", "\n");
13612        d.caret = 0;
13613        d.insert_link("./notes.md");
13614        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
13615        assert_eq!(d.selection(), Some((1, 11)));
13616        d.insert("Notes");
13617        assert_eq!(d.source, "[Notes](./notes.md)\n");
13618    }
13619
13620    #[test]
13621    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
13622        // The autolink's text is its URL, so re-pointing replaces the whole
13623        // node — the caret must not splice a second link inside the first.
13624        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
13625        d.caret = 10;
13626        d.insert_link("https://y.dev");
13627        assert_eq!(d.source, "see <https://y.dev> ok\n");
13628    }
13629
13630    #[test]
13631    fn code_language_reads_and_edits_through_the_fence() {
13632        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
13633        d.caret = 10; // inside the code body
13634        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13635        assert!(d.caret_in_fenced_code());
13636
13637        d.set_code_language("python");
13638        assert!(
13639            d.source.starts_with("```python\n"),
13640            "source: {:?}",
13641            d.source
13642        );
13643        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
13644
13645        // Clearing it leaves a bare fence and no label.
13646        d.set_code_language("");
13647        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
13648        assert_eq!(d.code_language_at_caret(), None);
13649
13650        // A caret outside any code block edits nothing.
13651        let mut p = doc_with("code_lang_none", "just prose\n");
13652        assert!(!p.caret_in_fenced_code());
13653        p.set_code_language("rust");
13654        assert_eq!(p.source, "just prose\n");
13655    }
13656
13657    #[test]
13658    fn code_language_reads_and_edits_through_a_quoted_fence() {
13659        let mut d = doc_with("code_lang_quote", "> ```rust\n> let x = 1;\n> ```\n");
13660        d.caret = d.source.find("let").unwrap();
13661        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13662        assert!(d.caret_in_fenced_code());
13663        d.set_code_language("python");
13664        assert!(
13665            d.source.starts_with("> ```python\n"),
13666            "source: {:?}",
13667            d.source
13668        );
13669        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
13670    }
13671
13672    #[test]
13673    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
13674        // Markdown's info string ends at whitespace, so `two words` would write
13675        // a fence that reads back with a different language than the one asked
13676        // for. twig refuses it; leaf reports that and leaves the source alone.
13677        // The old splice trimmed the ends and wrote whatever was left.
13678        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
13679        d.caret = 10;
13680        d.set_code_language("two words");
13681        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
13682        assert!(d.status.is_some(), "the refusal should be reported");
13683        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13684    }
13685
13686    #[test]
13687    fn link_destination_at_caret_reads_both_spellings() {
13688        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
13689        d.caret = 5;
13690        assert_eq!(
13691            d.link_destination_at_caret().as_deref(),
13692            Some("https://x.dev")
13693        );
13694        d.caret = 0;
13695        assert_eq!(d.link_destination_at_caret(), None);
13696
13697        // An autolink has no `destination`; its text is the URL.
13698        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
13699        a.caret = 10;
13700        assert_eq!(
13701            a.link_destination_at_caret().as_deref(),
13702            Some("https://x.dev")
13703        );
13704        a.caret = 21;
13705        assert_eq!(a.link_destination_at_caret(), None);
13706    }
13707
13708    #[test]
13709    fn heading_at_caret_is_the_nearest_heading_above() {
13710        let src = "intro\n\n# Title\n\n## The *Second* Part\n\nbody here\n";
13711        let mut d = doc_with("heading_at", src);
13712        // Under no heading at all.
13713        d.caret = 2;
13714        assert_eq!(d.heading_at_caret(), None);
13715        // In a paragraph under a level-2 heading: that heading, its markup
13716        // stripped for the slug, and its own span.
13717        d.caret = src.find("body").unwrap() + 2;
13718        let h = d.heading_at_caret().expect("under `## The Second Part`");
13719        assert_eq!(h.text, "The Second Part");
13720        assert_eq!(h.level, 2);
13721        assert_eq!(&src[h.span.clone()].trim_end(), &"## The *Second* Part");
13722        // Standing in a heading answers with that heading.
13723        let h = d.heading_at(src.find("Title").unwrap()).unwrap();
13724        assert_eq!((h.text.as_str(), h.level), ("Title", 1));
13725        // And the text is what `locate` lands a slug of.
13726        let landing = d.locate("the-second-part").unwrap();
13727        assert_eq!(landing.start, d.heading_at_caret().unwrap().span.start);
13728    }
13729
13730    #[test]
13731    fn locate_finds_the_block_a_declared_id_names() {
13732        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
13733        // verse. The locator has to land on the *verse*, which is the whole
13734        // reason a link carries one.
13735        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
13736                   {#v2}\nYea, I make a record in the language of my father.\n";
13737        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13738        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
13739        assert_eq!(
13740            d.source[v2.start..v2.end].trim_end(),
13741            "Yea, I make a record in the language of my father."
13742        );
13743        // The attribute line is not part of it: `start` is a place to put a
13744        // caret, and `{#v2}` is markup the caret has no business landing in.
13745        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
13746        assert_eq!(d.locate("v99"), None);
13747    }
13748
13749    #[test]
13750    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
13751        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
13752        // Markdown heading is literal text. So `#the-second-part` can only be
13753        // the heading's own words, which is the rule every Markdown renderer
13754        // already follows and therefore the one a link was authored against.
13755        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
13756        let mut d = doc_with("locate_md", src);
13757        let hit = d.locate("the-second-part").expect("the heading's slug");
13758        assert!(d.source[hit.start..].starts_with("## The Second Part"));
13759        // Bounded by the next heading that isn't under it, so a peek shows the
13760        // section rather than only its title.
13761        assert_eq!(
13762            &d.source[hit.start..hit.end],
13763            "## The Second Part\n\nbody\n\n"
13764        );
13765
13766        // A subsection does not end its parent: `# Title` runs to `## Third`'s
13767        // sibling only because there is no other `#`, so it covers the lot.
13768        let title = d.locate("title").expect("the top heading");
13769        assert_eq!(title.end, d.source.len());
13770    }
13771
13772    #[test]
13773    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
13774        // djot mints `Some-Heading-Here`; a link to it is written
13775        // `#some-heading-here` by nearly everything that writes links. Both
13776        // spellings are one question.
13777        let src = "## Some Heading Here\n\nbody\n";
13778        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13779        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
13780        let slugged = d.locate("some-heading-here").expect("the link's spelling");
13781        assert_eq!(exact, slugged);
13782        // The section, not the heading line — there is more to show than a title.
13783        assert_eq!(&d.source[exact.start..exact.end], src);
13784    }
13785
13786    #[test]
13787    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
13788        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
13789        assert_eq!(d.locate(""), None);
13790        assert_eq!(d.locate("   "), None);
13791        // All punctuation: it names nothing, and must not be read as "match the
13792        // first heading whose slug is also empty".
13793        assert_eq!(d.locate("!!!"), None);
13794    }
13795
13796    #[test]
13797    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
13798        // The document's mistake, and the answer every other anchor
13799        // implementation gives — the alternative is for a link to mean whichever
13800        // of the two a walk happened to reach first.
13801        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
13802        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13803        let hit = d.locate("dup").expect("the first `{#dup}`");
13804        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
13805    }
13806
13807    #[test]
13808    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
13809        // The button's whole job: a reference where the caret was, a definition
13810        // to give it meaning, and the caret waiting in the empty note so the
13811        // next keystroke is the note's first word.
13812        let mut d = doc_with("fn_insert", "A claim and more.\n");
13813        d.caret = 7; // just past "A claim"
13814        d.insert_footnote();
13815        assert!(
13816            d.source.starts_with("A claim[^1] and more."),
13817            "{:?}",
13818            d.source
13819        );
13820        assert!(
13821            d.source.contains("[^1]:"),
13822            "the definition too: {:?}",
13823            d.source
13824        );
13825        assert_eq!(d.status, None);
13826
13827        let reference = d.source.find("[^1]").unwrap();
13828        let note = d
13829            .footnote_at(reference + 2)
13830            .expect("the reference just written");
13831        assert_eq!(note.label, "1");
13832        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
13833        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
13834        // …and typing there is typing into the note, not near it.
13835        d.insert("the note");
13836        assert_eq!(
13837            d.footnote_at(reference + 2).and_then(|f| f.text),
13838            Some("the note".to_string())
13839        );
13840    }
13841
13842    #[test]
13843    fn insert_footnote_numbers_past_the_notes_already_written() {
13844        // A second press must not hand back a label somebody else is using: twig
13845        // reuses a defined label rather than appending a rival definition, so a
13846        // repeat of `1` would quietly point the new reference at the old note.
13847        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
13848        d.caret = 7; // past `[^1]`, before " two."
13849        d.insert_footnote();
13850        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
13851        assert_eq!(d.source.matches("[^2]:").count(), 1);
13852    }
13853
13854    #[test]
13855    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
13856        // `[^2]` with no definition is still a 2 that means something to whoever
13857        // wrote it — stepping over it would mint a note for their reference. A
13858        // word label takes no number, so it blocks none.
13859        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
13860        d.caret = d.source.find(" c").unwrap();
13861        d.insert_footnote();
13862        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
13863        assert!(
13864            d.source.starts_with("a[^2] b[^why][^1] c"),
13865            "{:?}",
13866            d.source
13867        );
13868    }
13869
13870    #[test]
13871    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
13872        // A reference annotates the words before it. Consuming the selection —
13873        // which is what an insert normally does — would delete the very claim
13874        // the author selected in order to footnote.
13875        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
13876        d.anchor = Some(2);
13877        d.caret = 7; // "claim" selected
13878        d.insert_footnote();
13879        assert!(
13880            d.source.starts_with("A claim[^1] and more."),
13881            "{:?}",
13882            d.source
13883        );
13884    }
13885
13886    #[test]
13887    fn a_note_just_written_still_knows_where_its_reference_is() {
13888        // The authoring loop in one test: press the button, type the note, ask to
13889        // go back. The caret ends at the note's last byte — which is the *end* of
13890        // the definition's span, the one offset the query used to exclude — so
13891        // this is where the round trip either works or doesn't.
13892        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
13893        d.caret = 7;
13894        d.insert_footnote();
13895        d.insert("the note");
13896        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
13897        let back = d
13898            .footnote_definition_at_caret()
13899            .expect("still in the note we just typed");
13900        assert_eq!(back.label, "1");
13901        // …and following it lands on the reference's label, where a reader's
13902        // return leg lands.
13903        assert_eq!(back.offset, Some(9));
13904        assert_eq!(&d.source[9..10], "1");
13905    }
13906
13907    #[test]
13908    fn insert_footnote_takes_one_undo_for_both_halves() {
13909        // twig writes the pair as a single edit; the point of that is here.
13910        let before = "A claim and more.\n";
13911        let mut d = doc_with("fn_insert_undo", before);
13912        d.caret = 7;
13913        d.insert_footnote();
13914        assert_ne!(d.source, before);
13915        d.undo();
13916        assert_eq!(d.source, before, "one undo takes back both halves");
13917    }
13918
13919    #[test]
13920    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
13921        // HTML is authorable — it spells the inline marks — and has no footnote.
13922        // The refusal says so rather than writing brackets that would render as
13923        // brackets.
13924        let src = "<p>A claim.</p>\n";
13925        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
13926        assert!(!Capabilities::of(Format::Html).footnote);
13927        d.caret = 5;
13928        d.insert_footnote();
13929        assert_eq!(d.source, src, "nothing written");
13930        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
13931    }
13932
13933    #[test]
13934    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
13935        // The empty body is the one place this could go wrong: the definition
13936        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
13937        // byte early would draw up in the paragraph above the note it belongs to.
13938        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
13939        d.place_caret(7, false);
13940        d.insert_footnote();
13941        d.build_visual(80); // the frame a frontend draws after the edit
13942        assert_eq!(
13943            d.vmap.snap_to_stop(d.caret),
13944            d.caret,
13945            "the caret sits on a stop"
13946        );
13947        let (row, _) = d.caret_pos();
13948        assert!(
13949            drawn_rows(&d)[row].contains("[1]"),
13950            "the caret is on the note's row, not above it: {:?}",
13951            drawn_rows(&d)
13952        );
13953    }
13954
13955    #[test]
13956    fn footnote_at_caret_resolves_a_reference_to_its_note() {
13957        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
13958        // blank line, as one has to.
13959        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
13960        d.caret = 9;
13961        let f = d
13962            .footnote_at_caret()
13963            .expect("the caret stands in a reference");
13964        assert_eq!(f.label, "1");
13965        assert_eq!(f.text.as_deref(), Some("the note"));
13966        // The offset points at the note's first word, not at the definition's
13967        // `[` — the marker is decoration with no caret stop on it.
13968        assert_eq!(f.offset, Some(29));
13969        assert_eq!(&d.source[29..37], "the note");
13970        // …and `end` closes the range, so a frontend can ask which rendered rows
13971        // the note occupies rather than re-deriving them from the text.
13972        assert_eq!(f.end, Some(37));
13973        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
13974    }
13975
13976    /// Two definitions in a row: each is its own note, and neither reaches into
13977    /// the other.
13978    ///
13979    /// A djot definition's span used to run past the blank line into the first
13980    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
13981    /// offsets named the *next* note's rows too, showing a reader two footnotes
13982    /// when they had asked about one. twig 3.1 ends the span after the block's
13983    /// own last line; the test outlives the workaround leaf carried for it.
13984    #[test]
13985    fn footnote_at_stops_a_note_at_the_definition_after_it() {
13986        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
13987        for format in [Format::Markdown, Format::Djot] {
13988            let mut d = Doc::from_source(src.to_string(), format).unwrap();
13989            d.caret = 7;
13990            let f = d.footnote_at_caret().expect("a reference");
13991            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
13992            assert_eq!(
13993                &src[f.offset.unwrap()..f.end.unwrap()],
13994                "first note.",
13995                "in {format:?}"
13996            );
13997        }
13998    }
13999
14000    /// The other side of that boundary: a blank line *inside* a definition is
14001    /// interior to it, and the note keeps its second paragraph.
14002    ///
14003    /// This is what the old body scan cost. It stopped at the first line not
14004    /// indented under the note — a blank line is not — so a two-paragraph note
14005    /// came back as its first paragraph, and "go to note" framed half of it.
14006    /// Reading the span twig gives is both simpler and right.
14007    #[test]
14008    fn footnote_at_keeps_a_notes_second_paragraph() {
14009        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
14010        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
14011        d.caret = 7;
14012        let f = d.footnote_at_caret().expect("a reference");
14013        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
14014        // And it stops there — `After.` is the next block, not more note.
14015        assert_eq!(
14016            &src[f.offset.unwrap()..f.end.unwrap()],
14017            f.text.as_deref().unwrap()
14018        );
14019        assert!(!f.text.as_deref().unwrap().contains("After"));
14020    }
14021
14022    #[test]
14023    fn footnote_at_bounds_a_note_whose_body_is_empty() {
14024        // `[^1]:` with nothing after it. The range is empty rather than
14025        // inverted, and still points inside the definition — which is what keeps
14026        // a frontend's row lookup from walking off into the block above.
14027        let src = "A claim[^1].\n\n[^1]:\n";
14028        let mut d = doc_with("fn_empty_body", src);
14029        d.caret = 9;
14030        let f = d.footnote_at_caret().expect("a reference");
14031        assert_eq!(f.text.as_deref(), Some(""));
14032        assert_eq!(f.offset, f.end, "an empty note is an empty range");
14033        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
14034    }
14035
14036    #[test]
14037    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
14038        let mut d = doc_with(
14039            "fn_at_caret_none",
14040            "A claim[^1] and more.\n\n[^1]: the note\n",
14041        );
14042        d.caret = 2; // in the prose
14043        assert_eq!(d.footnote_at_caret(), None);
14044    }
14045
14046    #[test]
14047    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
14048        // The two are deliberately separate: a reference names a note in this
14049        // document, a link names somewhere to leave for, and answering one with
14050        // the other is what made a reference click do nothing at all.
14051        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
14052        d.caret = 3; // the `1` of `[^1]`
14053        assert!(d.footnote_at_caret().is_some());
14054        assert_eq!(
14055            d.link_destination_at_caret(),
14056            None,
14057            "a reference is not a link"
14058        );
14059
14060        d.caret = 10; // inside the link's label
14061        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
14062        assert_eq!(
14063            d.link_destination_at_caret().as_deref(),
14064            Some("https://x.dev")
14065        );
14066    }
14067
14068    #[test]
14069    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
14070        // A `[^99]` the document never defines is a real state — a note deleted
14071        // out from under its reference — and the label is what lets a frontend
14072        // say so. `None` here would be indistinguishable from "not on a
14073        // reference", which is the wrong thing to tell a reader.
14074        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
14075        d.caret = 9;
14076        let f = d
14077            .footnote_at_caret()
14078            .expect("the reference is still a reference");
14079        assert_eq!(f.label, "99");
14080        assert_eq!(f.text, None);
14081        assert_eq!(f.offset, None);
14082    }
14083
14084    #[test]
14085    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
14086        // Labels are not always numbers, and a note's body runs past its first
14087        // line — the indented continuation belongs to the note, so it comes back
14088        // with it (source bytes, verbatim, as documented).
14089        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
14090        let mut d = doc_with("fn_word_label", src);
14091        d.caret = 6;
14092        let f = d
14093            .footnote_at_caret()
14094            .expect("the caret stands in a reference");
14095        assert_eq!(f.label, "note");
14096        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
14097    }
14098
14099    #[test]
14100    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
14101        // The point of the offset form: a pointer hovering a reference asks what
14102        // note it names, and must not drag the caret along to ask.
14103        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
14104        d.caret = 0;
14105        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
14106        assert_eq!(f.label, "1");
14107        assert_eq!(f.text.as_deref(), Some("the note"));
14108        assert_eq!(d.caret, 0, "asking must not move the caret");
14109        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
14110    }
14111
14112    #[test]
14113    fn footnote_definition_at_caret_points_back_at_the_reference() {
14114        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
14115        // the caret can rest on — is at 9.
14116        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
14117        d.caret = 30; // inside the note's body
14118        let f = d
14119            .footnote_definition_at_caret()
14120            .expect("the caret stands in a definition");
14121        assert_eq!(f.label, "1");
14122        assert_eq!(f.offset, Some(9));
14123        assert_eq!(&d.source[7..11], "[^1]");
14124    }
14125
14126    #[test]
14127    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
14128        // The two legs have to meet: wherever `footnote_at` sends the caret, the
14129        // definition query must answer for — otherwise arriving at a note leaves
14130        // the reader somewhere the way back isn't offered.
14131        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
14132        let mut d = doc_with("fn_def_marker", src);
14133        let landed = d.footnote_at(9).unwrap().offset.unwrap();
14134        assert_eq!(
14135            d.footnote_definition_at(landed).and_then(|f| f.offset),
14136            Some(9),
14137            "the note a reference sends you to offers the way back"
14138        );
14139    }
14140
14141    #[test]
14142    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
14143        // The two queries answer for disjoint places, which is what lets one
14144        // gesture mean "down to the note" in one and "back up" in the other
14145        // without either having to remember which way the reader is going.
14146        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
14147        d.caret = 2; // prose
14148        assert_eq!(d.footnote_definition_at_caret(), None);
14149        d.caret = 9; // the reference
14150        assert_eq!(d.footnote_definition_at_caret(), None);
14151        assert!(
14152            d.footnote_at_caret().is_some(),
14153            "which is the reference's own query"
14154        );
14155    }
14156
14157    #[test]
14158    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
14159        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
14160        // note", which is false and leaves a frontend unable to explain why the
14161        // way back is missing.
14162        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
14163        let mut d = doc_with("fn_def_orphan", src);
14164        d.caret = src.find("orphan").unwrap();
14165        let f = d
14166            .footnote_definition_at_caret()
14167            .expect("an orphan is still a definition");
14168        assert_eq!(f.label, "2");
14169        assert_eq!(f.offset, None);
14170    }
14171
14172    #[test]
14173    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
14174        // One label, cited twice. The first is where the reader most likely came
14175        // from, and the only answer that doesn't depend on how they got here.
14176        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
14177        let mut d = doc_with("fn_def_repeat", src);
14178        d.caret = src.find("the note").unwrap();
14179        let f = d.footnote_definition_at_caret().expect("a definition");
14180        assert_eq!(
14181            f.offset,
14182            Some(5),
14183            "the first `[^a]`'s label, not the second's"
14184        );
14185        assert_eq!(&src[3..7], "[^a]");
14186    }
14187
14188    #[test]
14189    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
14190        // Down and back up, each leg found from the document rather than from a
14191        // memory of the other — so it still works for a reader who scrolled to
14192        // the notes instead of jumping there.
14193        //
14194        // `place_caret` rather than assigning `caret`, because that is what a
14195        // frontend calls: it snaps to a real caret stop, and a jump that lands
14196        // on a byte the caret can't rest on would arrive somewhere the return
14197        // leg no longer answers for. `build_map` first, since snapping is a
14198        // no-op until the map exists — which is exactly how this went unnoticed
14199        // when the offsets pointed at the `[^` markers.
14200        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
14201        d.build_map(None);
14202        d.place_caret(9, false);
14203        let down = d
14204            .footnote_at_caret()
14205            .expect("a reference")
14206            .offset
14207            .expect("a note");
14208        d.place_caret(down, false);
14209        let up = d
14210            .footnote_definition_at_caret()
14211            .expect("a definition")
14212            .offset
14213            .expect("a reference");
14214        d.place_caret(up, false);
14215        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
14216        assert_eq!(
14217            d.footnote_at_caret().expect("back on the reference").label,
14218            "1"
14219        );
14220    }
14221
14222    #[test]
14223    fn insert_link_hands_the_destination_to_twig_raw() {
14224        // Escaping is twig's, and format-specific: Markdown ends a destination
14225        // at the first space and needs the `<…>` form, where djot would read
14226        // those angle brackets as part of the URL.
14227        let mut d = doc_with("link_space", "word\n");
14228        d.anchor = Some(0);
14229        d.caret = 4;
14230        d.insert_link("a b");
14231        assert_eq!(d.source, "[word](<a b>)\n");
14232    }
14233
14234    #[test]
14235    fn insert_link_reports_a_destination_no_format_can_carry() {
14236        let mut d = doc_with("link_bad", "word\n");
14237        d.anchor = Some(0);
14238        d.caret = 4;
14239        d.insert_link("a\nb");
14240        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
14241        assert!(
14242            d.status.is_some(),
14243            "InvalidArgument should reach the status line"
14244        );
14245        assert!(!d.dirty);
14246    }
14247
14248    #[test]
14249    fn insert_link_works_in_wysiwyg_view() {
14250        let mut d = wysiwyg_doc("link_wys", "word here\n");
14251        d.anchor = Some(0);
14252        d.caret = 4;
14253        d.insert_link("http://x.dev");
14254        assert_eq!(d.source, "[word](http://x.dev) here\n");
14255        assert_eq!(d.selected_text(), Some("word"));
14256        // The map the caret has to keep riding is rebuilt each frame; motion
14257        // over the fresh one must still land on a real stop (the debug_assert).
14258        d.build_visual(80);
14259        d.move_right(false);
14260        d.move_left(false);
14261    }
14262
14263    #[test]
14264    fn insert_link_labelled_writes_the_label_and_lands_after_the_link() {
14265        // A host that knows the title of what it links: the label is the
14266        // text, and the link is finished — caret past it, nothing selected.
14267        for fmt in [Format::Markdown, Format::Djot] {
14268            let mut d = Doc::from_source("see \n".into(), fmt).unwrap();
14269            d.caret = 4;
14270            d.insert_link_labelled("2026-10-02.md", "Title");
14271            assert_eq!(d.source, "see [Title](2026-10-02.md)\n", "{fmt:?}");
14272            assert_eq!(d.selection(), None, "{fmt:?}");
14273            assert_eq!(d.caret, "see [Title](2026-10-02.md)".len(), "{fmt:?}");
14274            assert_eq!(
14275                d.link_destination_at(5).as_deref(),
14276                Some("2026-10-02.md"),
14277                "{fmt:?}"
14278            );
14279            assert!(d.dirty);
14280        }
14281    }
14282
14283    #[test]
14284    fn insert_link_labelled_escapes_the_label_so_it_round_trips() {
14285        // A `]` must not close the link early, nor a `*` open emphasis: the
14286        // label goes in as literal text in the body's own grammar.
14287        for fmt in [Format::Markdown, Format::Djot] {
14288            let label = "a] *b* [c` <d>";
14289            let mut d = Doc::from_source("\n".into(), fmt).unwrap();
14290            d.caret = 0;
14291            d.insert_link_labelled("x.md", label);
14292            assert_eq!(d.selection(), None, "{fmt:?}");
14293            assert_eq!(d.caret, d.source.len() - 1, "{fmt:?}: {:?}", d.source);
14294            let link = d
14295                .nodes()
14296                .into_iter()
14297                .find(|n| n.kind == Kind::Link)
14298                .unwrap_or_else(|| panic!("{fmt:?}: no link in {:?}", d.source));
14299            assert_eq!(link.span, 0..d.source.len() - 1, "{fmt:?}: {:?}", d.source);
14300            assert_eq!(d.link_destination_at(1).as_deref(), Some("x.md"));
14301            // What the link's text reads as, markup and escapes aside.
14302            let text: String = d
14303                .nodes()
14304                .into_iter()
14305                .filter(|n| n.span.start >= link.span.start && n.span.end <= link.span.end)
14306                .inspect(|n| {
14307                    assert!(
14308                        matches!(n.kind, Kind::Para | Kind::Link | Kind::Str),
14309                        "{fmt:?}: the label minted {:?} in {:?}",
14310                        n.kind,
14311                        d.source
14312                    )
14313                })
14314                .filter(|n| n.kind == Kind::Str)
14315                .filter_map(|n| n.text)
14316                .collect();
14317            assert_eq!(text, label, "{fmt:?}: {:?}", d.source);
14318        }
14319    }
14320
14321    #[test]
14322    fn insert_link_labelled_ignores_the_label_over_a_selection() {
14323        let mut d = doc_with("link_lab_sel", "word here\n");
14324        d.anchor = Some(0);
14325        d.caret = 4;
14326        d.insert_link_labelled("http://x.dev", "Ignored");
14327        assert_eq!(d.source, "[word](http://x.dev) here\n");
14328        assert_eq!(d.selected_text(), Some("word"));
14329    }
14330
14331    #[test]
14332    fn insert_link_labelled_repoints_the_link_the_caret_stands_in() {
14333        // A host's Edit Link calls the labelled verb too: inside a link it
14334        // re-points and keeps the text there is, as `insert_link` does.
14335        let mut d = doc_with("link_lab_repoint", "[word](http://x.dev)\n");
14336        d.caret = 3;
14337        d.insert_link_labelled("http://y.dev", "Ignored");
14338        assert_eq!(d.source, "[word](http://y.dev)\n");
14339        assert_eq!(d.selected_text(), Some("word"));
14340
14341        let mut d = doc_with("link_lab_repoint_auto", "see <https://x.dev> ok\n");
14342        d.caret = 10;
14343        d.insert_link_labelled("https://y.dev", "Ignored");
14344        assert_eq!(d.source, "see <https://y.dev> ok\n");
14345    }
14346
14347    #[test]
14348    fn insert_link_labelled_just_past_a_link_writes_a_new_one() {
14349        // The caret at a link's end is not in it: nothing to re-point.
14350        let mut d = doc_with("link_lab_after", "[a](x.md)\n");
14351        d.caret = 9;
14352        d.insert_link_labelled("y.md", "B");
14353        assert_eq!(d.source, "[a](x.md)[B](y.md)\n");
14354        assert_eq!(d.caret, 18);
14355    }
14356
14357    #[test]
14358    fn insert_link_labelled_is_one_undo_step() {
14359        let mut d = doc_with("link_lab_undo", "see \n");
14360        d.caret = 4;
14361        let steps = d.undo_steps;
14362        d.insert_link_labelled("x.md", "Title");
14363        assert_eq!(d.undo_steps, steps + 1);
14364        assert!(d.undo());
14365        assert_eq!(d.source, "see \n");
14366        assert_eq!(d.caret, 4);
14367        assert!(d.redo());
14368        assert_eq!(d.source, "see [Title](x.md)\n");
14369    }
14370
14371    #[test]
14372    fn insert_link_labelled_refuses_a_bad_destination_and_leaves_no_label() {
14373        let mut d = doc_with("link_lab_bad", "see \n");
14374        d.caret = 4;
14375        d.insert_link_labelled("a\nb", "Title");
14376        assert_eq!(d.source, "see \n");
14377        assert!(d.status.is_some());
14378        assert!(!d.dirty);
14379    }
14380
14381    #[test]
14382    fn insert_link_labelled_refuses_a_read_only_document() {
14383        let mut d = doc_with("link_lab_ro", "see \n");
14384        d.set_read_only(true);
14385        d.caret = 4;
14386        d.insert_link_labelled("x.md", "Title");
14387        assert_eq!(d.source, "see \n");
14388        assert!(!d.dirty);
14389    }
14390
14391    #[test]
14392    fn click_maps_a_row_col_to_a_byte_offset() {
14393        let mut d = doc_with("click", "ab\ncd\n");
14394        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
14395        assert_eq!(d.caret, 4);
14396    }
14397
14398    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
14399    // stop just as the `(row, col)` click path does, so the caret can never come
14400    // to rest in the blank gap between two paragraphs — where it would draw in one
14401    // place and type in another.
14402    #[test]
14403    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
14404        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
14405        // caret stop (stops are 0,1,3,4).
14406        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
14407        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
14408        d.place_caret(2, false);
14409        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
14410        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
14411    }
14412
14413    #[test]
14414    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
14415        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
14416        d.place_caret(0, false); // anchor at the start of "A"
14417        d.place_caret(2, true); // drag into the gap
14418        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
14419        let (s, e) = d.selection().expect("a selection");
14420        assert!(
14421            d.vmap.is_stop(s) && d.vmap.is_stop(e),
14422            "selection {s}..{e} off a stop"
14423        );
14424    }
14425
14426    #[test]
14427    fn place_caret_on_a_real_stop_is_left_untouched() {
14428        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
14429        d.place_caret(3, false); // the start of "B" — a genuine stop
14430        assert_eq!(d.caret, 3);
14431    }
14432
14433    // An *empty paragraph* (two blank lines, an intentional blank line the user
14434    // opened) is a real caret stop, unlike the gap — a click into it must stay.
14435    #[test]
14436    fn place_caret_rests_in_an_empty_paragraph() {
14437        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
14438        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
14439        assert!(d.vmap.is_stop(empty));
14440        d.place_caret(empty, false);
14441        assert_eq!(d.caret, empty);
14442    }
14443
14444    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
14445    // a drag over the word `bold` ends there, and a caret placed there stays.
14446    #[test]
14447    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
14448        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
14449        let mut d = wysiwyg_doc("place_mark_end", src);
14450        let start = src.find("bold").unwrap();
14451        d.place_caret(start, false);
14452        d.place_caret(start + 4, true);
14453        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
14454        d.toggle(InlineKind::Strong);
14455        assert_eq!(d.source, src.replace("**bold**", "bold"));
14456    }
14457
14458    #[test]
14459    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
14460        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
14461        d.caret = 7; // before the `d`
14462        d.move_right(false);
14463        assert_eq!(d.caret, 8, "onto the end of the bold");
14464        assert!(d.active_inline_marks().contains(InlineKind::Strong));
14465        d.move_right(false);
14466        assert_eq!(d.caret, 10, "past the closing `**`");
14467        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
14468        d.move_left(false);
14469        assert_eq!(d.caret, 8);
14470        d.move_left(false);
14471        assert_eq!(d.caret, 7);
14472        // Typing at the inner home extends the bold.
14473        d.caret = 8;
14474        d.insert("!");
14475        assert_eq!(d.source, "a **bold!** b");
14476    }
14477
14478    #[test]
14479    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
14480        // The splice path shifts the home with the block it is in, and the
14481        // re-rendered block finds its own again.
14482        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
14483        d.build_visual_unwrapped();
14484        d.edit(0, 0, "zz");
14485        d.build_visual_unwrapped();
14486        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
14487        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
14488        let at = d.source.find("bold").unwrap();
14489        d.edit(at, at, "very ");
14490        d.build_visual_unwrapped();
14491        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
14492        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
14493    }
14494
14495    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
14496        doc_in(View::Wysiwyg, name, body)
14497    }
14498
14499    /// How many list items the source actually parses into — the check that a
14500    /// marker Leaf wrote is a marker the format agrees is one.
14501    fn list_items(doc: &mut Doc) -> usize {
14502        doc.editor
14503            .nodes()
14504            .unwrap()
14505            .iter()
14506            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
14507            .count()
14508    }
14509
14510    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
14511    /// incremental (`build_spliced` / `build_cached`) path must always match.
14512    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
14513        reference_map_revealing(source, None)
14514    }
14515
14516    /// [`reference_map`] with a reveal line — the ground truth for the
14517    /// `MarkupMode::Full` builds, where the map is a function of the caret's
14518    /// line as well as the text.
14519    fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
14520        // The same parse `Doc` uses. With twig's plain defaults instead, the two
14521        // sides disagree on what the *document* is before the renderer is even
14522        // reached — a bare `:word` is a text directive to one and prose to the
14523        // other — and the mismatch reads as a splice bug that isn't one.
14524        let mut ed =
14525            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
14526        let nodes = ed.nodes().unwrap();
14527        crate::wysiwyg::build(
14528            &nodes,
14529            source,
14530            None,
14531            false,
14532            &wysiwyg::Surface::default(),
14533            reveal,
14534        )
14535    }
14536
14537    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
14538        if a.rows.len() != b.rows.len() {
14539            return true;
14540        }
14541        for (ra, rb) in a.rows.iter().zip(&b.rows) {
14542            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
14543                return true;
14544            }
14545            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
14546                if ga.ch != gb.ch || ga.src != gb.src {
14547                    return true;
14548                }
14549            }
14550        }
14551        false
14552    }
14553
14554    #[test]
14555    fn incremental_build_matches_a_fresh_build_across_edits() {
14556        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
14557        // fast path, gated on twig's `dirty_range`) or falls back to
14558        // `build_cached`. After each edit the map must be byte-identical to a
14559        // from-scratch build — this is the correctness net under the splice.
14560        let docs = [
14561            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
14562            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
14563            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
14564            // A footnote definition is a root beside `doc`, merged back into the
14565            // top-level list by `wysiwyg::top_blocks`. The random edits below
14566            // make and unmake definitions as they go (a deleted `:` turns one
14567            // back into a paragraph, and vice versa), which is exactly the
14568            // structural churn the splice path has to notice and bail out of.
14569            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
14570            // A comment is a top-level block that draws no rows — a layout entry
14571            // at zero rows either side of blocks that do. The edits below type
14572            // into the blocks around it (a splice past a hidden block), and
14573            // break the comment open into prose and back (a structural change).
14574            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
14575            // Link reference definitions: a hidden block that an edit can turn
14576            // into a paragraph (a deleted `:`) and back, and whose own bytes an
14577            // edit can land in.
14578            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
14579            // Blank lines above the first block draw as rows, counted as its
14580            // separator; above it past frontmatter and past a comment too.
14581            "\n\nfirst pushed down\n\nsecond\n",
14582            "---\ntitle: x\n---\n\n\nafter frontmatter\n\nmore\n",
14583            "<!-- lead -->\n\n\nafter a comment\n\nmore\n",
14584            // No `<div>` here yet: an edit that takes the blank line under
14585            // `</div>` leaves a map no fresh build draws. See
14586            // docs/tasks/text-glued-under-a-closing-div.md.
14587        ];
14588        // A deterministic mix: mostly single characters (which stay inside one
14589        // block → splice), plus edits that reshape structure (a paragraph break,
14590        // a heading marker, a code fence → fallback), so both paths are exercised.
14591        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
14592        for src in docs {
14593            let mut d = wysiwyg_doc("diff", src);
14594            d.build_visual_unwrapped();
14595            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
14596
14597            for step in 0..60usize {
14598                let len = d.source.len();
14599                let raw = (step * 13 + 5) % (len + 1);
14600                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
14601                let pre = d.source.clone();
14602                let action;
14603                if step % 3 == 0 && pos < len {
14604                    let end = (pos + 1..=len)
14605                        .find(|&i| d.source.is_char_boundary(i))
14606                        .unwrap();
14607                    action = format!("delete [{pos},{end})");
14608                    d.edit(pos, end, "");
14609                } else {
14610                    let ins = inserts[step % inserts.len()];
14611                    action = format!("insert {ins:?} @ {pos}");
14612                    d.edit(pos, pos, ins);
14613                }
14614                d.build_visual_unwrapped();
14615                if maps_differ(&d.vmap, &reference_map(&d.source)) {
14616                    panic!(
14617                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
14618                        d.source
14619                    );
14620                }
14621            }
14622        }
14623    }
14624
14625    /// A frontend is handed [`Doc::vmap`] and may present it differently:
14626    /// leaf-ratatui splices blank filler rows under an oversized heading so the
14627    /// raster it paints there has somewhere to stand, and leaves them in the map
14628    /// because the caret and the mouse both read it between frames. The splice
14629    /// path addresses that map by *row index*, against the block layout the last
14630    /// build recorded — so handed a map with rows in it that no block owns, it
14631    /// laid the re-rendered block over one of the fillers and carried the rows
14632    /// the block really occupied into the suffix. One stranded copy of the
14633    /// edited line, and everything below it a row further down, per keystroke.
14634    ///
14635    /// A map that isn't the one the layout describes is a map this path can't
14636    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
14637    #[test]
14638    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
14639        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
14640        d.build_visual_unwrapped();
14641
14642        // Stand in for the heading filler rows: two blank rows past the heading
14643        // that no block accounts for. Cloning a real row keeps every field
14644        // plausible — it is the row *count* the splice can't survive.
14645        let filler = d.vmap.rows[0].clone();
14646        d.vmap.rows.insert(1, filler.clone());
14647        d.vmap.rows.insert(1, filler);
14648
14649        // An edit inside the last block: the single-block case the splice path
14650        // is for, and the one the frontend hits on every keystroke.
14651        let at = d.source.len() - 1;
14652        d.edit(at, at, "!");
14653        d.build_visual_unwrapped();
14654
14655        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
14656    }
14657
14658    /// A glyph's [`FaceId`] has to mean the same thing however its row was
14659    /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
14660    /// cloned at a shifted offset, and a previous map's rows a splice kept
14661    /// untouched — and only the first of those walks a `data-font` at all. An
14662    /// index into a per-build table would have had the same glyph naming two
14663    /// families the moment a second one appeared; the id is the name's own
14664    /// hash, so nothing is remapped and the table is merged rather than rebuilt.
14665    ///
14666    /// Two families, because one cannot tell a wrong id from a right one.
14667    ///
14668    /// [`FaceId`]: crate::style::FaceId
14669    /// [`BlockCache`]: crate::wysiwyg::BlockCache
14670    #[test]
14671    fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
14672        use crate::style::{FaceId, FaceRef};
14673        let garamond = FaceId::of("Garamond");
14674        let futura = FaceId::of("Futura");
14675        let mut d = wysiwyg_doc(
14676            "two_faces",
14677            "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
14678        );
14679        d.build_visual_unwrapped();
14680
14681        // What the map has to keep saying, whichever path built it.
14682        let check = |d: &Doc, ctx: &str| {
14683            let face_of = |ch: char| {
14684                d.vmap
14685                    .rows
14686                    .iter()
14687                    .flat_map(|r| r.glyphs.iter())
14688                    .find(|g| g.ch == ch)
14689                    .map(|g| g.style.font)
14690            };
14691            assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
14692            assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
14693            assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
14694            assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
14695            assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
14696        };
14697        check(&d, "fresh");
14698
14699        // An edit inside the second block: the single-block case the splice
14700        // path is for. The first block's rows are carried over untouched, so
14701        // its glyphs' ids are the previous build's and the table has to be too.
14702        let at = d.source.find("beta").unwrap();
14703        d.edit(at, at, "z");
14704        d.build_visual_unwrapped();
14705        check(&d, "after an edit in the second block");
14706        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
14707
14708        // And the other way round, so the block that was kept is the one that
14709        // is now re-rendered.
14710        let at = d.source.find("alpha").unwrap();
14711        d.edit(at, at, "z");
14712        d.build_visual_unwrapped();
14713        check(&d, "after an edit in the first block");
14714        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
14715
14716        // A structural edit is one the splice bails out of, so the map is
14717        // reassembled by `build_cached` — where an untouched block is a *cache
14718        // hit* and its rows are cloned without a `data-font` being walked
14719        // again. The names the entry stored are what keeps the table honest
14720        // there.
14721        let at = d.source.find("\n\ny ").unwrap();
14722        d.edit(at, at, "\n\nmiddle");
14723        d.build_visual_unwrapped();
14724        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
14725        // Twice, because that first `build_cached` is what stores the entries:
14726        // this one is the build where the Garamond block is a *hit*, its rows
14727        // cloned with their ids and no attribute walked to explain them.
14728        let at = d.source.len() - 1;
14729        d.edit(at, at, "\n\ntail");
14730        d.build_visual_unwrapped();
14731        check(&d, "after a structural edit, through the block cache");
14732        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
14733
14734        // A family the edit took the last glyph of leaves the glyphs with no
14735        // face and the table with a name nothing asks for — harmless, and the
14736        // price of not walking the rows the splice exists to avoid walking.
14737        let span = d.source.find("<span data-font=\"Futura\">").unwrap();
14738        let end = d.source.rfind("</span>").unwrap() + "</span>".len();
14739        d.edit(span, end, "beta");
14740        d.build_visual_unwrapped();
14741        assert!(!d.source.contains("Futura"), "{:?}", d.source);
14742        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
14743    }
14744
14745    #[test]
14746    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
14747        // The same correctness net as `incremental_build_matches_a_fresh_build_
14748        // across_edits`, under `MarkupMode::Full` — where the map depends on
14749        // the caret's *line* as well as the text, so the two caches have a new
14750        // way to be wrong. Both are exercised: the block cache can hand back
14751        // rows built for a line that is no longer the revealed one, and the
14752        // splice path can reuse a suffix that still has yesterday's line raw.
14753        //
14754        // Caret motion is interleaved with the edits deliberately, because a
14755        // caret that only ever moved with the edit would never cross a line
14756        // without also dirtying it — the case where a stale reveal survives.
14757        let docs = [
14758            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
14759            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
14760        ];
14761        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
14762        for src in docs {
14763            let mut d = wysiwyg_doc("reveal_diff", src);
14764            d.set_markup_mode(MarkupMode::Full);
14765
14766            for step in 0..60usize {
14767                let len = d.source.len();
14768                let raw = (step * 13 + 5) % (len + 1);
14769                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
14770                let pre = d.source.clone();
14771                let action;
14772                if step % 3 == 0 && pos < len {
14773                    let end = (pos + 1..=len)
14774                        .find(|&i| d.source.is_char_boundary(i))
14775                        .unwrap();
14776                    action = format!("delete [{pos},{end})");
14777                    d.edit(pos, end, "");
14778                } else {
14779                    let ins = inserts[step % inserts.len()];
14780                    action = format!("insert {ins:?} @ {pos}");
14781                    d.edit(pos, pos, ins);
14782                }
14783                // Walk the caret somewhere else in the document, independently
14784                // of where the edit landed.
14785                let want = (step * 29 + 11) % (d.source.len() + 1);
14786                d.caret = (want..=d.source.len())
14787                    .find(|&i| d.source.is_char_boundary(i))
14788                    .unwrap();
14789                d.build_visual_unwrapped();
14790
14791                let want = reference_map_revealing(&d.source, d.reveal_line());
14792                if maps_differ(&d.vmap, &want) {
14793                    panic!(
14794                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
14795                        d.caret, d.source
14796                    );
14797                }
14798            }
14799        }
14800    }
14801
14802    #[test]
14803    fn caret_motion_across_lines_rebuilds_only_under_full() {
14804        // The cache-key change has to earn its keep in both directions: `Full`
14805        // must rebuild when the caret changes line (or the reveal would never
14806        // move), and the hidden modes must *not* (or every arrow key would pay
14807        // for a feature they don't use). The existing `cache_motion` test pins
14808        // the second for the default mode; this pins the pair against a mode
14809        // change alone.
14810        let body = "*one* here\n\n*two* there\n";
14811
14812        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
14813        full.set_markup_mode(MarkupMode::Full);
14814        caret_at(&mut full, "one");
14815        let before = full.revision();
14816        caret_at(&mut full, "two");
14817        assert_eq!(full.revision(), before, "motion is not an edit");
14818        assert!(
14819            drawn_rows(&full).iter().any(|r| r == "*two* there"),
14820            "the map followed the caret: {:?}",
14821            drawn_rows(&full)
14822        );
14823
14824        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
14825        caret_at(&mut hidden, "one");
14826        let key = hidden.vmap_key.clone();
14827        caret_at(&mut hidden, "two");
14828        assert_eq!(
14829            hidden.vmap_key, key,
14830            "a hidden mode rebuilds nothing on motion"
14831        );
14832    }
14833
14834    #[test]
14835    fn wysiwyg_down_crosses_a_paragraph_boundary() {
14836        // Regression: the blank separator row used to share the previous
14837        // paragraph's end offset, so Down got pinned at the boundary (while Up
14838        // still crossed). Both directions must step through it symmetrically.
14839        //
14840        // It's now stepped *over* rather than onto: the blank line between two
14841        // paragraphs is the boundary being drawn, not a line of the document, so
14842        // one press of Down crosses it. The goal column survives the crossing —
14843        // col 3 at the end of "abc" is col 3 at the end of "def".
14844        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
14845        d.caret = 3; // end of "abc" (row 0)
14846        d.move_down(false);
14847        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
14848        assert_eq!(d.caret, 8); // end of "def", col 3 kept
14849        d.move_up(false);
14850        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
14851        assert_eq!(d.caret, 3);
14852    }
14853
14854    #[test]
14855    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
14856        // The second Up and the second Down here run off the ends of the
14857        // document, which is no longer a place a press is swallowed: they carry
14858        // the caret to the start and the end of the text. The claim in the
14859        // middle — that a Down retraces the Up that crossed the paragraph gap —
14860        // is the one this test is for, and it is asserted where it is made.
14861        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
14862        d.caret = 5; // start of "def"
14863        let start = d.caret_pos();
14864        d.move_up(false);
14865        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
14866        d.move_up(false);
14867        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
14868        d.move_down(false);
14869        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
14870        d.move_down(false);
14871        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
14872    }
14873
14874    #[test]
14875    fn wysiwyg_new_paragraph_shows_before_typing() {
14876        // Regression: two Enters at the end of a paragraph produced trailing
14877        // newlines with no AST node, so the caret appeared stuck on the old line
14878        // until a character was typed. It must ride down onto the new line now.
14879        let mut d = doc_with("wys_newpara", "abc\n");
14880        d.view = View::Wysiwyg;
14881        d.caret = 3;
14882        d.insert("\n");
14883        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
14884        assert_eq!(d.source, "abc\n\n\n");
14885        d.build_visual(80);
14886        let (row, _) = d.caret_pos();
14887        assert!(
14888            row >= 2,
14889            "caret should have moved down to the new line, got row {row}"
14890        );
14891        assert!(
14892            d.vmap.num_rows() >= 3,
14893            "the blank lines should render as rows"
14894        );
14895    }
14896
14897    #[test]
14898    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
14899        // The reported bug: Enter at the end of a paragraph that has another
14900        // paragraph below put the caret at the *start of the next paragraph* —
14901        // the empty paragraph it opened had no row, so the caret snapped onto
14902        // "World". It must now sit on its own empty line, with a blank spacer
14903        // above it (the paragraph gap).
14904        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
14905        d.caret = 5; // end of "Hello"
14906        d.newline();
14907        d.build_visual(80);
14908        let (row, col) = d.caret_pos();
14909        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
14910        assert_eq!(
14911            d.vmap.row_width(row),
14912            0,
14913            "caret's row must be empty, not 'World'"
14914        );
14915        assert!(
14916            row >= 2,
14917            "a blank spacer row should sit above the caret, got row {row}"
14918        );
14919        // The row above the caret is a real (empty) gap, and "Hello" stays put.
14920        assert_eq!(
14921            d.vmap.row_width(row - 1),
14922            0,
14923            "the row above the caret is a gap"
14924        );
14925        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
14926        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
14927    }
14928
14929    #[test]
14930    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
14931        // At the document end a single Enter must also show the paragraph gap —
14932        // a blank spacer row above the caret — so the layout already matches how
14933        // it will look once the new paragraph has text.
14934        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
14935        d.caret = 5; // end of "Hello", no trailing newline
14936        d.newline(); // source becomes "Hello\n\n"
14937        d.build_visual(80);
14938        let (row, col) = d.caret_pos();
14939        assert_eq!(col, 0);
14940        assert!(
14941            row >= 2,
14942            "caret should sit below a blank spacer, got row {row}"
14943        );
14944        assert_eq!(
14945            d.vmap.row_width(row - 1),
14946            0,
14947            "the row above the caret is a gap"
14948        );
14949    }
14950
14951    #[test]
14952    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
14953        // The spacer is view-only: typing the new paragraph must not reflow the
14954        // caret onto a different row — the transient view already matched the
14955        // settled one.
14956        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
14957        d.caret = 5;
14958        d.newline();
14959        d.build_visual(80);
14960        let before = d.caret_pos();
14961        d.insert("New");
14962        d.build_visual(80);
14963        let after = d.caret_pos();
14964        assert_eq!(
14965            after.0, before.0,
14966            "typing must not move the caret to another row ({before:?} -> {after:?})"
14967        );
14968    }
14969
14970    #[test]
14971    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
14972        // Return at the end of the block's last line writes an empty line the
14973        // map used to drop, so the caret landed on `after` and the next
14974        // keystroke went into the paragraph below instead of into the code.
14975        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
14976        d.caret = d.source.find("beta").unwrap() + "beta".len();
14977        d.build_visual(80);
14978        let before = d.caret_pos().0;
14979
14980        d.newline();
14981        d.build_visual(80);
14982        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
14983
14984        let (row, col) = d.caret_pos();
14985        assert_eq!(row, before + 1, "the caret moves down one row");
14986        assert_eq!(col, 0, "onto the head of the empty line");
14987        let span = d.vmap.code_blocks[0].rows_span.clone();
14988        assert!(
14989            span.contains(&row),
14990            "caret row {row} is outside the block's rows {span:?}"
14991        );
14992
14993        // The whole point: what is typed next is code.
14994        d.insert("gamma");
14995        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
14996    }
14997
14998    #[test]
14999    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
15000        let fm = "---\ntitle: hi\n---\n";
15001        let body = format!("{fm}# leaf\n\nbody\n");
15002        let mut d = wysiwyg_doc("wys_fm", &body);
15003        // Opening lifts the caret out of the now-hidden frontmatter.
15004        assert_eq!(
15005            d.caret,
15006            fm.len(),
15007            "caret should start at the first real block"
15008        );
15009        // Left at the content start can't step back into frontmatter.
15010        d.move_left(false);
15011        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
15012        // Doc-start lands on the content floor, not offset 0.
15013        d.move_doc_start(false);
15014        assert_eq!(d.caret, fm.len());
15015        // Select-all + copy never include the frontmatter bytes.
15016        d.select_all();
15017        let sel = d.selected_text().unwrap().to_string();
15018        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
15019        assert!(
15020            sel.starts_with("# leaf"),
15021            "selection should begin at content: {sel:?}"
15022        );
15023    }
15024
15025    #[test]
15026    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
15027        // A fresh note is frontmatter and nothing else. With no rendered block
15028        // to floor the caret it opened at offset 0 — before the opening `---` —
15029        // so the first keystroke wrote itself in front of the metadata and the
15030        // file came out as `This---\ntitle: …`.
15031        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
15032        let mut d = wysiwyg_doc("wys_fm_only", fm);
15033        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
15034        // Nothing is rendered, so the caret draws at the origin of an empty view
15035        // — the same place an empty document puts it.
15036        assert_eq!(d.caret_pos(), (0, 0));
15037        d.insert("This");
15038        assert_eq!(d.source, format!("{fm}This"));
15039    }
15040
15041    /// `select_range` is the verb for a range a host already knows the bytes of,
15042    /// so it must not snap — and must still hold every invariant `place_caret`
15043    /// holds, the frontmatter floor above all.
15044    #[test]
15045    fn select_range_takes_the_range_as_given_but_still_floors_it() {
15046        let fm = "---\ntitle: foo\n---\n\n";
15047        let body = format!("{fm}body foo here\n");
15048        let mut d = wysiwyg_doc("wys_select_range", &body);
15049
15050        // The `foo` in the body: taken exactly, not snapped to a caret stop.
15051        let at = body.rfind("foo").unwrap();
15052        d.select_range(at, at + 3);
15053        assert_eq!(d.selection(), Some((at, at + 3)));
15054        assert_eq!(d.selected_text(), Some("foo"));
15055
15056        // The `foo` in the hidden frontmatter: below the floor, so both ends
15057        // come up to it rather than parking the caret in the metadata, where a
15058        // later keystroke would rewrite `title:`.
15059        let hidden = body.find("foo").unwrap();
15060        assert!(hidden < d.vmap.content_start);
15061        d.select_range(hidden, hidden + 3);
15062        assert!(
15063            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
15064            "a range under the floor must not leave the caret in the frontmatter"
15065        );
15066
15067        // Past the end, and mid-character, are both brought back to something
15068        // sliceable rather than panicking the next reader of the range.
15069        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
15070        let mut d = multi;
15071        d.select_range(2, 9_999);
15072        assert_eq!(d.caret, d.source.len());
15073        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
15074        assert!(d.source.is_char_boundary(d.caret));
15075    }
15076
15077    /// The bug `select_range` exists for: a match butting up against a hidden
15078    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
15079    /// the one before the `**`.
15080    #[test]
15081    fn select_range_does_not_snap_off_a_hidden_delimiter() {
15082        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
15083        let at = d.source.find("needle").unwrap();
15084        d.select_range(at, at + 6);
15085        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
15086    }
15087
15088    #[test]
15089    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
15090        // Backspace deletes `prev_boundary..caret` directly; at the first real
15091        // block that boundary is inside the hidden frontmatter, so it must be a
15092        // no-op rather than eating the closing `---`.
15093        let fm = "---\ntitle: hi\n---\n";
15094        let body = format!("{fm}leaf\n");
15095        let mut d = wysiwyg_doc("wys_fm_bs", &body);
15096        assert_eq!(d.caret, fm.len());
15097        d.backspace();
15098        assert_eq!(d.source, body, "backspace must not touch frontmatter");
15099        d.delete_word_back();
15100        assert_eq!(
15101            d.source, body,
15102            "word-delete must not touch frontmatter either"
15103        );
15104    }
15105
15106    #[test]
15107    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
15108        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
15109        // fenced div, really, since core parses these on for every document
15110        // now (`parse_extensions`). The container is a `directive` node, an
15111        // `is_block_container` kind like `block_quote`, so the caret works
15112        // inside its child paragraph exactly as it would inside a quote: typing
15113        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
15114        // untouched.
15115        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
15116        let mut d = wysiwyg_doc("wys_vis", body);
15117        d.caret = body.find("hello").unwrap() + "hello".len();
15118        d.insert("!");
15119        assert_eq!(
15120            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
15121            "typing inside the block edits its content in place"
15122        );
15123        assert!(
15124            d.source.contains(":::vis{.public .family}"),
15125            "opening fence survives"
15126        );
15127        assert!(d.source.contains(":::\nafter"), "closing fence survives");
15128    }
15129
15130    #[test]
15131    fn source_view_still_reaches_frontmatter() {
15132        // The metadata is only *hidden*, never lost: the source view edits and
15133        // selects it in full, and it's always preserved on save.
15134        let fm = "---\ntitle: hi\n---\n";
15135        let body = format!("{fm}# leaf\n");
15136        let mut d = doc_with("src_fm", &body);
15137        d.select_all();
15138        let sel = d.selected_text().unwrap();
15139        assert!(
15140            sel.contains("title"),
15141            "source view should select everything"
15142        );
15143        d.move_doc_start(false);
15144        assert_eq!(d.caret, 0, "source view can reach offset 0");
15145    }
15146
15147    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
15148
15149    #[test]
15150    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
15151        // The border and padding between two cells all share one source offset,
15152        // so a column-stepping caret would sit on `│` and then stall there
15153        // forever. Right must step: end of "Name" -> start of "Qty".
15154        let mut d = wysiwyg_doc("tbl_right", TABLE);
15155        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
15156        d.move_right(false);
15157        assert_eq!(
15158            d.caret,
15159            TABLE.find("Qty").unwrap(),
15160            "should land in the next cell"
15161        );
15162        let (r, c) = d.caret_pos();
15163        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
15164    }
15165
15166    #[test]
15167    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
15168        let mut d = wysiwyg_doc("tbl_left", TABLE);
15169        d.caret = TABLE.find("Qty").unwrap();
15170        d.move_left(false);
15171        assert_eq!(
15172            d.caret,
15173            TABLE.find("Name").unwrap() + 4,
15174            "end of the previous cell"
15175        );
15176    }
15177
15178    #[test]
15179    fn wysiwyg_down_steps_over_a_table_rule() {
15180        // Between the header and the first body row sits a `├───┼───┤` rule.
15181        // It's drawn but holds no caret, so one Down must reach "Pear".
15182        let mut d = wysiwyg_doc("tbl_down", TABLE);
15183        d.caret = TABLE.find("Name").unwrap();
15184        d.move_down(false);
15185        assert_eq!(
15186            d.caret,
15187            TABLE.find("Pear").unwrap(),
15188            "one Down reaches the body row"
15189        );
15190        d.move_down(false);
15191        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
15192    }
15193
15194    #[test]
15195    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
15196        let mut d = wysiwyg_doc("tbl_tab", TABLE);
15197        d.caret = TABLE.find("Name").unwrap();
15198        // A hop lands with the destination cell's whole content selected, the
15199        // caret at its end — so typing replaces the cell like a form field.
15200        assert!(d.cell_hop(true));
15201        assert_eq!(
15202            d.selected_text(),
15203            Some("Qty"),
15204            "the target cell comes up selected"
15205        );
15206        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
15207        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
15208        assert_eq!(d.selected_text(), Some("Pear"));
15209        assert!(d.cell_hop(false));
15210        assert_eq!(d.selected_text(), Some("Qty"));
15211    }
15212
15213    #[test]
15214    fn tab_outside_a_table_is_not_a_cell_hop() {
15215        // `cell_hop` reports false so the frontend can indent as usual.
15216        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
15217        d.caret = 4;
15218        assert!(!d.cell_hop(true));
15219        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
15220    }
15221
15222    #[test]
15223    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
15224        let mut d = wysiwyg_doc("tbl_edge", TABLE);
15225        d.caret = TABLE.rfind("12").unwrap(); // the final cell
15226        assert!(!d.cell_hop(true), "no cell after the last one");
15227        d.caret = TABLE.find("Name").unwrap();
15228        assert!(!d.cell_hop(false), "no cell before the first one");
15229    }
15230
15231    #[test]
15232    fn wysiwyg_vertical_cell_motion_holds_the_column() {
15233        // Down/Up step to the cell above/below in the *same column*, not back to
15234        // the top-left the way a naive row/col motion over the picture would.
15235        let mut d = wysiwyg_doc("tbl_vert", TABLE);
15236        d.caret = TABLE.find("Qty").unwrap();
15237        // Each vertical hop selects the destination cell, holding the column.
15238        assert!(d.cell_move_vertical(true));
15239        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
15240        assert!(d.cell_move_vertical(true));
15241        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
15242        assert!(!d.cell_move_vertical(true), "no row below the last");
15243        assert!(d.cell_move_vertical(false));
15244        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
15245        assert!(d.cell_move_vertical(false));
15246        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
15247        assert!(!d.cell_move_vertical(false), "no row above the header");
15248    }
15249
15250    #[test]
15251    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
15252        let mut d = wysiwyg_doc("tbl_grow", TABLE);
15253        d.caret = TABLE.rfind("12").unwrap();
15254        let rows_before = d.source.matches('\n').count();
15255        assert!(d.cell_tab(true), "acts as a table key");
15256        assert_eq!(
15257            d.source.matches('\n').count(),
15258            rows_before + 1,
15259            "a fresh row was appended"
15260        );
15261        assert!(d.caret_in_table(), "the caret entered the new row");
15262        // The caret sits in the new row's first cell — past the old last cell.
15263        assert!(d.caret > TABLE.rfind("12").unwrap());
15264    }
15265
15266    #[test]
15267    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
15268        let mut d = wysiwyg_doc("tbl_ret", TABLE);
15269        d.caret = TABLE.find("Name").unwrap();
15270        assert!(d.cell_return(), "acts as a table key");
15271        assert_eq!(
15272            d.selected_text(),
15273            Some("Pear"),
15274            "Return drops one cell, selecting it"
15275        );
15276        // From the last row, Return appends a row and enters it.
15277        d.caret = TABLE.rfind("Fig").unwrap();
15278        let rows_before = d.source.matches('\n').count();
15279        assert!(d.cell_return());
15280        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
15281        assert!(d.caret_in_table());
15282    }
15283
15284    #[test]
15285    fn return_and_tab_outside_a_table_decline() {
15286        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
15287        d.caret = 4;
15288        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
15289        assert!(!d.cell_tab(true), "no table: the frontend indents");
15290        assert!(
15291            !d.cell_line_break(),
15292            "no table: the frontend breaks the line"
15293        );
15294    }
15295
15296    #[test]
15297    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
15298        // A document that ends in a table used to end *inside* it: nothing
15299        // past the last cell was a caret stop, so a click in the blank space
15300        // under the grid snapped back into the table and there was no way to
15301        // write a line after it. The bottom border's end is that stop now.
15302        let mut d = wysiwyg_doc("tbl_trail", TABLE);
15303        let rows = d.vmap.num_rows();
15304        d.click(rows + 3, 0, false);
15305        let end = TABLE.trim_end_matches('\n').len();
15306        assert_eq!(d.caret, end, "the caret stands just past the table");
15307        assert!(!d.caret_in_table(), "past the table is outside it");
15308        assert!(!d.cell_return(), "Return there is the frontend's newline");
15309        d.newline();
15310        d.insert("after");
15311        assert_eq!(
15312            d.source,
15313            format!("{TABLE}\nafter\n"),
15314            "Enter opens a paragraph under the table"
15315        );
15316    }
15317
15318    #[test]
15319    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
15320        // The stop sits at the end of the table's last source line, and a
15321        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
15322        // three-cell row. So the text gets a paragraph of its own, as it does
15323        // beside a block picture.
15324        let mut d = wysiwyg_doc("tbl_type", TABLE);
15325        d.caret = TABLE.trim_end_matches('\n').len();
15326        d.insert("x");
15327        assert_eq!(d.source, format!("{TABLE}\nx\n"));
15328        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
15329        // And a paste, which joins the block exactly as typing would.
15330        let mut d = wysiwyg_doc("tbl_paste", TABLE);
15331        d.caret = TABLE.trim_end_matches('\n').len();
15332        d.paste("pasted");
15333        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
15334    }
15335
15336    #[test]
15337    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
15338        let mut d = wysiwyg_doc("tbl_edge", TABLE);
15339        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
15340        let end = TABLE.trim_end_matches('\n').len();
15341        d.caret = last_cell_end;
15342        d.move_right(false);
15343        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
15344        // Backspace there takes no byte: the one behind the caret is the row's
15345        // closing `|`, which the rich view never drew. It steps back instead.
15346        d.backspace();
15347        assert_eq!(d.source, TABLE, "nothing deleted");
15348        assert_eq!(d.caret, last_cell_end, "back into the last cell");
15349        // Down from the last row lands on the same stop, and Up returns.
15350        d.move_down(false);
15351        assert_eq!(d.caret, end, "Down from the last row leaves the table");
15352        d.move_up(false);
15353        assert_eq!(d.caret, last_cell_end);
15354    }
15355
15356    #[test]
15357    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
15358        // With prose under the table, the stop is one hop between the last
15359        // cell and the paragraph — the shape a block picture's second stop has.
15360        let src = format!("{TABLE}\nafter\n");
15361        let mut d = wysiwyg_doc("tbl_mid", &src);
15362        d.caret = TABLE.rfind("12").unwrap() + 2;
15363        d.move_right(false);
15364        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
15365        d.move_right(false);
15366        assert_eq!(d.caret, src.find("after").unwrap());
15367        // Typing at the stop still opens a paragraph, and the text below keeps
15368        // its own.
15369        d.move_left(false);
15370        d.insert("x");
15371        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
15372    }
15373
15374    #[test]
15375    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
15376        let mut d = wysiwyg_doc("tbl_break", TABLE);
15377        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
15378        assert!(d.cell_line_break(), "acts as a table key");
15379        assert!(
15380            d.source.contains("Pear<br>"),
15381            "spelled as an inline <br>: {}",
15382            d.source
15383        );
15384        assert!(d.caret_in_table(), "still in the cell, past the break");
15385        // The break renders as a real line: the "Pear" cell now draws two lines,
15386        // so the table's picture is one row taller than a single-line table.
15387        d.build_visual(80);
15388        let table = &d.vmap.tables[0];
15389        let cell = &table.grid[1].cells[0]; // first body row, first column
15390        assert!(
15391            cell.glyphs.iter().any(|g| g.ch == '\n'),
15392            "the cell carries the break as a newline glyph for the frontend to split"
15393        );
15394    }
15395
15396    #[test]
15397    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
15398        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
15399        // back as structure — the whole point of routing through insert_line_break
15400        // instead of splicing raw `<br>` bytes.
15401        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
15402        d.caret = TABLE.find("Pear").unwrap() + 4;
15403        assert!(d.cell_line_break());
15404        let kinds: Vec<Kind> = d
15405            .editor
15406            .nodes()
15407            .unwrap()
15408            .iter()
15409            .map(|n| n.kind.clone())
15410            .collect();
15411        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
15412        assert!(
15413            !kinds.contains(&Kind::RawInline),
15414            "still raw HTML: {kinds:?}"
15415        );
15416    }
15417
15418    #[test]
15419    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
15420        // The `<br>` draws as one newline glyph, so Backspace over it must take
15421        // all four bytes — a one-byte delete would strand a visible `<br` in the
15422        // cell (the reported bug).
15423        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
15424        d.caret = TABLE.find("Pear").unwrap() + 4;
15425        assert!(d.cell_line_break());
15426        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
15427        d.backspace(); // caret sits just past the break
15428        assert!(
15429            !d.source.contains("<br"),
15430            "no half-deleted <br left: {}",
15431            d.source
15432        );
15433        assert!(
15434            d.source.contains("| Pear |"),
15435            "the cell is back to one line: {}",
15436            d.source
15437        );
15438    }
15439
15440    #[test]
15441    fn backspace_at_a_cell_start_is_a_wall() {
15442        // The task's repro: two presses used to take the padding and then the
15443        // `|`, merging `d` into `c`'s cell and leaving the row a column short.
15444        let src = "| a | b |\n| - | - |\n| c | d |\n";
15445        let mut d = wysiwyg_doc("tbl_wall_bs", src);
15446        d.place_caret(src.find("d |").unwrap(), false);
15447        for _ in 0..3 {
15448            d.backspace();
15449        }
15450        assert_eq!(d.source, src);
15451        // Inside the padding too — right after the `|`, before the space. Set
15452        // directly: `place_caret` would snap it onto the stop past the space.
15453        d.caret = src.find("| d").unwrap() + 1;
15454        d.backspace();
15455        assert_eq!(d.source, src);
15456        // The row's first cell, and an empty one, are walls the same.
15457        d.place_caret(src.find("c |").unwrap(), false);
15458        d.backspace();
15459        assert_eq!(d.source, src);
15460        // A cell that opens on hidden markup: the wall is the first letter
15461        // drawn, not the `**` in front of it.
15462        let bold = "| a | b |\n| - | - |\n| c | **d** |\n";
15463        let mut d = wysiwyg_doc("tbl_wall_bs_bold", bold);
15464        d.place_caret(bold.find("d**").unwrap(), false);
15465        d.backspace();
15466        assert_eq!(d.source, bold);
15467        let empty = "| a | b |\n| - | - |\n| c |   |\n";
15468        let mut d = wysiwyg_doc("tbl_wall_bs_empty", empty);
15469        d.place_caret(empty.find("|   |").unwrap() + 2, false);
15470        d.backspace();
15471        assert_eq!(d.source, empty);
15472    }
15473
15474    #[test]
15475    fn backspace_inside_a_cell_still_deletes_a_character() {
15476        let src = "| a | b |\n| - | - |\n| c | de |\n";
15477        let mut d = wysiwyg_doc("tbl_wall_bs_mid", src);
15478        d.place_caret(src.find("e |").unwrap(), false);
15479        d.backspace();
15480        assert_eq!(d.source, "| a | b |\n| - | - |\n| c | e |\n");
15481    }
15482
15483    #[test]
15484    fn delete_at_a_cell_end_is_a_wall() {
15485        // The mirror: Delete at `c`'s end would take the padding, then the `|`.
15486        let src = "| a | b |\n| - | - |\n| c | d |\n";
15487        let mut d = wysiwyg_doc("tbl_wall_del", src);
15488        d.place_caret(src.find("c |").unwrap() + 1, false);
15489        for _ in 0..3 {
15490            d.delete_forward();
15491        }
15492        assert_eq!(d.source, src);
15493        // And inside the trailing padding, set directly past the snap.
15494        d.caret = src.find("c |").unwrap() + 2;
15495        d.delete_forward();
15496        assert_eq!(d.source, src);
15497        // The row's last cell is a wall on its closing `|` as well.
15498        d.place_caret(src.find("d |").unwrap() + 1, false);
15499        d.delete_forward();
15500        assert_eq!(d.source, src);
15501        // Mid-cell Delete is untouched.
15502        d.place_caret(src.find("c |").unwrap(), false);
15503        d.delete_forward();
15504        assert_eq!(d.source, "| a | b |\n| - | - |\n|  | d |\n");
15505    }
15506
15507    #[test]
15508    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
15509        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
15510        d.caret = TABLE.find("Pear").unwrap() + 4;
15511        assert!(d.cell_line_break());
15512        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
15513        d.delete_forward();
15514        assert!(
15515            !d.source.contains("<br"),
15516            "no half-deleted <br: {}",
15517            d.source
15518        );
15519        assert!(
15520            d.source.contains("| Pear |"),
15521            "cell back to one line: {}",
15522            d.source
15523        );
15524    }
15525
15526    #[test]
15527    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
15528        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
15529        // still consumed (a real newline would split the one-line row), but the
15530        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
15531        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
15532        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
15533        d.caret = src.find("Pear").unwrap() + 4;
15534        assert!(d.caret_in_table(), "caret should be inside the djot table");
15535        assert!(
15536            d.cell_line_break(),
15537            "the key is consumed, not passed to the frontend"
15538        );
15539        assert_eq!(d.source, src, "the djot cell is left untouched");
15540        assert!(
15541            !d.source.contains("<br>"),
15542            "no non-idiomatic <br> spliced into djot"
15543        );
15544        assert!(
15545            d.status.is_some(),
15546            "the refusal is surfaced on the status line"
15547        );
15548    }
15549
15550    #[test]
15551    fn typing_in_a_cell_edits_that_cell() {
15552        // Editing comes free once offsets map correctly: the caret is a source
15553        // offset, so a normal splice lands inside the pipe table.
15554        let mut d = wysiwyg_doc("tbl_type", TABLE);
15555        d.caret = TABLE.find("Pear").unwrap() + 4;
15556        d.insert("s");
15557        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
15558    }
15559
15560    #[test]
15561    fn motion_and_delete_treat_an_emoji_as_one_character() {
15562        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
15563        // bytes, several codepoints. Right-arrow must clear it in one step, and
15564        // backspace must remove the whole cluster, not a stray joiner.
15565        let family = "👨‍👩‍👧";
15566        let mut d = doc_with("emoji", &format!("a{family}b\n"));
15567        d.caret = 1; // just after 'a', before the emoji
15568        d.move_right(false);
15569        assert_eq!(
15570            d.caret,
15571            1 + family.len(),
15572            "one step clears the whole cluster"
15573        );
15574        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
15575
15576        d.backspace(); // delete the emoji as a unit
15577        assert_eq!(d.source, "ab\n");
15578        assert_eq!(d.caret, 1);
15579    }
15580
15581    #[test]
15582    fn motion_handles_a_combining_accent_as_one_character() {
15583        // "e" + U+0301 (combining acute) renders as one é.
15584        let mut d = doc_with("combining", "e\u{0301}x\n");
15585        d.caret = 0;
15586        d.move_right(false);
15587        assert_eq!(
15588            d.caret,
15589            "e\u{0301}".len(),
15590            "steps past base + combining mark"
15591        );
15592    }
15593
15594    #[test]
15595    fn undo_then_redo_round_trips_an_edit() {
15596        let mut d = doc_with("undo", "hello\n");
15597        d.caret = 5;
15598        d.insert("!");
15599        assert_eq!(d.source, "hello!\n");
15600        d.undo();
15601        assert_eq!(d.source, "hello\n");
15602        assert_eq!(d.caret, 5, "undo restores the caret");
15603        d.redo();
15604        assert_eq!(d.source, "hello!\n");
15605    }
15606
15607    #[test]
15608    fn a_run_of_typing_undoes_as_one_step() {
15609        let mut d = doc_with("coalesce", "\n");
15610        d.caret = 0;
15611        d.insert("a");
15612        d.insert("b");
15613        d.insert("c");
15614        assert_eq!(d.source, "abc\n");
15615        d.undo(); // the whole typed run, not just "c"
15616        assert_eq!(d.source, "\n");
15617        d.undo(); // nothing left — the run was one step
15618        assert_eq!(d.source, "\n");
15619        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
15620    }
15621
15622    // ── IME composition ──────────────────────────────────────────────────────
15623
15624    #[test]
15625    fn a_composition_run_undoes_as_one_step() {
15626        let mut d = doc_with("compose", "\n");
15627        d.caret = 0;
15628        // What an IME does: each step replaces the last one's provisional bytes.
15629        d.edit_composing(0, 0, "k");
15630        d.edit_composing(0, 1, "か");
15631        d.edit_composing(0, 3, "かん");
15632        d.edit_composing(0, 6, "感"); // the commit
15633        d.end_composition();
15634        assert_eq!(d.source, "感\n");
15635        d.undo(); // the whole composition, not its last keystroke
15636        assert_eq!(d.source, "\n");
15637        assert_eq!(d.status.as_deref(), None, "the run was a single step");
15638    }
15639
15640    /// A Replace All the way a host does one: every match, last to first so
15641    /// the offsets still to go stay put, each a selection replaced by an
15642    /// insert, inside one undo group. `select_range` rather than the host's
15643    /// snapping `place_caret`, which would snap against a map these tests do
15644    /// not rebuild between edits.
15645    fn replace_all_in(d: &mut Doc, needle: &str, with: &str) {
15646        let hits: Vec<usize> = d.source.match_indices(needle).map(|(i, _)| i).collect();
15647        d.begin_undo_group();
15648        for at in hits.into_iter().rev() {
15649            d.select_range(at, at + needle.len());
15650            d.insert(with);
15651        }
15652        d.end_undo_group();
15653    }
15654
15655    #[test]
15656    fn a_replace_all_undoes_and_redoes_as_one_step() {
15657        let mut d = wysiwyg_doc("group", "a cat, a **cat**, a cat\n");
15658        d.caret = 0;
15659        d.insert("x");
15660        replace_all_in(&mut d, "cat", "dog");
15661        assert_eq!(d.source, "xa dog, a **dog**, a dog\n");
15662        assert!(d.undo(), "one undo");
15663        assert_eq!(
15664            d.source, "xa cat, a **cat**, a cat\n",
15665            "puts back every match"
15666        );
15667        assert!(d.redo(), "one redo");
15668        assert_eq!(
15669            d.source, "xa dog, a **dog**, a dog\n",
15670            "replaces them all again"
15671        );
15672        assert!(d.undo());
15673        assert!(d.undo(), "the typing before is its own step");
15674        assert_eq!(d.source, "a cat, a **cat**, a cat\n");
15675        assert!(!d.can_undo());
15676    }
15677
15678    #[test]
15679    fn typing_after_a_group_is_a_step_of_its_own() {
15680        // A one-character replacement is an insert like a keystroke, and a
15681        // keystroke after it would coalesce with it outside a group.
15682        let mut d = wysiwyg_doc("group", "a b a\n");
15683        replace_all_in(&mut d, "a", "c");
15684        d.caret = d.source.len() - 1;
15685        d.insert("d");
15686        assert_eq!(d.source, "c b cd\n");
15687        assert!(d.undo());
15688        assert_eq!(d.source, "c b c\n", "the typing alone");
15689        assert!(d.undo());
15690        assert_eq!(d.source, "a b a\n", "then the replacement, whole");
15691    }
15692
15693    #[test]
15694    fn a_group_of_more_edits_than_the_history_holds_is_still_one_step() {
15695        // twig keeps 200 steps; a group folds as it goes, so it never holds
15696        // more than one of them.
15697        let src = format!("start\n{}\n", "x ".repeat(300));
15698        let mut d = wysiwyg_doc("group", &src);
15699        d.caret = 5;
15700        d.insert("!");
15701        replace_all_in(&mut d, "x", "yy");
15702        assert_eq!(d.undo_steps, 2);
15703        assert!(d.undo());
15704        assert_eq!(d.source, src.replacen("start", "start!", 1));
15705        assert!(d.undo());
15706        assert_eq!(d.source, src);
15707        assert!(!d.can_undo());
15708    }
15709
15710    #[test]
15711    fn an_undo_closes_an_open_group() {
15712        let mut d = wysiwyg_doc("group", "one\n");
15713        d.caret = 3;
15714        d.begin_undo_group();
15715        d.begin_undo_group();
15716        d.insert("x");
15717        assert!(d.in_undo_group());
15718        assert!(d.undo());
15719        assert!(
15720            !d.in_undo_group(),
15721            "the history moved, so the group is over"
15722        );
15723        d.end_undo_group();
15724        d.end_undo_group();
15725        assert_eq!(d.source, "one\n");
15726        assert!(d.redo());
15727        assert_eq!(d.source, "onex\n");
15728    }
15729
15730    #[test]
15731    fn replacing_the_source_keeps_the_caret_on_its_characters() {
15732        let mut d = wysiwyg_doc("replace", "One.\n\nTwo.\n\nThree.\n");
15733        let on = d.source.find("Three").unwrap() + 2;
15734        d.caret = on;
15735        // Another device's edit, before the caret: the caret moves along.
15736        assert!(d.replace_source("One, and more.\n\nTwo.\n\nThree.\n"));
15737        assert_eq!(d.source, "One, and more.\n\nTwo.\n\nThree.\n");
15738        assert_eq!(&d.source[d.caret..], "ree.\n", "still inside Three");
15739        // After the caret: it stays.
15740        let at = d.caret;
15741        assert!(d.replace_source("One, and more.\n\nTwo.\n\nThree.\n\nFour.\n"));
15742        assert_eq!(d.caret, at);
15743        assert!(d.selection().is_none());
15744        // One undo step, the caret where it stood.
15745        assert!(d.undo());
15746        assert_eq!(d.source, "One, and more.\n\nTwo.\n\nThree.\n");
15747        assert_eq!(d.caret, at);
15748    }
15749
15750    #[test]
15751    fn replacing_the_source_writes_markup_as_markup() {
15752        let mut d = wysiwyg_doc("replace-markup", "plain\n");
15753        assert!(d.replace_source("**bold** and *it*\n"));
15754        assert_eq!(
15755            d.source, "**bold** and *it*\n",
15756            "source, not typing: nothing escaped"
15757        );
15758        assert!(d.replace_source("**bold** and *it*\n"), "already there");
15759    }
15760
15761    #[test]
15762    fn the_differing_span_ends_on_characters() {
15763        assert_eq!(differing_span("abc", "abc"), (3, 3, ""));
15764        assert_eq!(differing_span("abXc", "abYc"), (2, 3, "Y"));
15765        assert_eq!(differing_span("aaa", "aaaa"), (3, 3, "a"));
15766        assert_eq!(differing_span("aaaa", "aa"), (2, 4, ""));
15767        // é and è share their first byte; the span must not split it.
15768        assert_eq!(differing_span("café", "cafè"), (3, 5, "è"));
15769        assert_eq!(differing_span("", "x"), (0, 0, "x"));
15770    }
15771
15772    #[test]
15773    fn a_substitution_behind_the_caret_leaves_the_caret_and_is_its_own_step() {
15774        let mut d = wysiwyg_doc("substitute", "\n");
15775        d.caret = 0;
15776        for c in ["I", " ", "s", "a", "w", " ", "t", "e", "h", " "] {
15777            d.insert(c);
15778        }
15779        assert_eq!(d.source, "I saw teh \n");
15780        let at = d.source.find("teh").unwrap();
15781        assert!(d.substitute(at, at + 3, "the"));
15782        assert_eq!(d.source, "I saw the \n");
15783        assert_eq!(d.caret, 10, "still after the space");
15784        assert!(d.selection().is_none());
15785        d.insert("c");
15786        assert_eq!(d.source, "I saw the c\n");
15787        assert!(d.undo(), "the typing after it is a step of its own");
15788        assert_eq!(d.source, "I saw the \n");
15789        assert!(d.undo(), "the correction is one step");
15790        assert_eq!(d.source, "I saw teh \n", "what was typed");
15791        assert_eq!(d.caret, 10, "with the caret where it stood");
15792        assert!(d.redo());
15793        assert_eq!(d.source, "I saw the \n");
15794        assert!(d.undo());
15795        assert!(d.undo(), "and the typing before it is its own");
15796        assert_eq!(d.source, "\n");
15797        assert!(!d.can_undo());
15798    }
15799
15800    #[test]
15801    fn a_substitution_keeps_the_markup_it_stands_beside() {
15802        // A quote typed just past a bold run: only its own byte changes.
15803        let mut d = wysiwyg_doc("substitute", "A **bold** word\n");
15804        let at = d.source.find(" word").unwrap();
15805        d.caret = at;
15806        d.insert("\"");
15807        assert_eq!(d.source, "A **bold**\" word\n");
15808        assert!(d.substitute(at, at + 1, "\u{201d}"));
15809        assert_eq!(d.source, "A **bold**\u{201d} word\n");
15810        assert_eq!(d.caret, at + "\u{201d}".len());
15811        assert!(
15812            d.marks_at(d.source.find("bold").unwrap())
15813                .iter()
15814                .any(|(k, _)| *k == InlineKind::Strong)
15815        );
15816        // A range that is not one is refused, and changes nothing.
15817        let src = d.source.clone();
15818        assert!(!d.substitute(3, 2, "x"));
15819        assert!(!d.substitute(0, src.len() + 1, "x"));
15820        assert!(!d.substitute(d.source.find('\u{201d}').unwrap() + 1, d.source.len(), "x"));
15821        assert_eq!(d.source, src);
15822    }
15823
15824    #[test]
15825    fn a_substitution_that_half_lands_takes_back_only_its_own_deletion() {
15826        // The deletion lands and twig refuses the literal text: the deleted
15827        // bytes go back, and nothing else moves — not an earlier substitution
15828        // of the same keystroke, not the group it is in, and no redo is left
15829        // to delete them again.
15830        let mut d = wysiwyg_doc("substitute", "\n");
15831        d.set_markup_mode(MarkupMode::None);
15832        d.caret = 0;
15833        for c in ["a", "\"", "b", " ", "\""] {
15834            d.insert(c);
15835        }
15836        assert_eq!(d.source, "a\"b \"\n");
15837
15838        d.refuse_next_literal = true;
15839        assert!(!d.substitute(1, 2, "\u{201c}"));
15840        assert_eq!(d.source, "a\"b \"\n");
15841        assert_eq!(d.caret, 5, "the caret where it stood");
15842        assert!(!d.can_redo(), "no redo step left behind");
15843
15844        d.begin_undo_group();
15845        assert!(d.substitute(4, 5, "\u{201d}"));
15846        d.refuse_next_literal = true;
15847        assert!(!d.substitute(1, 2, "\u{201c}"));
15848        assert!(d.in_undo_group(), "the group is still open");
15849        assert_eq!(d.source, "a\"b \u{201d}\n", "the first substitution stands");
15850        d.end_undo_group();
15851        assert!(!d.can_redo());
15852        assert!(d.undo());
15853        assert_eq!(d.source, "a\"b \"\n", "one step takes the group back");
15854        assert!(d.undo());
15855        assert_eq!(d.source, "\n", "and the typing is still one step");
15856        assert!(!d.can_undo());
15857
15858        // A group whose first edit is the one that half lands has no step.
15859        let mut d = wysiwyg_doc("substitute", "\n");
15860        d.set_markup_mode(MarkupMode::None);
15861        d.caret = 0;
15862        d.insert("\"");
15863        d.begin_undo_group();
15864        d.refuse_next_literal = true;
15865        assert!(!d.substitute(0, 1, "\u{201c}"));
15866        assert!(d.substitute(0, 1, "\u{201c}"));
15867        d.end_undo_group();
15868        assert!(d.undo());
15869        assert_eq!(
15870            d.source, "\"\n",
15871            "the substitution that landed is the group's step"
15872        );
15873        assert!(d.undo());
15874        assert_eq!(d.source, "\n");
15875    }
15876
15877    #[test]
15878    fn a_substitution_is_written_the_way_typing_writes() {
15879        // Where typing is literal, so is a replacement's text.
15880        let mut d = wysiwyg_doc("substitute", "say hi\n");
15881        d.set_markup_mode(MarkupMode::None);
15882        assert!(d.substitute(4, 6, "*hi*"));
15883        assert_eq!(d.source, "say \\*hi\\*\n");
15884        assert!(d.undo());
15885        assert_eq!(d.source, "say hi\n", "one step, escapes and all");
15886    }
15887
15888    #[test]
15889    fn can_undo_is_false_once_a_coalesced_run_is_undone() {
15890        // The task's repro: five characters typed are one twig step, and the
15891        // counter used to say five.
15892        let mut d = wysiwyg_doc("undo_truth", "\n");
15893        d.caret = 0;
15894        for c in ["h", "e", "l", "l", "o"] {
15895            d.insert(c);
15896        }
15897        assert!(d.can_undo());
15898        assert!(d.undo(), "the run undoes");
15899        assert_eq!(d.source, "\n");
15900        assert!(!d.can_undo(), "and nothing is left behind it");
15901        let rev = d.revision();
15902        assert!(!d.undo(), "an undo that does not move says so");
15903        assert_eq!(d.revision(), rev);
15904        assert!(d.can_redo());
15905        assert!(d.redo());
15906        assert_eq!(d.source, "hello\n");
15907        assert!(!d.can_redo());
15908        assert!(!d.redo());
15909    }
15910
15911    #[test]
15912    fn can_undo_is_exact_across_the_gestures_that_coalesce() {
15913        // Each gesture below folds, or may fold, one twig step into another.
15914        // After it, the mirror must name exactly the number of undos twig
15915        // takes — and the same number of redos back.
15916        type Gesture = fn(&mut Doc);
15917        let cases: &[(&str, &str, Gesture)] = &[
15918            ("typing", "\n", |d| {
15919                d.caret = 0;
15920                d.insert("ab");
15921                d.insert("c");
15922                d.move_left(false);
15923                d.insert("x");
15924            }),
15925            ("backspaces", "abcdef\n", |d| {
15926                d.caret = 6;
15927                d.backspace();
15928                d.backspace();
15929                d.insert("z");
15930                d.backspace();
15931            }),
15932            ("ordered list item", "1. one\n2. two\n", |d| {
15933                d.caret = 6;
15934                d.newline();
15935                d.insert("x");
15936            }),
15937            ("list indent", "- one\n- two\n", |d| {
15938                d.caret = d.source.find("two").unwrap();
15939                d.indent();
15940                d.outdent();
15941            }),
15942            ("bold then type", "one two\n", |d| {
15943                d.select_range(0, 3);
15944                d.toggle(InlineKind::Strong);
15945                d.caret = d.source.len() - 1;
15946                d.insert("!");
15947            }),
15948            ("highlight", "one two\n", |d| {
15949                d.select_range(0, 3);
15950                d.highlight(None);
15951                d.highlight(Some(MarkColor::Green));
15952            }),
15953            ("heading and quote", "one\n", |d| {
15954                d.caret = 1;
15955                d.toggle_heading(2);
15956                d.toggle_blockquote();
15957            }),
15958            ("footnote", "one\n", |d| {
15959                d.caret = 3;
15960                d.insert_footnote();
15961                d.insert("note");
15962            }),
15963            ("blocks", "one\n\ntwo\n\nthree\n", |d| {
15964                d.caret = 1;
15965                d.move_block_down();
15966                d.toggle_list(true);
15967                d.set_alignment(Some(Align::Center));
15968                d.insert_page_break();
15969                d.insert_table(2, 2);
15970            }),
15971            ("paste and compose", "\n", |d| {
15972                d.caret = 0;
15973                d.paste("one two");
15974                d.delete_word_back();
15975                d.edit_composing(d.caret, d.caret, "か");
15976                d.edit_composing(d.caret - 3, d.caret, "蚊");
15977                d.end_composition();
15978                d.insert("z");
15979            }),
15980            ("table", "| a | b |\n| - | - |\n| c | d |\n", |d| {
15981                d.place_caret(d.source.find("d |").unwrap(), false);
15982                d.cell_tab(true);
15983                d.insert("e");
15984                d.cell_return();
15985            }),
15986            ("replace all", "cat cat cat\n", |d| {
15987                d.caret = 0;
15988                d.insert("a ");
15989                replace_all_in(d, "cat", "dog");
15990                d.insert("!");
15991            }),
15992            ("nested group", "one\n\ntwo\n", |d| {
15993                d.begin_undo_group();
15994                d.caret = 3;
15995                d.insert("x");
15996                d.begin_undo_group();
15997                d.toggle_heading(1);
15998                d.end_undo_group();
15999                d.insert("y");
16000                d.end_undo_group();
16001                d.backspace();
16002            }),
16003            ("group around a list", "1. one\n2. two\n", |d| {
16004                d.begin_undo_group();
16005                d.caret = 6;
16006                d.newline();
16007                d.insert("x");
16008                d.end_undo_group();
16009            }),
16010            ("substitution", "\n", |d| {
16011                d.caret = 0;
16012                d.insert("t");
16013                d.insert("e");
16014                d.insert("h");
16015                d.insert(" ");
16016                d.substitute(0, 3, "the");
16017                d.insert("c");
16018            }),
16019            ("empty group", "one\n", |d| {
16020                d.caret = 3;
16021                d.insert("x");
16022                d.begin_undo_group();
16023                d.end_undo_group();
16024                d.insert("y");
16025            }),
16026            ("undo inside a group", "one\n", |d| {
16027                d.caret = 3;
16028                d.begin_undo_group();
16029                d.insert("x");
16030                d.undo();
16031                d.insert("y");
16032                d.insert("z");
16033                d.end_undo_group();
16034            }),
16035        ];
16036        for (name, src, gesture) in cases {
16037            let mut d = wysiwyg_doc("undo_exact", src);
16038            gesture(&mut d);
16039            let (steps, after) = (d.undo_steps, d.source.clone());
16040            let mut undone = 0;
16041            while d.can_undo() {
16042                assert!(d.undo(), "{name}: can_undo said yes, undo moved nothing");
16043                undone += 1;
16044            }
16045            assert!(!d.undo(), "{name}: can_undo said no, undo moved");
16046            assert_eq!(undone, steps, "{name}");
16047            assert_eq!(d.source, *src, "{name}: back to the start");
16048            let mut redone = 0;
16049            while d.can_redo() {
16050                assert!(d.redo(), "{name}: can_redo said yes, redo moved nothing");
16051                redone += 1;
16052            }
16053            assert!(!d.redo(), "{name}: can_redo said no, redo moved");
16054            assert_eq!(redone, steps, "{name}");
16055            assert_eq!(d.source, after, "{name}: forward to the end");
16056        }
16057    }
16058
16059    #[test]
16060    fn two_compositions_are_two_undo_steps() {
16061        let mut d = doc_with("compose_two", "\n");
16062        d.caret = 0;
16063        d.edit_composing(0, 0, "か");
16064        d.edit_composing(0, 3, "蚊");
16065        d.end_composition();
16066        d.edit_composing(3, 3, "き");
16067        d.edit_composing(3, 6, "木");
16068        d.end_composition();
16069        assert_eq!(d.source, "蚊木\n");
16070        d.undo();
16071        assert_eq!(d.source, "蚊\n", "only the second composition");
16072        d.undo();
16073        assert_eq!(d.source, "\n");
16074    }
16075
16076    #[test]
16077    fn a_composition_does_not_fold_into_the_typing_around_it() {
16078        let mut d = doc_with("compose_typing", "\n");
16079        d.caret = 0;
16080        d.insert("a");
16081        d.insert("b");
16082        d.edit_composing(2, 2, "か");
16083        d.edit_composing(2, 5, "蚊");
16084        d.end_composition();
16085        d.insert("c");
16086        assert_eq!(d.source, "ab蚊c\n");
16087        d.undo();
16088        assert_eq!(d.source, "ab蚊\n");
16089        d.undo();
16090        assert_eq!(d.source, "ab\n");
16091        d.undo();
16092        assert_eq!(d.source, "\n");
16093    }
16094
16095    #[test]
16096    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
16097        let mut d = doc_with("compose_spurious", "\n");
16098        d.caret = 0;
16099        d.insert("a");
16100        d.end_composition(); // an IME unmarking unprompted
16101        d.insert("b");
16102        assert_eq!(d.source, "ab\n");
16103        d.undo();
16104        assert_eq!(d.source, "\n", "still one typed run");
16105    }
16106
16107    // ── the clipboard's rich flavor ──────────────────────────────────────────
16108
16109    #[test]
16110    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
16111        let mut d = doc_with("sel_inline", "a **bold** c\n");
16112        d.anchor = Some(2);
16113        d.caret = 10; // `**bold**`, inside the paragraph
16114        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
16115    }
16116
16117    #[test]
16118    fn a_whole_block_selection_keeps_its_paragraph() {
16119        let mut d = doc_with("sel_block", "a **bold** c\n");
16120        d.anchor = Some(0);
16121        d.caret = 12; // the entire paragraph
16122        assert_eq!(
16123            d.selection_html().as_deref(),
16124            Some("<p>a <strong>bold</strong> c</p>")
16125        );
16126    }
16127
16128    #[test]
16129    fn a_multi_block_selection_keeps_its_structure() {
16130        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
16131        d.select_all();
16132        let html = d.selection_html().expect("renders");
16133        assert!(html.contains("<p>para</p>"), "{html:?}");
16134        assert!(html.contains("<li>one</li>"), "{html:?}");
16135    }
16136
16137    #[test]
16138    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
16139        // The fragment `Head` is a paragraph standalone; the *document* says it
16140        // sits inside one block, so the wrapper is an artifact either way.
16141        let mut d = doc_with("sel_heading", "# Head line\n");
16142        d.anchor = Some(2);
16143        d.caret = 6;
16144        assert_eq!(d.selection_html().as_deref(), Some("Head"));
16145    }
16146
16147    #[test]
16148    fn no_selection_publishes_no_html() {
16149        let mut d = doc_with("sel_none", "a b\n");
16150        d.caret = 1;
16151        assert_eq!(d.selection_html(), None);
16152    }
16153
16154    #[test]
16155    fn pasting_html_converts_it_and_is_one_undo_step() {
16156        let mut d = doc_with("paste_html", "x\n");
16157        d.caret = 1;
16158        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
16159        assert_eq!(d.source, "xa **b** c\n");
16160        d.undo();
16161        assert_eq!(d.source, "x\n", "the whole paste, in one step");
16162    }
16163
16164    #[test]
16165    fn pasting_html_replaces_the_selection() {
16166        let mut d = doc_with("paste_html_sel", "keep drop\n");
16167        d.anchor = Some(5);
16168        d.caret = 9;
16169        assert!(d.paste_html("<em>new</em>"));
16170        assert_eq!(d.source, "keep *new*\n");
16171    }
16172
16173    #[test]
16174    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
16175        let mut d = doc_with("paste_html_bad", "x\n");
16176        d.caret = 1;
16177        // twig builds no table from HTML; raw `<table>` in prose is worse than
16178        // the plain flavor the caller still holds.
16179        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
16180        assert_eq!(d.source, "x\n", "declined edits nothing");
16181    }
16182
16183    #[test]
16184    fn copy_then_paste_round_trips_through_the_html_flavor() {
16185        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
16186        d.select_all();
16187        let html = d.selection_html().expect("renders");
16188        let mut into = doc_with("clip_round_dst", "\n");
16189        into.caret = 0;
16190        assert!(into.paste_html(&html));
16191        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
16192    }
16193
16194    #[test]
16195    fn moving_the_caret_starts_a_new_undo_group() {
16196        let mut d = doc_with("break", "\n");
16197        d.caret = 0;
16198        d.insert("a");
16199        d.insert("b"); // "ab\n", caret at 2
16200        d.move_left(false); // breaks the run
16201        d.insert("X"); // "aXb\n"
16202        assert_eq!(d.source, "aXb\n");
16203        d.undo();
16204        assert_eq!(
16205            d.source, "ab\n",
16206            "first undo removes only the post-move insert"
16207        );
16208        d.undo();
16209        assert_eq!(d.source, "\n", "second undo removes the earlier run");
16210    }
16211
16212    #[test]
16213    fn undo_reverses_a_format_toggle() {
16214        let mut d = doc_with("fmt_undo", "a word b\n");
16215        d.anchor = Some(2);
16216        d.caret = 6;
16217        d.toggle(InlineKind::Strong);
16218        assert_eq!(d.source, "a **word** b\n");
16219        d.undo();
16220        assert_eq!(d.source, "a word b\n");
16221    }
16222
16223    #[test]
16224    fn undo_back_to_the_saved_state_clears_dirty() {
16225        let mut d = doc_with("dirty_undo", "hello\n");
16226        assert!(!d.dirty);
16227        d.caret = 5;
16228        d.insert("!");
16229        assert!(d.dirty);
16230        d.undo();
16231        assert!(
16232            !d.dirty,
16233            "undoing to the saved source is not a modification"
16234        );
16235    }
16236
16237    #[test]
16238    fn marking_saved_as_what_was_written_keeps_later_typing_dirty() {
16239        let mut d = doc_with("saved_as", "Does this\n");
16240        d.caret = 9;
16241        d.insert(" ");
16242        // The host reads the source, and its write takes a while…
16243        let written = d.source.clone();
16244        // …during which the rest of the sentence is typed.
16245        d.insert("work?");
16246        d.mark_saved_as(&written);
16247        assert!(d.dirty, "what was typed during the write is not saved");
16248        d.undo();
16249        assert!(!d.dirty, "undoing to what was written is the saved state");
16250
16251        d.redo();
16252        let written = d.source.clone();
16253        d.mark_saved_as(&written);
16254        assert!(!d.dirty, "nothing typed meanwhile: saved");
16255    }
16256
16257    #[test]
16258    fn a_new_edit_invalidates_redo() {
16259        let mut d = doc_with("redo_inv", "\n");
16260        d.caret = 0;
16261        d.insert("a");
16262        d.undo();
16263        d.insert("b"); // diverges — the redo of "a" is now gone
16264        d.redo();
16265        assert_eq!(d.source, "b\n");
16266    }
16267
16268    #[test]
16269    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
16270        let mut d = doc_with("can_undo", "hello\n");
16271        assert!(
16272            !d.can_undo() && !d.can_redo(),
16273            "a fresh document has no history"
16274        );
16275        d.caret = 5;
16276        d.insert("!");
16277        assert!(
16278            d.can_undo() && !d.can_redo(),
16279            "an edit is a step to take back"
16280        );
16281        d.undo();
16282        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
16283        d.redo();
16284        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
16285        d.undo();
16286        d.insert("?");
16287        assert!(
16288            d.can_undo() && !d.can_redo(),
16289            "a fresh edit ends the redo chain"
16290        );
16291        // A coalesced run over-counts steps — the bound is what a menu needs,
16292        // and it reconciles the moment twig reports the history empty.
16293        d.insert("a");
16294        d.insert("b");
16295        while d.can_undo() {
16296            d.undo();
16297        }
16298        assert_eq!(d.source, "hello\n");
16299        assert!(!d.can_undo());
16300        // A reading surface has nothing to undo, whatever the history holds.
16301        d.redo();
16302        d.set_read_only(true);
16303        assert!(!d.can_undo() && !d.can_redo());
16304    }
16305
16306    #[test]
16307    fn undo_on_empty_history_is_a_no_op() {
16308        let mut d = doc_with("undo_empty", "hi\n");
16309        d.undo();
16310        assert_eq!(d.source, "hi\n");
16311        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
16312    }
16313
16314    #[test]
16315    fn a_one_character_paste_is_its_own_undo_step() {
16316        for view in [View::Source, View::Wysiwyg] {
16317            let mut d = doc_in(view, "paste_step", "ab\n");
16318            d.caret = 0;
16319            d.insert("x");
16320            d.insert("y"); // a run of typing
16321            d.paste("z"); // one character, but pasted — not part of that run
16322            assert_eq!(d.source, "xyzab\n");
16323            d.undo();
16324            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
16325            assert_eq!(d.caret, 2, "and hands back the caret it found");
16326            d.undo();
16327            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
16328        }
16329    }
16330
16331    #[test]
16332    fn the_same_character_typed_still_joins_the_run() {
16333        // The other half of the pair: `z` is a keystroke here and a paste above,
16334        // and the two undo differently. Nothing about the *string* says which —
16335        // which is why provenance has to come from the door the caller uses.
16336        for view in [View::Source, View::Wysiwyg] {
16337            let mut d = doc_in(view, "typed_run", "ab\n");
16338            d.caret = 0;
16339            d.insert("x");
16340            d.insert("y");
16341            d.insert("z");
16342            d.undo();
16343            assert_eq!(d.source, "ab\n", "one run, one step");
16344        }
16345    }
16346
16347    #[test]
16348    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
16349        for view in [View::Source, View::Wysiwyg] {
16350            let mut d = doc_in(view, "undo_caret", "hello world\n");
16351            d.caret = 11; // standing at the end of "world", away from the edit
16352            d.edit(0, 5, "goodbye");
16353            assert_eq!(d.source, "goodbye world\n");
16354            d.undo();
16355            assert_eq!(d.source, "hello world\n");
16356            // The undone edit ends at offset 5; the user was at 11.
16357            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
16358        }
16359    }
16360
16361    #[test]
16362    fn undo_restores_the_selection_the_edit_replaced() {
16363        for view in [View::Source, View::Wysiwyg] {
16364            let mut d = doc_in(view, "undo_sel", "a word b\n");
16365            d.anchor = Some(2);
16366            d.caret = 6; // "word" selected
16367            d.insert("X");
16368            assert_eq!(d.source, "a X b\n");
16369            d.undo();
16370            assert_eq!(d.source, "a word b\n");
16371            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
16372        }
16373    }
16374
16375    #[test]
16376    fn redo_restores_the_caret_the_edit_left_behind() {
16377        for view in [View::Source, View::Wysiwyg] {
16378            let mut d = doc_in(view, "redo_caret", "hello world\n");
16379            d.caret = 11;
16380            d.edit(0, 5, "goodbye");
16381            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
16382            d.undo();
16383            d.redo();
16384            assert_eq!(d.source, "goodbye world\n");
16385            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
16386        }
16387    }
16388
16389    #[test]
16390    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
16391        for view in [View::Source, View::Wysiwyg] {
16392            let mut d = doc_in(view, "run_caret", "hi\n");
16393            d.caret = 2;
16394            d.insert("a");
16395            d.insert("b");
16396            d.insert("c");
16397            assert_eq!(d.source, "hiabc\n");
16398            d.undo();
16399            assert_eq!(d.source, "hi\n");
16400            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
16401            d.redo();
16402            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
16403        }
16404    }
16405
16406    #[test]
16407    fn undo_restores_the_caret_across_a_format_toggle() {
16408        // A toggle reaches twig without going through `splice`, so it has to
16409        // record its own step — miss it and every stack depth below it is off by
16410        // one, and undo starts handing back another edit's caret.
16411        for view in [View::Source, View::Wysiwyg] {
16412            let mut d = doc_in(view, "fmt_caret", "a word b\n");
16413            d.caret = 8;
16414            d.anchor = Some(2);
16415            d.caret = 6;
16416            d.toggle(InlineKind::Strong);
16417            assert_eq!(d.source, "a **word** b\n");
16418            d.undo();
16419            assert_eq!(d.source, "a word b\n");
16420            assert_eq!(
16421                d.selection(),
16422                Some((2, 6)),
16423                "the toggled selection comes back"
16424            );
16425        }
16426    }
16427
16428    #[test]
16429    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
16430        // The drift that would never announce itself: twig drops its redo stack
16431        // on any fresh edit, so a leaf redo entry that outlives it would restore
16432        // a caret from the timeline that edit abandoned.
16433        for view in [View::Source, View::Wysiwyg] {
16434            let mut d = doc_in(view, "redo_trunc", "hello world\n");
16435            d.caret = 11;
16436            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
16437            d.undo();
16438            assert_eq!(d.caret, 11);
16439            d.caret = 0;
16440            d.insert("X"); // diverges: A's redo is gone from twig
16441            assert_eq!(d.source, "Xhello world\n");
16442
16443            d.redo();
16444            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
16445            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
16446            d.undo();
16447            assert_eq!(d.source, "hello world\n");
16448            assert_eq!(
16449                d.caret, 0,
16450                "the surviving step's caret, not the dropped one"
16451            );
16452        }
16453    }
16454
16455    #[test]
16456    fn indent_and_outdent_move_the_caret_line_with_its_text() {
16457        for view in [View::Source, View::Wysiwyg] {
16458            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
16459            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
16460            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
16461            // Indentation the caret is standing *in* collapses to the line start
16462            // rather than dragging the caret into the text.
16463            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
16464            // A line with none to give back is left exactly as it was.
16465            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
16466            // Less than a full level gives back what it has.
16467            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
16468            // A tab is one level however many spaces it isn't.
16469            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
16470        }
16471    }
16472
16473    #[test]
16474    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
16475        // Why the level is two spaces and not the four both frontends type
16476        // today. Four is markdown's indented-code-block marker, so a Tab on a
16477        // paragraph would silently restyle it as code — a width that changes
16478        // what the document *means* isn't an indent. Pinned because the number
16479        // is the kind of thing a later list-aware pass would reach for.
16480        let mut d = doc_with("indent_kind", "hello\n");
16481        d.caret = 2;
16482        d.indent();
16483        assert_eq!(d.source, "  hello\n");
16484        assert!(
16485            d.nodes().iter().any(|n| n.kind == Kind::Para),
16486            "still prose after a Tab"
16487        );
16488        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
16489
16490        // The four-space level this replaces, for contrast: same text, and twig
16491        // reparses the paragraph into a code block.
16492        let mut wide = doc_with("indent_kind_4", "    hello\n");
16493        wide.build_visual(80);
16494        assert!(
16495            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
16496            "four spaces is a code block, not an indented paragraph"
16497        );
16498    }
16499
16500    #[test]
16501    fn indent_nests_a_list_item_under_its_parent() {
16502        // Tab indents a list item by its own marker width, landing its marker at
16503        // the parent's content column so twig reparses it as a nested list.
16504        for view in [View::Source, View::Wysiwyg] {
16505            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
16506            d.caret = 6; // on the second item
16507            d.indent();
16508            assert_eq!(d.source, "- a\n  - b\n");
16509            let lists = d
16510                .nodes()
16511                .iter()
16512                .filter(|n| n.kind == Kind::BulletList)
16513                .count();
16514            assert_eq!(lists, 2, "the indented item is a nested list");
16515        }
16516    }
16517
16518    #[test]
16519    fn indent_nests_an_ordered_item_at_its_marker_width() {
16520        // An ordered marker `1. ` is three columns wide, so a two-space step
16521        // (which nests a bullet) leaves it flat. Regression: Tab must use the
16522        // marker width, three, so the item actually nests — and the source
16523        // renumbers so the sub-list restarts at 1 and the outer list resumes.
16524        for view in [View::Source, View::Wysiwyg] {
16525            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
16526            d.caret = d.source.find('b').unwrap();
16527            d.indent();
16528            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
16529            let lists = d
16530                .nodes()
16531                .iter()
16532                .filter(|n| n.kind == Kind::OrderedList)
16533                .count();
16534            assert_eq!(lists, 2, "the indented item is a nested ordered list");
16535        }
16536    }
16537
16538    #[test]
16539    fn indent_leaves_a_lists_first_item_put() {
16540        // The first item of a list has no sibling above it to nest under, so Tab
16541        // is a no-op there — the marker stays at column zero rather than being
16542        // shoved into indentation twig can't read as a sub-list.
16543        for view in [View::Source, View::Wysiwyg] {
16544            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
16545            d.caret = 1; // on the FIRST item
16546            d.indent();
16547            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
16548            // The sibling below still nests, proving the guard is per-item.
16549            d.caret = d.source.find('b').unwrap();
16550            d.indent();
16551            assert_eq!(d.source, "- a\n  - b\n");
16552        }
16553    }
16554
16555    #[test]
16556    fn hidden_mode_keeps_typed_markup_literal() {
16557        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
16558        // twig escapes what would open markup, so the source is `\*hi\*` and the
16559        // AST is a plain string. Formatting is the commands' job in this mode.
16560        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
16561        d.insert("*hi*");
16562        assert_eq!(d.source, "\\*hi\\*");
16563        assert!(
16564            d.nodes()
16565                .iter()
16566                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
16567        );
16568    }
16569
16570    #[test]
16571    fn hidden_mode_escapes_a_line_start_block_marker() {
16572        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
16573        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
16574        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
16575        d.insert("# hi");
16576        assert_eq!(d.source, "\\# hi");
16577        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
16578    }
16579
16580    #[test]
16581    fn authoring_modes_keep_typed_markup_live() {
16582        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
16583        // (no escape), the same as source view — escaping is `None`'s alone, and
16584        // it's the axis, not the reveal, that decides.
16585        for (view, mode) in [
16586            (View::Wysiwyg, MarkupMode::Shortcuts),
16587            (View::Wysiwyg, MarkupMode::Full),
16588            (View::Source, MarkupMode::None),
16589        ] {
16590            let mut d = doc_in(view, "live_markup", "");
16591            d.set_markup_mode(mode);
16592            d.insert("*hi*");
16593            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
16594        }
16595    }
16596
16597    #[test]
16598    fn hidden_mode_overwrite_undoes_in_one_step() {
16599        // Typing over a selection escapes the replacement *and* stays a single
16600        // undo — the selection-delete and the literal insert fold together, so
16601        // one undo brings the whole selection back, like a plain overwrite.
16602        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
16603        d.anchor = Some(2);
16604        d.caret = 6; // "word"
16605        d.insert("*");
16606        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
16607        d.undo();
16608        assert_eq!(d.source, "a word b\n");
16609        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
16610    }
16611
16612    #[test]
16613    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
16614        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
16615        // the whole visual character, never stranding the hidden `\`.
16616        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
16617        d.insert("*");
16618        assert_eq!(d.source, "\\*");
16619        d.backspace();
16620        assert_eq!(d.source, "", "the escape backslash went with the *");
16621        // A *literal* backslash (source view, no escape) is an ordinary char.
16622        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
16623        s.caret = 3; // after `b`
16624        s.backspace();
16625        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
16626    }
16627
16628    #[test]
16629    fn hidden_mode_leaves_structural_markup_alone() {
16630        // Enter continues a bullet list by writing a real `- ` marker (an
16631        // `insert_raw`, not the typing path), so Hidden mode's escaping never
16632        // touches it — the list keeps working.
16633        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
16634        d.caret = 6;
16635        d.newline();
16636        d.insert("two");
16637        assert_eq!(d.source, "- item\n- two\n");
16638    }
16639
16640    #[test]
16641    fn markup_mode_defaults_to_none_and_round_trips() {
16642        // Diaryx's default is the clean `None` surface; a markup-fluent
16643        // frontend can climb the ladder, and the choice sticks.
16644        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
16645        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
16646        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
16647            d.set_markup_mode(mode);
16648            assert_eq!(d.markup_mode(), mode);
16649        }
16650    }
16651
16652    #[test]
16653    fn full_mode_reveals_only_the_caret_line() {
16654        // The mode's whole claim: the caret's line shows its raw delimiters and
16655        // every other line stays resolved. Two paragraphs with identical markup
16656        // so the only difference between the rows is where the caret is.
16657        let mut d = doc_in(
16658            View::Wysiwyg,
16659            "reveal_caret_line",
16660            "*one* here\n\n*two* there\n",
16661        );
16662        d.set_markup_mode(MarkupMode::Full);
16663
16664        caret_at(&mut d, "one");
16665        let rows = drawn_rows(&d);
16666        assert!(
16667            rows.iter().any(|r| r == "*one* here"),
16668            "caret's line raw: {rows:?}"
16669        );
16670        assert!(
16671            rows.iter().any(|r| r == "two there"),
16672            "other line resolved: {rows:?}"
16673        );
16674
16675        // Move to the other paragraph: the reveal follows, and the line just
16676        // left goes back to being resolved.
16677        caret_at(&mut d, "two");
16678        let rows = drawn_rows(&d);
16679        assert!(
16680            rows.iter().any(|r| r == "*two* there"),
16681            "caret's line raw: {rows:?}"
16682        );
16683        assert!(
16684            rows.iter().any(|r| r == "one here"),
16685            "left line resolved: {rows:?}"
16686        );
16687    }
16688
16689    #[test]
16690    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
16691        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
16692        // the same treatment as an emphasis's `*`: hidden while the caret is
16693        // elsewhere, shown in full where the caret lands. That falls out of
16694        // `delims` reading the bytes between the mark's span and its content
16695        // span, which is exactly `==🔴 ` and `==`, rather than from a table
16696        // of spellings — so the no-space form `==🟢green==` reveals right too.
16697        let mut d = doc_in(
16698            View::Wysiwyg,
16699            "reveal_coloured_mark",
16700            "a ==🔴 red== one\n\nb ==plain== two\n",
16701        );
16702        d.set_markup_mode(MarkupMode::Full);
16703
16704        caret_at(&mut d, "red");
16705        let rows = drawn_rows(&d);
16706        assert!(
16707            rows.iter().any(|r| r == "a ==🔴 red== one"),
16708            "the caret's line shows the colour it was written with: {rows:?}"
16709        );
16710        assert!(
16711            rows.iter().any(|r| r == "b plain two"),
16712            "and every other line stays resolved: {rows:?}"
16713        );
16714
16715        // Away from it, the emoji goes back to being markup — the reader sees
16716        // the words and the wash.
16717        caret_at(&mut d, "two");
16718        let rows = drawn_rows(&d);
16719        assert!(
16720            rows.iter().any(|r| r == "a red one"),
16721            "resolved again: {rows:?}"
16722        );
16723    }
16724
16725    #[test]
16726    fn hidden_modes_never_reveal_wherever_the_caret_is() {
16727        // The two rungs below `Full` share a rendering: delimiters stay hidden
16728        // even under the caret. `Shortcuts` differing from `None` only in what
16729        // typing does is exactly the point of splitting the axes.
16730        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
16731            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
16732            d.set_markup_mode(mode);
16733            caret_at(&mut d, "one");
16734            let rows = drawn_rows(&d);
16735            assert!(
16736                rows.iter().any(|r| r == "one here"),
16737                "{mode:?} hides: {rows:?}"
16738            );
16739            assert!(
16740                !rows.iter().any(|r| r.contains('*')),
16741                "{mode:?} shows no `*`: {rows:?}"
16742            );
16743        }
16744    }
16745
16746    #[test]
16747    fn revealed_delimiters_are_the_authors_own_spelling() {
16748        // Delimiters are re-read from the source rather than synthesized per
16749        // kind, so a line comes back spelled the way it was written: `_em_` does
16750        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
16751        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
16752        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
16753        d.set_markup_mode(MarkupMode::Full);
16754        caret_at(&mut d, "em");
16755        let rows = drawn_rows(&d);
16756        assert!(
16757            rows.iter().any(|r| r == body.trim_end()),
16758            "the revealed line is its own source: {rows:?}"
16759        );
16760    }
16761
16762    #[test]
16763    fn revealed_heading_shows_its_hashes() {
16764        // The `# ` marker is a block-level prefix, not an inline delimiter, so
16765        // it takes its own path — but it reveals on the same rule.
16766        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
16767        d.set_markup_mode(MarkupMode::Full);
16768
16769        caret_at(&mut d, "Title");
16770        assert!(
16771            drawn_rows(&d).iter().any(|r| r == "# Title"),
16772            "{:?}",
16773            drawn_rows(&d)
16774        );
16775
16776        caret_at(&mut d, "body");
16777        let rows = drawn_rows(&d);
16778        assert!(
16779            rows.iter().any(|r| r == "Title"),
16780            "hashes hidden again: {rows:?}"
16781        );
16782    }
16783
16784    #[test]
16785    fn revealed_delimiters_are_caret_stops() {
16786        // A delimiter that is drawn but can't be reached is worse than one
16787        // that's hidden: the mode exists so the markup can be *edited*. Every
16788        // revealed byte must be somewhere the caret can stand.
16789        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
16790        d.set_markup_mode(MarkupMode::Full);
16791        caret_at(&mut d, "em");
16792        let opener = d.source.find('*').unwrap();
16793        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
16794        assert!(
16795            d.vmap.is_stop(opener + 3),
16796            "the closing `*` is a caret stop"
16797        );
16798    }
16799
16800    #[test]
16801    fn setext_heading_reveals_nothing_across_its_newline() {
16802        // A setext heading's underline is on another line, so it is not the
16803        // caret line's to reveal — and emitting it would inject a `\n` glyph
16804        // that splits the row where the author wrote no break.
16805        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
16806        d.set_markup_mode(MarkupMode::Full);
16807        caret_at(&mut d, "Title");
16808        let rows = drawn_rows(&d);
16809        assert!(
16810            rows.iter().any(|r| r == "Title"),
16811            "title renders alone: {rows:?}"
16812        );
16813        assert!(
16814            !rows.iter().any(|r| r.contains('=')),
16815            "no underline leaks in: {rows:?}"
16816        );
16817    }
16818
16819    #[test]
16820    fn markup_mode_axes_split_the_ladder() {
16821        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
16822        // that authors markup but still hides it, and it's the only rung where
16823        // the two axes disagree.
16824        assert!(!MarkupMode::None.authors());
16825        assert!(!MarkupMode::None.reveals_caret_line());
16826        assert!(MarkupMode::Shortcuts.authors());
16827        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
16828        assert!(MarkupMode::Full.authors());
16829        assert!(MarkupMode::Full.reveals_caret_line());
16830    }
16831
16832    #[test]
16833    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
16834        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
16835        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
16836        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
16837        // nested bullet and `hello` stays prose: the file round-trips instead of
16838        // hiding a heading the user never asked for.
16839        for view in [View::Source, View::Wysiwyg] {
16840            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
16841            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
16842            d.indent();
16843            assert_eq!(d.source, "- hello\n  * \n");
16844            assert!(
16845                d.nodes().iter().all(|n| n.kind != Kind::Heading),
16846                "no heading"
16847            );
16848            // And it's genuinely a nested list, not a flat one.
16849            assert_eq!(
16850                d.nodes()
16851                    .iter()
16852                    .filter(|n| n.kind == Kind::BulletList)
16853                    .count(),
16854                2
16855            );
16856        }
16857    }
16858
16859    #[test]
16860    fn indenting_a_dash_item_with_content_keeps_its_dash() {
16861        // With content, `- x` can't be a setext underline, so there's nothing to
16862        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
16863        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
16864        d.caret = d.source.find('x').unwrap();
16865        d.indent();
16866        assert_eq!(d.source, "- hello\n  - x\n");
16867    }
16868
16869    #[test]
16870    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
16871        // The dash→`*` repair coalesces into the Tab, so a single undo restores
16872        // the whole pre-Tab state rather than stranding a half-collapsed doc.
16873        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
16874        d.caret = d.source.find("- \n").unwrap() + 2;
16875        d.indent();
16876        assert_eq!(d.source, "- hello\n  * \n");
16877        d.undo();
16878        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
16879    }
16880
16881    #[test]
16882    fn indent_leaves_a_nested_lists_first_item_put_too() {
16883        // The guard is about siblings, not depth: the first item of an *inner*
16884        // list (already nested under `a`) still has nothing before it at its own
16885        // level, so Tab can't take it deeper.
16886        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
16887        d.caret = d.source.find('b').unwrap();
16888        d.indent();
16889        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
16890        // But `c` (a sibling of `b`) nests under `b`.
16891        d.caret = d.source.find('c').unwrap();
16892        d.indent();
16893        assert_eq!(d.source, "- a\n  - b\n    - c\n");
16894    }
16895
16896    #[test]
16897    fn backspace_at_a_nested_item_start_outdents_it() {
16898        // Backspace with the caret right after a nested item's marker gives back
16899        // one level of nesting, the mirror of Tab — and renumbers the flattened
16900        // ordered list back to a clean run.
16901        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
16902        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
16903        d.backspace();
16904        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
16905    }
16906
16907    #[test]
16908    fn backspace_at_a_top_level_item_start_strips_the_marker() {
16909        // At the outermost level there's no nesting left to give back, so the same
16910        // keystroke drops the bullet and leaves a plain paragraph.
16911        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
16912        d.caret = d.source.find('b').unwrap(); // right after `- `
16913        d.backspace();
16914        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
16915    }
16916
16917    #[test]
16918    fn backspace_mid_item_still_deletes_a_character() {
16919        // The list behaviour is armed only at the item's content start; anywhere
16920        // else Backspace is the ordinary character delete.
16921        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
16922        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
16923        d.backspace();
16924        assert_eq!(d.source, "- b\n");
16925    }
16926
16927    #[test]
16928    fn backspace_at_a_heading_start_strips_the_marker() {
16929        // The `# ` is markup the rich view hides, so Backspace over it takes the
16930        // whole marker and leaves a paragraph. Deleting a byte of it instead left
16931        // `#Title` — no longer a heading, with the hash now literal text the user
16932        // never typed and has to delete again.
16933        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
16934        d.caret = d.source.find('T').unwrap(); // right after `## `
16935        d.backspace();
16936        assert_eq!(d.source, "Title\n");
16937        assert_eq!(
16938            d.caret, 0,
16939            "the caret stays with the text it was in front of"
16940        );
16941    }
16942
16943    #[test]
16944    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
16945        // Only the heading's own marker goes — the quote (or list) it sits in is
16946        // untouched, exactly as un-heading it should be.
16947        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
16948        d.caret = d.source.find('T').unwrap();
16949        d.backspace();
16950        assert_eq!(d.source, "> Title\n");
16951    }
16952
16953    #[test]
16954    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
16955        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
16956        // behind would surface the same stray hash the marker delete just avoided.
16957        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
16958        d.caret = d.source.find('T').unwrap();
16959        d.backspace();
16960        assert_eq!(d.source, "Title\n");
16961        // And it's one edit: a single undo puts the whole heading back.
16962        d.undo();
16963        assert_eq!(d.source, "# Title #\n");
16964    }
16965
16966    #[test]
16967    fn backspace_mid_heading_still_deletes_a_character() {
16968        // The heading behaviour is armed only at the content's start; anywhere
16969        // else Backspace is the ordinary character delete.
16970        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
16971        d.caret = d.source.find('b').unwrap();
16972        d.backspace();
16973        assert_eq!(d.source, "# b\n");
16974    }
16975
16976    #[test]
16977    fn source_view_backspace_still_edits_the_heading_marker_literally() {
16978        // In source view the `# ` is text on the screen the user is deleting a
16979        // byte of, so it keeps its literal meaning — the same split the list
16980        // ladder and Enter draw between the two views.
16981        let mut d = doc_with("bsp_head_src", "# Title\n");
16982        d.caret = d.source.find('T').unwrap();
16983        d.backspace();
16984        assert_eq!(d.source, "#Title\n");
16985    }
16986
16987    #[test]
16988    fn outdent_unnests_an_ordered_item_in_one_press() {
16989        // Shift+Tab gives back exactly the marker width the indent added, so a
16990        // nested ordered item unnests in a single press, and the flattened list
16991        // renumbers back to a clean 1, 2, 3.
16992        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
16993        d.caret = d.source.find('b').unwrap();
16994        d.outdent();
16995        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
16996        let lists = d
16997            .nodes()
16998            .iter()
16999            .filter(|n| n.kind == Kind::OrderedList)
17000            .count();
17001        assert_eq!(lists, 1, "back to one flat list");
17002    }
17003
17004    #[test]
17005    fn table_insert_row_adds_a_row_below_the_caret() {
17006        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
17007        d.caret = d.source.find('1').unwrap(); // in the body row
17008        d.table_insert_row(true);
17009        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
17010    }
17011
17012    #[test]
17013    fn table_insert_and_delete_column_at_the_caret() {
17014        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
17015        d.caret = d.source.find('a').unwrap(); // column 0
17016        d.table_insert_column(true); // add a column to the right of `a`
17017        assert_eq!(
17018            d.source,
17019            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
17020        );
17021        d.caret = d.source.find('b').unwrap(); // now the third column
17022        d.table_delete_column();
17023        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
17024    }
17025
17026    // ── ragged formats ───────────────────────────────────────────────────────
17027    // No format spells every gesture. HTML writes the inline marks as a tag pair
17028    // and no heading, list, quote or link; Markdown spells five of the eight
17029    // marks — the highlight only because leaf parses with `highlight`, which is
17030    // why the question is asked with the extensions; djot spells all eight and
17031    // no in-cell break. leaf asks twig per
17032    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
17033    // op discover the fact on its own — one of them didn't.
17034
17035    /// An HTML document in the rich view, ready for a gesture.
17036    fn html_doc(body: &str) -> Doc {
17037        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
17038        d.view = View::Wysiwyg;
17039        d.build_visual(80);
17040        d
17041    }
17042
17043    #[test]
17044    fn a_table_gesture_leaves_an_html_table_alone() {
17045        // The regression this guard exists for. twig's table editor consults no
17046        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
17047        // HTML `<table>` as a *pipe table* and reported success: the whole
17048        // element replaced by `| a | b |`, silently, on one press of a toolbar
17049        // button. Every grid op went the same way.
17050        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
17051        // A table of named operations, which is what it looks like.
17052        #[allow(clippy::type_complexity)]
17053        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
17054            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
17055            ("delete row", &|d: &mut Doc| d.table_delete_row()),
17056            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
17057            ("delete column", &|d: &mut Doc| d.table_delete_column()),
17058            ("align", &|d: &mut Doc| {
17059                d.table_set_alignment(Alignment::Right)
17060            }),
17061            ("move row", &|d: &mut Doc| d.table_move_row(true)),
17062            ("move column", &|d: &mut Doc| d.table_move_column(true)),
17063        ];
17064        for (name, op) in ops {
17065            let mut d = html_doc(src);
17066            d.caret = d.source.find('a').unwrap();
17067            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
17068            op(&mut d);
17069            assert_eq!(d.source, src, "{name} rewrote an HTML table");
17070            assert!(
17071                !d.dirty,
17072                "{name} marked the document dirty without editing it"
17073            );
17074            assert!(d.status.is_some(), "{name} refused without saying why");
17075        }
17076    }
17077
17078    #[test]
17079    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
17080        // A task box is a form control in HTML and a footnote has no native
17081        // spelling at all — the two gestures twig 3.5 still spells nothing
17082        // for, now that a quote, a list, a link and an image print through
17083        // its renderer (see the test below).
17084        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
17085        // A table of named operations, which is what it looks like.
17086        #[allow(clippy::type_complexity)]
17087        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
17088            ("task item", &|d: &mut Doc| d.toggle_task_item()),
17089            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
17090            ("footnote", &|d: &mut Doc| d.insert_footnote()),
17091        ];
17092        for (name, op) in ops {
17093            let mut d = html_doc(src);
17094            let at = d.source.find("Hello").unwrap();
17095            d.caret = at;
17096            d.anchor = Some(at + 5); // a selection, for the ops that want one
17097            op(&mut d);
17098            assert_eq!(d.source, src, "{name} edited an HTML document");
17099            assert!(
17100                !d.dirty,
17101                "{name} marked the document dirty without editing it"
17102            );
17103            let status = d.status.as_deref().unwrap_or("");
17104            assert!(
17105                status.contains("html"),
17106                "{name}: the refusal should name the format, got {status:?}"
17107            );
17108        }
17109    }
17110
17111    #[test]
17112    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
17113        // twig 3.5: where HTML has no marker alphabet it prints the fresh
17114        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
17115        // the paragraph as its item, an `<a>` or `<img>` over the selection.
17116        // Until then every one of these was a refusal; now each is a real
17117        // edit, which is what the toolbar's capability flags say too.
17118        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
17119        #[allow(clippy::type_complexity)]
17120        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
17121            (
17122                "quote",
17123                &|d: &mut Doc| d.toggle_blockquote(),
17124                "<blockquote>",
17125            ),
17126            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
17127            (
17128                "ordered list",
17129                &|d: &mut Doc| d.toggle_list(true),
17130                "<ol>\n<li>",
17131            ),
17132            (
17133                "link",
17134                &|d: &mut Doc| d.insert_link("https://example.dev"),
17135                "<a href=\"https://example.dev\">Hello</a>",
17136            ),
17137            (
17138                "image",
17139                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
17140                "<img alt=\"Hello\" src=\"pic.png\">",
17141            ),
17142        ];
17143        for (name, op, expect) in ops {
17144            let mut d = html_doc(src);
17145            let at = d.source.find("Hello").unwrap();
17146            d.caret = at;
17147            d.anchor = Some(at + 5);
17148            op(&mut d);
17149            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
17150            assert!(d.dirty, "{name}: a real edit");
17151            assert_eq!(
17152                d.status, None,
17153                "{name}: a supported gesture reports nothing"
17154            );
17155        }
17156    }
17157
17158    #[test]
17159    fn html_spells_a_heading_as_its_tag_pair() {
17160        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
17161        // along — the one block gesture whose HTML shape it can write. So ⌘2
17162        // in an HTML document is a real edit, and ⌘0 takes it back.
17163        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
17164        let mut d = html_doc(src);
17165        d.caret = d.source.find("Hello").unwrap();
17166        d.toggle_heading(2);
17167        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
17168        assert!(d.dirty);
17169        assert_eq!(d.status, None, "a supported gesture reports nothing");
17170        d.toggle_heading(2);
17171        assert_eq!(d.source, src, "the same level again is back to a paragraph");
17172    }
17173
17174    #[test]
17175    fn html_spells_the_inline_marks_and_the_rule() {
17176        // The other half, and why one per-document flag stopped being enough:
17177        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
17178        // already emits and the parser reads straight back as the same mark —
17179        // and the rule button writes an `<hr>`. Refusing these on the old
17180        // "HTML is parse-only" reading would now be leaf's own limitation.
17181        let mut d = html_doc("<p>Hello world</p>\n");
17182        let at = d.source.find("world").unwrap();
17183        d.caret = at;
17184        d.anchor = Some(at + 5);
17185        d.toggle(InlineKind::Strong);
17186        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
17187        assert!(d.dirty);
17188        assert_eq!(d.status, None, "a supported gesture reports nothing");
17189
17190        // And off again — the toggle reverses, which is the property that makes
17191        // authoring in HTML worth offering rather than a one-way trip.
17192        d.toggle(InlineKind::Strong);
17193        assert_eq!(d.source, "<p>Hello world</p>\n");
17194
17195        let mut d = html_doc("<p>Hello world</p>\n");
17196        d.caret = d.source.find("world").unwrap();
17197        d.insert_thematic_break();
17198        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
17199    }
17200
17201    #[test]
17202    fn a_mark_the_format_cannot_spell_arms_nothing() {
17203        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
17204        // sticky mark for the next text typed. Guarding only the twig call
17205        // leaves that path live, promising a mark the gesture will not write and
17206        // then swallowing the error inside `insert`.
17207        //
17208        // Markdown carries this, on the superscript now rather than on the
17209        // highlight: `^x^` is text there in any configuration, whereas twig
17210        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
17211        // which every leaf document does.
17212        let mut d = doc_with("mark", "Hello world\n");
17213        d.view = View::Wysiwyg;
17214        d.build_visual(80);
17215        d.caret = d.source.find("world").unwrap();
17216        d.toggle(InlineKind::Superscript);
17217        assert!(d.pending_marks.is_empty(), "no mark should be armed");
17218        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
17219        d.insert("X");
17220        assert_eq!(d.source, "Hello Xworld\n");
17221    }
17222
17223    #[test]
17224    fn markdown_authors_a_highlight_and_a_strikethrough() {
17225        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
17226        // write. `==x==` is authorable because leaf's own `parse_extensions`
17227        // turns `highlight` on — twig will only mint bytes this editor's reparse
17228        // reads back — and `~~x~~` because GFM strikethrough is parsed by
17229        // default, so the refusal there was never right for any leaf document.
17230        // twig 3.10 adds the underline, spelled `<u>x</u>` and paired back into
17231        // an `insert` under the `html_elements` leaf parses with.
17232        for (kind, marked) in [
17233            (InlineKind::Mark, "a ==word== b\n"),
17234            (InlineKind::Delete, "a ~~word~~ b\n"),
17235            (InlineKind::Insert, "a <u>word</u> b\n"),
17236        ] {
17237            let mut d = doc_with("author_mark", "a word b\n");
17238            d.anchor = Some(2);
17239            d.caret = 6;
17240            d.toggle(kind);
17241            assert_eq!(d.source, marked, "{kind:?}");
17242            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
17243            assert!(d.dirty, "{kind:?}");
17244            // The region stays selected, so the second press reverses it — the
17245            // property that separates authoring from a one-way trip.
17246            d.toggle(kind);
17247            assert_eq!(d.source, "a word b\n", "{kind:?}");
17248        }
17249    }
17250
17251    #[test]
17252    fn an_authored_highlight_reads_back_as_a_mark() {
17253        // The round trip the extension gate exists to protect: what the toggle
17254        // writes, the reparse must read back as a `mark` rather than as two
17255        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
17256        // rebuilt map rather than from the source text.
17257        let mut d = doc_with("mark_roundtrip", "a word b\n");
17258        d.view = View::Wysiwyg;
17259        d.build_visual(80);
17260        d.anchor = Some(2);
17261        d.caret = 6;
17262        d.toggle(InlineKind::Mark);
17263        assert_eq!(d.source, "a ==word== b\n");
17264        d.build_visual(80);
17265        let w = d
17266            .vmap
17267            .rows
17268            .iter()
17269            .flat_map(|r| r.glyphs.iter())
17270            .find(|g| g.ch == 'w')
17271            .expect("the highlighted word");
17272        assert_eq!(w.style.role, crate::Role::Mark(None));
17273    }
17274
17275    #[test]
17276    fn an_authored_underline_reads_back_as_an_insert() {
17277        // The same round trip for `<u>…</u>`: under `html_elements` the tag pair
17278        // is one `insert`, so the word is underlined and the tags are markup the
17279        // caret-off line hides — not two `raw_inline` runs drawn as literal
17280        // HTML around plain text.
17281        let mut d = doc_with("underline_roundtrip", "a word b\nnext\n");
17282        d.view = View::Wysiwyg;
17283        d.build_visual(80);
17284        d.anchor = Some(2);
17285        d.caret = 6;
17286        d.toggle(InlineKind::Insert);
17287        assert_eq!(d.source, "a <u>word</u> b\nnext\n");
17288        d.anchor = None;
17289        d.caret = d.source.find("next").unwrap();
17290        d.build_visual(80);
17291        let glyphs: Vec<_> = d.vmap.rows.iter().flat_map(|r| r.glyphs.iter()).collect();
17292        let w = glyphs
17293            .iter()
17294            .find(|g| g.ch == 'w')
17295            .expect("the underlined word");
17296        assert!(w.style.underline);
17297        assert!(!glyphs.iter().any(|g| g.ch == '<'), "the tags are hidden");
17298    }
17299
17300    #[test]
17301    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
17302        // The three states of one gesture, in the order a palette is pressed:
17303        // an uncoloured highlight takes the prefix, a coloured one has it
17304        // replaced, and `None` takes it away with the space that was part of the
17305        // spelling.
17306        let mut d = doc_with("mark_colour", "a ==word== b\n");
17307        d.caret = d.source.find("word").unwrap();
17308        d.set_mark_color(Some(MarkColor::Red));
17309        assert_eq!(d.source, "a ==🔴 word== b\n");
17310        assert_eq!(d.status, None);
17311        assert!(d.dirty);
17312
17313        d.set_mark_color(Some(MarkColor::Blue));
17314        assert_eq!(d.source, "a ==🔵 word== b\n");
17315
17316        d.set_mark_color(None);
17317        assert_eq!(d.source, "a ==word== b\n");
17318    }
17319
17320    #[test]
17321    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
17322        // The prefix is written *before* the word, so an offset in the word has
17323        // to ride its width — a caret that stayed put would be a caret that
17324        // walked backwards through the text it was standing in.
17325        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
17326        let word = d.source.find("word").unwrap();
17327        d.caret = word + 2; // between `wo` and `rd`
17328        d.set_mark_color(Some(MarkColor::Red));
17329        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
17330
17331        // And back the other way when the prefix goes.
17332        d.set_mark_color(None);
17333        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
17334    }
17335
17336    #[test]
17337    fn the_colour_at_the_caret_is_what_the_palette_lights() {
17338        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
17339        d.caret = d.source.find("red").unwrap();
17340        assert!(d.caret_in_mark());
17341        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
17342
17343        d.caret = d.source.find("plain").unwrap();
17344        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
17345        assert_eq!(d.mark_color_at_caret(), None);
17346
17347        d.caret = d.source.find(" and ").unwrap() + 2;
17348        assert!(!d.caret_in_mark());
17349        assert_eq!(d.mark_color_at_caret(), None);
17350    }
17351
17352    #[test]
17353    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
17354        // The gesture colours a highlight that exists; it does not make one.
17355        // Two presses is the price of a coloured highlight from bare text, and
17356        // the reason is undo — one press that spliced twice would take two
17357        // presses to take back.
17358        let mut d = doc_with("mark_colour_none", "a word b\n");
17359        d.caret = d.source.find("word").unwrap();
17360        d.set_mark_color(Some(MarkColor::Red));
17361        assert_eq!(d.source, "a word b\n");
17362        assert!(d.status.is_some(), "it should say why");
17363        assert!(!d.dirty);
17364
17365        // Clearing where there is nothing to clear is the same refusal, not a
17366        // quiet success — the caret is in no highlight either way.
17367        d.status = None;
17368        d.set_mark_color(None);
17369        assert_eq!(d.source, "a word b\n");
17370        assert!(d.status.is_some());
17371    }
17372
17373    #[test]
17374    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
17375        // twig answers this one *successfully* with a `Change` describing some
17376        // earlier edit, so a caller that trusted the change would jump the caret
17377        // to wherever that was. Core answers it before asking.
17378        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
17379        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
17380        d.caret = d.source.find("word").unwrap();
17381        let (source, caret) = (d.source.clone(), d.caret);
17382        d.set_mark_color(None);
17383        assert_eq!(d.source, source);
17384        assert_eq!(
17385            d.caret, caret,
17386            "the caret must not ride a change that isn't one"
17387        );
17388        assert_eq!(d.status, None, "and it is not an error either");
17389    }
17390
17391    #[test]
17392    fn djot_spells_the_highlight_and_not_its_colour() {
17393        // The reason the palette is its own capability rather than the Highlight
17394        // button's: `{=word=}` is a highlight djot writes happily, and there is
17395        // no djot spelling for a colour on it.
17396        assert!(Capabilities::of(Format::Djot).mark);
17397        assert!(!Capabilities::of(Format::Djot).mark_color);
17398        assert!(Capabilities::of(Format::Markdown).mark_color);
17399
17400        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
17401        d.caret = d.source.find("word").unwrap();
17402        assert!(
17403            d.caret_in_mark(),
17404            "the caret is in a highlight all the same"
17405        );
17406        d.set_mark_color(Some(MarkColor::Red));
17407        assert_eq!(d.source, "a {=word=} b\n");
17408        assert!(
17409            d.status.as_deref().unwrap_or("").contains("djot"),
17410            "and the refusal names the document's format: {:?}",
17411            d.status
17412        );
17413    }
17414
17415    #[test]
17416    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
17417        // The round trip that matters for a palette: the bytes twig writes are
17418        // bytes its own reparse reads back as a colour, so the swatch that was
17419        // pressed is the swatch that lights afterwards.
17420        let mut d = doc_with("mark_colour_undo", "a word b\n");
17421        d.anchor = Some(2);
17422        d.caret = 6;
17423        d.toggle(InlineKind::Mark);
17424        d.caret = d.source.find("word").unwrap();
17425        d.set_mark_color(Some(MarkColor::Green));
17426        assert_eq!(d.source, "a ==🟢 word== b\n");
17427        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
17428
17429        // One splice, one step: the colour comes off and the highlight stays.
17430        d.undo();
17431        assert_eq!(d.source, "a ==word== b\n");
17432        d.undo();
17433        assert_eq!(d.source, "a word b\n");
17434    }
17435
17436    #[test]
17437    fn every_colour_leaf_names_is_one_twig_writes() {
17438        // The two enums are one vocabulary, and this is what says so: each of
17439        // leaf's colours writes an emoji twig's reparse reads back as *that*
17440        // colour, so `twig_mark_color`'s table cannot quietly pair red with
17441        // orange.
17442        for color in MarkColor::ALL {
17443            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
17444            d.caret = d.source.find("word").unwrap();
17445            d.set_mark_color(Some(color));
17446            assert_eq!(d.status, None, "{color:?}");
17447            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
17448        }
17449    }
17450
17451    #[test]
17452    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
17453        // The two presses a coloured highlight is made of, in the state the
17454        // first one leaves: `toggle` selects the whole `==word==` and puts the
17455        // caret one past the closing `==`, which is *not* in the mark. Asking at
17456        // the caret alone would refuse to colour the highlight just written —
17457        // the selection's start is what answers.
17458        let mut d = doc_with("mark_colour_fresh", "a word b\n");
17459        d.anchor = Some(2);
17460        d.caret = 6;
17461        d.toggle(InlineKind::Mark);
17462        assert_eq!(d.source, "a ==word== b\n");
17463        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
17464
17465        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
17466        d.set_mark_color(Some(MarkColor::Yellow));
17467        assert_eq!(d.source, "a ==🟡 word== b\n");
17468        assert_eq!(d.status, None);
17469    }
17470
17471    #[test]
17472    fn one_press_highlights_a_selection_and_colours_it() {
17473        // What a toolbar swatch means over a plain selection, and the undo it
17474        // has to have: one press, one step. Two steps would leave an uncoloured
17475        // highlight behind on the way back, which is a state the author never
17476        // asked for and never saw.
17477        let mut d = doc_with("highlight_one", "a word b\n");
17478        d.anchor = Some(2);
17479        d.caret = 6;
17480        d.highlight(Some(MarkColor::Purple));
17481        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
17482        assert_eq!(d.status, None);
17483
17484        d.undo();
17485        assert_eq!(d.source, "a word b\n", "one press, one undo");
17486    }
17487
17488    #[test]
17489    fn one_press_on_an_existing_highlight_only_recolours_it() {
17490        // The other half: inside a highlight there is nothing to make, so the
17491        // compound is the plain gesture and the text is untouched.
17492        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
17493        d.caret = d.source.find("word").unwrap();
17494        d.highlight(Some(MarkColor::Blue));
17495        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
17496        d.undo();
17497        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
17498    }
17499
17500    #[test]
17501    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
17502        // `None` means "no colour", and over bare text that is the Highlight
17503        // button's own job. The fold must not happen here — there is no second
17504        // splice, and folding would take the *previous* edit into this one.
17505        let mut d = doc_with("highlight_none", "a word b and more\n");
17506        d.caret = d.source.find("more").unwrap() + 4; // after "more"
17507        d.insert("!"); // an earlier edit for a wrong fold to swallow
17508        d.anchor = Some(2);
17509        d.caret = 6;
17510        d.highlight(None);
17511        assert_eq!(d.source, "a ==word== b and more!\n");
17512
17513        d.undo();
17514        assert_eq!(
17515            d.source, "a word b and more!\n",
17516            "only the highlight came off"
17517        );
17518        d.undo();
17519        assert_eq!(
17520            d.source, "a word b and more\n",
17521            "and the edit before it survived"
17522        );
17523    }
17524
17525    #[test]
17526    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
17527        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
17528        // a colour cannot be armed with it — so the compound declines rather
17529        // than leaving half a promise.
17530        let mut d = doc_with("highlight_bare", "a word b\n");
17531        d.caret = 4;
17532        d.highlight(Some(MarkColor::Red));
17533        assert_eq!(d.source, "a word b\n");
17534        assert!(d.pending_marks.is_empty(), "and nothing armed");
17535        assert!(d.status.is_some());
17536    }
17537
17538    #[test]
17539    fn a_read_only_document_takes_no_colour() {
17540        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
17541        d.caret = d.source.find("word").unwrap();
17542        d.set_read_only(true);
17543        d.set_mark_color(Some(MarkColor::Red));
17544        assert_eq!(d.source, "a ==word== b\n");
17545    }
17546
17547    #[test]
17548    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
17549        // The other door into `toggle`: no selection, so nothing reaches twig
17550        // until `insert` realises the armed mark. It is armed now — the guard
17551        // above asks `Doc::supports`, which asks with the extensions — and what
17552        // it writes is the same `==…==`.
17553        let mut d = doc_with("sticky_mark", "xy\n");
17554        d.caret = 1;
17555        d.toggle(InlineKind::Mark);
17556        assert!(d.pending_marks.contains(InlineKind::Mark));
17557        d.insert("Z");
17558        assert_eq!(d.source, "x==Z==y\n");
17559    }
17560
17561    #[test]
17562    fn html_documents_still_take_typed_text() {
17563        // The guard covers *markup* gestures and must not touch plain editing:
17564        // twig's splicer is language-neutral, and typing into an HTML document
17565        // is the thing that does work today.
17566        let mut d = html_doc("<p>Hello world</p>\n");
17567        d.caret = d.source.find("world").unwrap();
17568        d.insert("big ");
17569        assert_eq!(d.source, "<p>Hello big world</p>\n");
17570        assert!(d.dirty);
17571        d.backspace();
17572        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
17573        d.undo();
17574        d.undo();
17575        assert_eq!(d.source, "<p>Hello world</p>\n");
17576    }
17577
17578    #[test]
17579    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
17580        // `authorable` only separates "there is a door in" from "there is not",
17581        // and HTML is on the near side of that line — which is exactly why a
17582        // toolbar must not be built from it.
17583        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
17584        assert!(html.authorable());
17585        assert!(
17586            !Doc::from_source("<r>x</r>".into(), Format::Xml)
17587                .unwrap()
17588                .authorable()
17589        );
17590
17591        let caps = html.capabilities();
17592        assert!(caps.bold && caps.italic && caps.code && caps.mark);
17593        assert!(caps.thematic_break && caps.cell_line_break);
17594        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
17595        // quote, a list, a code block's language, a link and an image — each
17596        // printed as a fresh node where HTML has no marker to rewrite. A task
17597        // box is a form control and a footnote has no spelling, so those two
17598        // are what keeps the record ragged.
17599        assert!(caps.heading && caps.blockquote && caps.bullet_list);
17600        assert!(caps.link && caps.image && caps.code_language);
17601        assert!(!caps.task && !caps.footnote);
17602        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
17603        // twig's table editor would happily re-emit as `| a | b |`.
17604        assert!(!caps.table);
17605
17606        // The two lightweight formats spell everything leaf offers — and still
17607        // differ from each other, which is the other half of why one boolean
17608        // can't serve.
17609        for fmt in [Format::Markdown, Format::Djot] {
17610            let caps = Capabilities::of(fmt);
17611            assert!(
17612                caps.heading && caps.blockquote && caps.ordered_list,
17613                "{fmt:?}"
17614            );
17615            assert!(
17616                caps.task && caps.link && caps.image && caps.table,
17617                "{fmt:?}"
17618            );
17619        }
17620        // Both spell the highlight, the strikethrough and the underline: djot
17621        // natively, and Markdown because `Capabilities` asks with
17622        // `parse_extensions` rather than with twig's defaults — `==x==` is text
17623        // under those, and a mark under the `highlight` leaf always parses
17624        // with; `<u>x</u>` likewise pairs into an `insert` only under
17625        // `html_elements`.
17626        for fmt in [Format::Markdown, Format::Djot] {
17627            let caps = Capabilities::of(fmt);
17628            assert!(caps.mark && caps.strike && caps.underline, "{fmt:?}");
17629        }
17630        // What still separates them, now that the highlight doesn't: djot has
17631        // no in-cell break, and Markdown spells neither of the scripts.
17632        assert!(Capabilities::of(Format::Djot).superscript);
17633        assert!(!Capabilities::of(Format::Markdown).superscript);
17634        assert!(Capabilities::of(Format::Markdown).cell_line_break);
17635        assert!(!Capabilities::of(Format::Djot).cell_line_break);
17636
17637        // A parse-only format answers no to every one of them, so the coarse
17638        // predicate and the record agree there.
17639        let caps = Capabilities::of(Format::Xml);
17640        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
17641    }
17642
17643    #[test]
17644    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
17645        // The guard exists to name the *document's* format rather than twig's
17646        // internals, so the message has to survive being one leaf writes itself.
17647        // Checked against a gesture twig also refuses, since that is the pair
17648        // most at risk of drifting apart — the task box, once the code
17649        // language stopped being one (twig 3.5).
17650        let mut d = html_doc("<p>Hello</p>\n");
17651        d.caret = d.source.find("Hello").unwrap();
17652        d.toggle_task_item();
17653        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
17654        assert!(!d.dirty);
17655    }
17656
17657    #[test]
17658    fn table_set_alignment_respells_the_delimiter() {
17659        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
17660        d.caret = d.source.find('b').unwrap();
17661        d.table_set_alignment(Alignment::Right);
17662        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
17663    }
17664
17665    #[test]
17666    fn each_empty_table_cell_has_its_own_editable_home() {
17667        // Regression: an empty cell has no twig content_span, so both cells of a
17668        // `|  |  |` row collapsed onto the row's start (before the first `│`).
17669        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
17670        // tell the cells apart. Each empty cell must now have a distinct home
17671        // inside it.
17672        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
17673        let (c0, c1) = {
17674            let cells = &d.vmap.tables[0].grid[1].cells;
17675            (cells[0].start, cells[1].start)
17676        };
17677        assert!(
17678            c0 < c1,
17679            "the two empty cells have distinct homes: {c0} < {c1}"
17680        );
17681        d.caret = c0;
17682        d.insert("x");
17683        assert_eq!(
17684            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
17685            "typed inside the cell"
17686        );
17687    }
17688
17689    #[test]
17690    fn arrows_step_into_each_empty_table_cell() {
17691        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
17692        let (c0, c1) = {
17693            let cells = &d.vmap.tables[0].grid[1].cells;
17694            (cells[0].start, cells[1].start)
17695        };
17696        d.caret = d.source.find('b').unwrap(); // in the header's second cell
17697        let mut seen = std::collections::HashSet::new();
17698        for _ in 0..6 {
17699            d.move_right(false);
17700            seen.insert(d.caret);
17701        }
17702        assert!(
17703            seen.contains(&c0),
17704            "right arrow reaches the first empty cell"
17705        );
17706        assert!(
17707            seen.contains(&c1),
17708            "right arrow reaches the second empty cell"
17709        );
17710    }
17711
17712    #[test]
17713    fn table_op_off_a_table_is_a_no_op_with_a_status() {
17714        let mut d = doc_with("tbl_none", "just text\n");
17715        d.caret = 3;
17716        d.table_insert_row(true);
17717        assert_eq!(d.source, "just text\n", "nothing changed");
17718        assert!(d.status.is_some(), "a status explains why");
17719        assert!(!d.caret_in_table());
17720    }
17721
17722    #[test]
17723    fn enter_in_an_ordered_list_renumbers_the_following_items() {
17724        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
17725        // the renumber pass keeps them sequential, matching what the view draws.
17726        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
17727        d.caret = d.source.find('a').unwrap() + 1; // end of item a
17728        d.newline();
17729        d.insert("x");
17730        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
17731    }
17732
17733    #[test]
17734    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
17735        for view in [View::Source, View::Wysiwyg] {
17736            let mut d = doc_in(view, "outdent_noop", "hello\n");
17737            d.caret = 2;
17738            d.outdent();
17739            assert_eq!(d.source, "hello\n");
17740            assert!(!d.dirty, "a no-op is not a modification");
17741            d.undo();
17742            assert_eq!(
17743                d.status.as_deref(),
17744                Some("nothing to undo"),
17745                "spends no undo step"
17746            );
17747            assert_eq!(d.source, "hello\n");
17748        }
17749    }
17750
17751    #[test]
17752    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
17753        for view in [View::Source, View::Wysiwyg] {
17754            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
17755            d.anchor = Some(0);
17756            d.caret = 7; // through "two"
17757            d.indent();
17758            assert_eq!(
17759                d.source, "  one\n\n  two\n",
17760                "the blank line keeps no trailing pad"
17761            );
17762            // Selected, so a second Tab lands on the same lines rather than on
17763            // whatever the shifted offsets now cover.
17764            assert_eq!(d.selection(), Some((0, 12)));
17765            d.indent();
17766            assert_eq!(d.source, "    one\n\n    two\n");
17767        }
17768    }
17769
17770    #[test]
17771    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
17772        for view in [View::Source, View::Wysiwyg] {
17773            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
17774            d.anchor = Some(0);
17775            d.caret = 15;
17776            d.outdent();
17777            assert_eq!(d.source, "two\none\nnone\n");
17778        }
17779    }
17780
17781    #[test]
17782    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
17783        for view in [View::Source, View::Wysiwyg] {
17784            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
17785            d.anchor = Some(0);
17786            d.caret = 7;
17787            d.indent();
17788            assert_eq!(d.source, "  one\n\n  two\n");
17789            d.undo();
17790            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
17791            assert_eq!(
17792                d.selection(),
17793                Some((0, 7)),
17794                "with the selection it was aimed at"
17795            );
17796            d.redo();
17797            assert_eq!(d.source, "  one\n\n  two\n");
17798            assert_eq!(
17799                d.selection(),
17800                Some((0, 12)),
17801                "redo replays the caret the indent placed, not the one splice left"
17802            );
17803        }
17804    }
17805
17806    #[test]
17807    fn vertical_motion_keeps_the_column() {
17808        let mut d = doc_with("move", "abcd\nef\n");
17809        d.caret = 3; // "abc|d" on row 0, col 3
17810        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
17811        assert_eq!(d.caret, 7); // just after "ef"
17812    }
17813
17814    // ── goal column ──────────────────────────────────────────────────────────
17815
17816    #[test]
17817    fn vertical_motion_goal_column_survives_a_short_line() {
17818        // Regression: re-deriving the column from the clamped position on
17819        // every step permanently forgets it once a short line clamps it.
17820        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
17821        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
17822        assert_eq!(
17823            g("abcd|ef\nxy\nghijkl\n", |d| {
17824                d.move_down(false); // clamps to end of "xy"
17825                d.move_down(false); // restores col 4 on the long line
17826            }),
17827            "abcdef\nxy\nghij|kl\n"
17828        );
17829    }
17830
17831    #[test]
17832    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
17833        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
17834        assert_eq!(d.goal_col, None);
17835        d.caret = 4; // row 0, col 4
17836        d.move_down(false); // clamps into "xy"; goal stays the original col
17837        assert_eq!(d.goal_col, Some(4));
17838        assert_eq!(d.caret_pos(), (1, 2));
17839
17840        // A horizontal motion drops the goal column...
17841        d.move_left(false);
17842        assert_eq!(d.goal_col, None);
17843
17844        // ...so the next vertical motion picks up the *new* column (1), not
17845        // the stale one (4).
17846        d.move_down(false);
17847        assert_eq!(d.goal_col, Some(1));
17848        assert_eq!(d.caret_pos(), (2, 1));
17849    }
17850
17851    #[test]
17852    fn editing_clears_the_goal_column() {
17853        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
17854        d.caret = 4;
17855        d.move_down(false);
17856        assert_eq!(d.goal_col, Some(4));
17857        d.insert("Z");
17858        assert_eq!(d.goal_col, None);
17859    }
17860
17861    #[test]
17862    fn vertical_motion_on_an_empty_document_is_a_no_op() {
17863        let mut d = doc_with("empty_vert", "");
17864        d.move_down(false);
17865        assert_eq!(d.caret, 0);
17866        d.move_up(false);
17867        assert_eq!(d.caret, 0);
17868    }
17869
17870    // ── the document's edges ─────────────────────────────────────────────────
17871
17872    #[test]
17873    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
17874        // The reproduction, and the disagreement: Down on the last line ran to
17875        // the end of the document in the source view — by accident, an
17876        // out-of-range row clamping to the end of the string — and did nothing
17877        // whatever in the view leaf opens in. One rule now, in both.
17878        for (view, tag) in VIEWS {
17879            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
17880            d.caret = 1;
17881            d.move_down(false);
17882            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
17883            d.move_up(false);
17884            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
17885        }
17886    }
17887
17888    #[test]
17889    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
17890        // Down off the bottom is a motion like any other, so it latches a goal
17891        // column — and Up comes back to the column the caret left, not to the
17892        // one the document's end happened to be in.
17893        for (view, tag) in VIEWS {
17894            let gap = if view == View::Source { "\n" } else { "\n\n" };
17895            let src = format!("abcdef{gap}ghijkl");
17896            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
17897            d.caret = 2; // row 0, col 2
17898            d.move_down(false);
17899            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
17900            d.move_down(false);
17901            assert_eq!(
17902                d.caret,
17903                src.len(),
17904                "{tag}: Down off the bottom reaches the end"
17905            );
17906            d.move_up(false);
17907            assert_eq!(
17908                d.caret_pos().1,
17909                2,
17910                "{tag}: Up returns to the column Down left"
17911            );
17912        }
17913    }
17914
17915    #[test]
17916    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
17917        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
17918        // Up that did nothing still armed a goal column, and the next Down aimed
17919        // at a column the caret had never been in.
17920        for (view, tag) in VIEWS {
17921            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
17922            d.caret = 0;
17923            d.move_up(false);
17924            assert_eq!(d.caret, 0, "{tag}: already at the start");
17925            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
17926
17927            d.caret = d.source.len();
17928            d.move_down(false);
17929            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
17930            assert_eq!(
17931                d.goal_col, None,
17932                "{tag}: a no-op Down latched a goal column"
17933            );
17934        }
17935    }
17936
17937    // ── soft wrap ────────────────────────────────────────────────────────────
17938    // Every other test here builds the map at 80 columns, where no fixture is
17939    // long enough to fold. A wrap is where one offset belongs to two rows at
17940    // once, and it broke everything that asks the caret what row it is on.
17941
17942    /// The wrapped fixture these cases share, folded at 12 columns into
17943    /// `one two ` / `three four ` / `five six ` / `seven eight`.
17944    fn wrapped_doc(name: &str) -> Doc {
17945        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
17946        d.build_visual(12);
17947        d
17948    }
17949
17950    #[test]
17951    fn home_and_end_work_from_a_wrapped_row() {
17952        // The reproduction: offset 19 is the `f` of "five", the first character
17953        // of the third row — and also the offset the second row ends at. It
17954        // resolved to the *second* row, so End aimed at a place the caret was
17955        // already in and did nothing, while Home walked backwards onto a row the
17956        // caret had left.
17957        let mut d = wrapped_doc("wrap_home_end");
17958        d.caret = 19;
17959        assert_eq!(
17960            d.caret_pos(),
17961            (2, 0),
17962            "the wrap boundary opens the third row"
17963        );
17964        d.move_end(false);
17965        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
17966        d.move_home(false);
17967        assert_eq!(d.caret, 19, "Home left the row the caret was on");
17968    }
17969
17970    #[test]
17971    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
17972        // The row's end is the last offset that is only ever its own: the offset
17973        // past it opens the row below, and aiming there would send a second
17974        // press on to *that* row's end, and a third to the next — End walking
17975        // down the paragraph rather than sitting where it landed.
17976        let mut d = wrapped_doc("wrap_end_twice");
17977        d.caret = 12; // inside "three", on the second row
17978        d.move_end(false);
17979        assert_eq!(
17980            d.caret, 18,
17981            "the end of `three four`, before the space the wrap ate"
17982        );
17983        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
17984        d.move_end(false);
17985        assert_eq!(d.caret, 18, "a second End moved the caret");
17986        d.move_home(false);
17987        assert_eq!(d.caret, 8, "Home takes the row's own start");
17988    }
17989
17990    #[test]
17991    fn vertical_motion_crosses_a_soft_wrap() {
17992        // Down aimed at the row below's column 0, an offset that resolved *up*
17993        // to the row above's end — so it landed on the offset it already had and
17994        // the caret could never leave a paragraph's first row.
17995        let mut d = wrapped_doc("wrap_down");
17996        d.caret = 0;
17997        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
17998            d.move_down(false);
17999            assert_eq!(d.caret, want, "Down stalled");
18000            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
18001        }
18002        d.move_down(false);
18003        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
18004
18005        // ...and back up, one row per press. The goal column is the end of the
18006        // last row, past every other row's width, so each press clamps to the
18007        // row's own last offset rather than to the one that opens the next.
18008        let mut d = wrapped_doc("wrap_up");
18009        d.caret = 39;
18010        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
18011            d.move_up(false);
18012            assert_eq!(d.caret, want, "Up stalled");
18013            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
18014        }
18015    }
18016
18017    #[test]
18018    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
18019        // The kills take the same line Home and End do, so in WYSIWYG they take
18020        // the visual row — and a soft wrap has no newline in it to delete, so
18021        // nothing is joined by reaching the end of one.
18022        let mut d = wrapped_doc("wrap_kill");
18023        d.caret = 19; // the `f` of "five", opening the third row
18024        d.delete_to_line_end();
18025        // The space the wrap ate goes with the row it was drawn on: sparing it
18026        // would leave "four  seven", two spaces where the row had been.
18027        assert_eq!(d.source, "one two three four seven eight");
18028
18029        // Backwards from the row's last caret position — which is *before* that
18030        // space, so this one survives, being on the far side of the caret.
18031        let mut d = wrapped_doc("wrap_kill_back");
18032        d.caret = 27;
18033        d.delete_to_line_start();
18034        assert_eq!(d.source, "one two three four  seven eight");
18035    }
18036
18037    // ── document start / end ────────────────────────────────────────────────
18038
18039    #[test]
18040    fn move_doc_start_and_end_jump_to_the_edges() {
18041        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
18042        assert_eq!(
18043            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
18044            "|hello\nworld\n"
18045        );
18046        assert_eq!(
18047            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
18048            "hello\nworld\n|"
18049        );
18050        // Already at the edge: a no-op.
18051        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
18052        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
18053    }
18054
18055    #[test]
18056    fn move_doc_start_and_end_extend_the_selection() {
18057        assert_eq!(
18058            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
18059                .move_doc_end(true)),
18060            "hello wor[ld\n|]"
18061        );
18062        assert_eq!(
18063            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
18064                .move_doc_start(true)),
18065            "[|hello wor]ld\n"
18066        );
18067    }
18068
18069    #[test]
18070    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
18071        let mut d = doc_with("empty_edges", "");
18072        d.move_doc_end(false);
18073        assert_eq!(d.caret, 0);
18074        d.move_doc_start(false);
18075        assert_eq!(d.caret, 0);
18076    }
18077
18078    // ── arrow collapses an active selection ─────────────────────────────────
18079
18080    #[test]
18081    fn arrow_collapses_selection_to_its_near_edge() {
18082        let mut d = doc_with("collapse", "hello world\n");
18083
18084        // Forward selection (anchor before caret): Right -> end, Left -> start.
18085        d.anchor = Some(2);
18086        d.caret = 7;
18087        d.move_right(false);
18088        assert_eq!((d.caret, d.anchor), (7, None));
18089
18090        d.anchor = Some(2);
18091        d.caret = 7;
18092        d.move_left(false);
18093        assert_eq!((d.caret, d.anchor), (2, None));
18094
18095        // Backward selection (anchor after caret): edges are the same
18096        // regardless of which end the caret started on.
18097        d.anchor = Some(7);
18098        d.caret = 2;
18099        d.move_right(false);
18100        assert_eq!((d.caret, d.anchor), (7, None));
18101
18102        d.anchor = Some(7);
18103        d.caret = 2;
18104        d.move_left(false);
18105        assert_eq!((d.caret, d.anchor), (2, None));
18106    }
18107
18108    #[test]
18109    fn arrow_with_extend_keeps_growing_the_selection() {
18110        let mut d = doc_with("collapse_extend", "hello world\n");
18111        d.anchor = Some(2);
18112        d.caret = 7;
18113        d.move_right(true); // extend: no collapse, caret steps one further
18114        assert_eq!((d.caret, d.anchor), (8, Some(2)));
18115    }
18116
18117    #[test]
18118    fn arrow_without_a_selection_moves_one_character_as_before() {
18119        let mut d = doc_with("no_collapse", "hello\n");
18120        d.caret = 2;
18121        d.move_right(false);
18122        assert_eq!(d.caret, 3);
18123        d.move_left(false);
18124        assert_eq!(d.caret, 2);
18125    }
18126
18127    /// Press Right until it stops, collecting the offsets walked through. Every
18128    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
18129    /// two stops sharing one source offset can't be moved between, so the caret
18130    /// stalls on the first of them and the walk never reaches the rest.
18131    fn walk_right(d: &mut Doc) -> Vec<usize> {
18132        let mut seen = vec![d.caret];
18133        for _ in 0..2000 {
18134            let before = d.caret;
18135            d.move_right(false);
18136            if d.caret == before {
18137                break;
18138            }
18139            seen.push(d.caret);
18140        }
18141        seen
18142    }
18143
18144    #[test]
18145    fn the_caret_crosses_a_soft_break() {
18146        // A newline inside a paragraph is a `soft_break`, which twig gives no
18147        // span of its own — the space it renders as used to borrow the offset of
18148        // the character before it, and a caret can't move without changing
18149        // offset. Right must walk clean off the end of the first line.
18150        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
18151        d.caret = 0;
18152        let seen = walk_right(&mut d);
18153        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
18154    }
18155
18156    #[test]
18157    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
18158        // The paragraph holds one soft break. Folded (the default) it lays out as
18159        // a single reflowed row; Preserve re-lays it as a row per source line.
18160        // The setter must invalidate the cached map for the change to show, and
18161        // again on the way back — so a round trip returns to the folded layout.
18162        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
18163        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
18164        d.build_visual(80);
18165        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
18166
18167        d.set_line_flow(LineFlow::Preserve);
18168        d.build_visual(80);
18169        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
18170
18171        d.set_line_flow(LineFlow::Fold);
18172        d.build_visual(80);
18173        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
18174    }
18175
18176    #[test]
18177    fn the_caret_still_crosses_a_preserved_soft_break() {
18178        // Preserve renders the soft break as a row boundary rather than a space,
18179        // but the caret must still reach every offset — the break's own offset is
18180        // the first row's end stop, so Right walks clean off the end of line one
18181        // onto line two, exactly as it does when the break is folded.
18182        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
18183        d.set_line_flow(LineFlow::Preserve);
18184        d.build_visual(80);
18185        d.caret = 0;
18186        let seen = walk_right(&mut d);
18187        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
18188    }
18189
18190    #[test]
18191    fn the_caret_walks_a_code_block() {
18192        // Every glyph of a code block used to map to the block's start, so the
18193        // whole block was a single offset and the caret couldn't move inside it.
18194        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
18195        let mut d = wysiwyg_doc("code_walk", src);
18196        d.caret = 0;
18197        let seen = walk_right(&mut d);
18198        // The fences are markup: hidden, and no caret stop. The code between
18199        // them is reached a character at a time.
18200        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
18201        for off in code.clone() {
18202            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
18203        }
18204        assert!(seen.contains(&code.end), "no stop after the last line");
18205    }
18206
18207    #[test]
18208    fn the_caret_walks_an_indented_code_block() {
18209        // An indented block's text has the four-space indent stripped, so it
18210        // isn't a verbatim slice and its lines have to be re-found. The caret
18211        // lands on the code, never in the indent.
18212        let src = "    indented\n    code\n";
18213        let mut d = wysiwyg_doc("indent_code_walk", src);
18214        d.caret = 0;
18215        let seen = walk_right(&mut d);
18216        assert!(seen.contains(&src.find("indented").unwrap()));
18217        assert!(seen.contains(&src.find("code").unwrap()));
18218        assert!(
18219            !seen.contains(&0) || seen[0] == 0,
18220            "the caret starts where it was put"
18221        );
18222        // Nothing in the stripped indent is a stop.
18223        for off in [1, 2, 3] {
18224            assert!(!seen.contains(&off), "landed in the indent at {off}");
18225        }
18226    }
18227
18228    #[test]
18229    fn the_caret_leaves_a_tight_heading() {
18230        // "# H" with text directly under it: the heading row's end and the
18231        // separator row's end are the same offset. Right used to find the
18232        // separator's copy, set the caret to where it already was, and stop.
18233        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
18234        d.caret = 2; // the "H"
18235        let seen = walk_right(&mut d);
18236        assert!(
18237            seen.len() > 2,
18238            "Right stalled at the heading's end: {seen:?}"
18239        );
18240        assert!(
18241            seen.contains(&8),
18242            "never reached the end of \"text\": {seen:?}"
18243        );
18244    }
18245
18246    #[test]
18247    fn the_caret_skips_the_gap_between_two_paragraphs() {
18248        // The blank line between two paragraphs is the boundary itself. The
18249        // caret used to be able to sit on it, and typing there landed in the
18250        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
18251        // soft break, so the text visibly snapped back up.
18252        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
18253        d.caret = 1; // the end of "A"
18254        d.move_right(false);
18255        assert_eq!(d.caret, 3, "Right stopped in the gap");
18256        d.insert("x");
18257        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
18258    }
18259
18260    #[test]
18261    fn down_from_a_paragraph_lands_on_the_next_one() {
18262        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
18263        d.caret = 0;
18264        d.move_down(false);
18265        assert_eq!(d.caret, 3, "Down stopped in the gap");
18266    }
18267
18268    #[test]
18269    fn clicking_the_gap_lands_on_real_text() {
18270        // A click can still *reach* the gap — it's drawn, so it's clickable.
18271        // It has to resolve to somewhere the caret can be.
18272        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
18273        d.click(1, 0, false); // the gap row
18274        assert!(
18275            d.caret == 1 || d.caret == 3,
18276            "click left the caret in the gap at {}",
18277            d.caret
18278        );
18279        d.insert("x");
18280        // Either edge of the boundary is a fair place to land; inside it isn't.
18281        assert!(
18282            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
18283            "click in the gap typed into the boundary: {:?}",
18284            d.source
18285        );
18286    }
18287
18288    #[test]
18289    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
18290        // Enter inserts a paragraph break, which leaves a blank line spare on
18291        // either side of a new one. That middle line is a real empty paragraph:
18292        // the caret lands there, and typing makes a paragraph rather than
18293        // extending a neighbour.
18294        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
18295        d.caret = 1;
18296        d.newline();
18297        assert_eq!(d.source, "A\n\n\n\nB\n");
18298        d.build_visual(80);
18299        let (row, _) = d.caret_pos();
18300        assert!(
18301            d.vmap.row_is_navigable(row),
18302            "the caret landed on a gap row"
18303        );
18304        d.insert("x");
18305        assert_eq!(
18306            d.source, "A\n\nx\n\nB\n",
18307            "the new paragraph merged into a neighbour"
18308        );
18309    }
18310
18311    #[test]
18312    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
18313        let mut d = wysiwyg_doc("gap_eof", "A\n");
18314        d.caret = 1;
18315        d.newline();
18316        d.build_visual(80);
18317        let (row, _) = d.caret_pos();
18318        assert!(
18319            d.vmap.row_is_navigable(row),
18320            "the caret landed on a gap row"
18321        );
18322        d.insert("x");
18323        assert!(
18324            d.source.starts_with("A\n\n") && d.source.contains('x'),
18325            "typing at the end merged into A: {:?}",
18326            d.source
18327        );
18328    }
18329
18330    // ── click_past_end ───────────────────────────────────────────────────────
18331
18332    /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
18333    /// rendered as `|`.
18334    fn past_end(name: &str, body: &str) -> (Doc, String) {
18335        let mut d = wysiwyg_doc(name, body);
18336        d.click_past_end();
18337        let out = render_caret(&d);
18338        (d, out)
18339    }
18340
18341    #[test]
18342    fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
18343        let (mut d, out) = past_end("pe_para", "A\n");
18344        assert_eq!(out, "A\n\n|");
18345        d.build_visual(80);
18346        let (row, _) = d.caret_pos();
18347        assert!(
18348            d.vmap.row_is_navigable(row),
18349            "the caret landed on a gap row"
18350        );
18351        d.insert("x");
18352        assert_eq!(d.source, "A\n\nx", "typing merged into A");
18353    }
18354
18355    #[test]
18356    fn click_past_end_writes_both_newlines_when_the_file_has_none() {
18357        assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
18358    }
18359
18360    #[test]
18361    fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
18362        let (d, out) = past_end("pe_there", "A\n\n");
18363        assert_eq!(out, "A\n\n|");
18364        assert!(!d.dirty, "a click wrote to a document it did not need to");
18365        assert!(
18366            !d.can_undo(),
18367            "a click that changed nothing left an undo step"
18368        );
18369    }
18370
18371    #[test]
18372    fn click_past_end_leaves_a_closed_fence() {
18373        let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
18374        assert_eq!(out, "```\ncode\n```\n\n|");
18375        d.build_visual(80);
18376        let (row, _) = d.caret_pos();
18377        assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
18378        d.insert("x");
18379        assert_eq!(d.source, "```\ncode\n```\n\nx");
18380    }
18381
18382    #[test]
18383    fn click_past_end_closes_an_unclosed_fence_first() {
18384        assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
18385        assert_eq!(
18386            past_end("pe_open2", "````\ncode").1,
18387            "````\ncode\n````\n\n|"
18388        );
18389        assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
18390        assert_eq!(
18391            past_end("pe_quoted", "> ```\n> code\n").1,
18392            "> ```\n> code\n> ```\n\n|"
18393        );
18394    }
18395
18396    #[test]
18397    fn click_past_end_does_not_close_an_indented_block() {
18398        assert_eq!(past_end("pe_indent", "    code\n").1, "    code\n\n|");
18399    }
18400
18401    #[test]
18402    fn click_past_end_under_a_list_and_a_table_leaves_them() {
18403        let (mut d, out) = past_end("pe_list", "- a\n- b\n");
18404        assert_eq!(out, "- a\n- b\n\n|");
18405        d.insert("x");
18406        assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
18407        assert_eq!(
18408            past_end("pe_table", "| a |\n|---|\n| b |\n").1,
18409            "| a |\n|---|\n| b |\n\n|"
18410        );
18411    }
18412
18413    #[test]
18414    fn click_past_end_on_an_empty_document_writes_nothing() {
18415        let (d, out) = past_end("pe_empty", "");
18416        assert_eq!(out, "|");
18417        assert!(!d.dirty);
18418    }
18419
18420    #[test]
18421    fn click_past_end_is_one_undo_step() {
18422        let (mut d, _) = past_end("pe_undo", "A\n");
18423        d.undo();
18424        assert_eq!(d.source, "A\n");
18425    }
18426
18427    #[test]
18428    fn click_past_end_in_the_source_view_only_moves_the_caret() {
18429        let mut d = doc_with("pe_source", "A\n");
18430        d.click_past_end();
18431        assert_eq!(render_caret(&d), "A\n|");
18432        assert!(!d.dirty);
18433    }
18434
18435    #[test]
18436    fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
18437        let mut d = wysiwyg_doc("pe_ro", "A\n");
18438        d.set_read_only(true);
18439        d.click_past_end();
18440        assert_eq!(render_caret(&d), "A\n|");
18441    }
18442
18443    // ── toggle_code_block ────────────────────────────────────────────────────
18444
18445    #[test]
18446    fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
18447        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
18448        assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
18449        assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
18450        assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
18451        assert_eq!(
18452            g("cb_wrap", "aaa\nb|bb\nccc\n"),
18453            "```\naaa\nb|bb\nccc\n```\n"
18454        );
18455        assert_eq!(
18456            g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
18457            "aaa\nb|bb\nccc\n"
18458        );
18459        // An indented block dedents, and the lines stay one-to-one.
18460        assert_eq!(g("cb_indent", "    co|de\n"), "co|de\n");
18461        // A quote's marker is kept on every line, the fence's included.
18462        assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
18463    }
18464
18465    #[test]
18466    fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
18467        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
18468        assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
18469        assert_eq!(
18470            g("cb_blank_mid", "A\n\n|\n\nB\n"),
18471            "A\n\n```\n|\n```\n\nB\n"
18472        );
18473        // Tight against a neighbour, a blank line goes in on that side.
18474        assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
18475        // Inside a quote, on a quoted blank line.
18476        assert_eq!(
18477            g("cb_blank_quote", "> A\n>\n> |\n"),
18478            "> A\n>\n> ```\n> |\n> ```\n"
18479        );
18480    }
18481
18482    #[test]
18483    fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
18484        let mut d = wysiwyg_doc("cb_type", "A\n\n");
18485        d.click_past_end();
18486        d.toggle_code_block();
18487        assert!(
18488            d.caret_in_code_block(),
18489            "the caret is not in the block it opened"
18490        );
18491        d.insert("let x = 1;");
18492        d.newline();
18493        d.insert("x");
18494        assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
18495        d.build_visual(80);
18496        let (row, _) = d.caret_pos();
18497        assert!(d.vmap.rows[row].code, "typed text is not on a code row");
18498        // And back out: the button reverses what it did, text kept.
18499        d.toggle_code_block();
18500        assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
18501        assert!(!d.caret_in_code_block());
18502    }
18503
18504    #[test]
18505    fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
18506        let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
18507        d.anchor = Some(1);
18508        d.caret = 7;
18509        d.toggle_code_block();
18510        assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
18511        assert!(d.selection().is_some(), "the block came back unselected");
18512        d.toggle_code_block();
18513        assert_eq!(
18514            d.source, "one\n\ntwo\n\nthree\n",
18515            "a second press did not reverse the first"
18516        );
18517    }
18518
18519    #[test]
18520    fn toggle_code_block_inside_a_list_item_fences_and_unfences_in_place() {
18521        for (name, before, fenced) in [
18522            ("cb_list", "- it|em\n", "- ```\n  it|em\n  ```\n"),
18523            ("cb_olist", "1. it|em\n", "1. ```\n   it|em\n   ```\n"),
18524            ("cb_task", "- [ ] it|em\n", "- [ ] ```\n  it|em\n  ```\n"),
18525            (
18526                "cb_nested",
18527                "- a\n  - it|em\n",
18528                "- a\n  - ```\n    it|em\n    ```\n",
18529            ),
18530        ] {
18531            let mut d = wysiwyg_doc(name, &before.replace('|', ""));
18532            d.caret = before.find('|').unwrap();
18533            d.toggle_code_block();
18534            assert_eq!(render_caret(&d), fenced, "{name}: fencing");
18535            assert!(
18536                d.caret_in_code_block(),
18537                "{name}: the caret is in the new block"
18538            );
18539            d.toggle_code_block();
18540            assert_eq!(
18541                render_caret(&d),
18542                before,
18543                "{name}: a second press reverses the first"
18544            );
18545        }
18546    }
18547
18548    #[test]
18549    fn toggle_code_block_in_a_list_item_is_one_undo_step() {
18550        let mut d = wysiwyg_doc("cb_list_undo", "- item\n");
18551        d.caret = 4;
18552        d.toggle_code_block();
18553        d.undo();
18554        assert_eq!(render_caret(&d), "- it|em\n");
18555    }
18556
18557    #[test]
18558    fn caret_in_code_block_reads_fenced_and_indented_blocks() {
18559        let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n    more\n");
18560        d.caret = 2;
18561        assert!(!d.caret_in_code_block());
18562        d.caret = 11;
18563        assert!(d.caret_in_code_block());
18564        d.caret = 25;
18565        assert!(d.caret_in_code_block(), "an indented block is a code block");
18566    }
18567
18568    #[test]
18569    fn caret_in_blockquote_reads_every_line_of_a_quote_and_nothing_after() {
18570        let src = "para\n\n> # Head\n> body|\n\nafter\n";
18571        let mut d = wysiwyg_doc("bq_in", &src.replace('|', ""));
18572        let quote_start = src.find('>').unwrap();
18573        let body_end = src.find('|').unwrap();
18574        let after = src.find("after").unwrap() - 1;
18575        for (at, inside) in [
18576            (2, false),
18577            (quote_start + 4, true),
18578            (body_end - 2, true),
18579            (body_end, true),
18580            (after, false),
18581            (after + 2, false),
18582        ] {
18583            d.caret = at;
18584            assert_eq!(d.caret_in_blockquote(), inside, "caret at {at}");
18585        }
18586        // A heading in a quote is both: the quote is a layer over the style.
18587        d.caret = quote_start + 4;
18588        assert_eq!(d.current_heading_level(), Some(1));
18589    }
18590
18591    #[test]
18592    fn caret_in_blockquote_follows_the_toggle() {
18593        let mut d = wysiwyg_doc("bq_toggle", "hello\n");
18594        d.caret = 2;
18595        assert!(!d.caret_in_blockquote());
18596        d.toggle_blockquote();
18597        assert!(d.caret_in_blockquote());
18598        d.toggle_blockquote();
18599        assert!(!d.caret_in_blockquote());
18600    }
18601
18602    #[test]
18603    fn toggle_code_block_is_one_undo_step() {
18604        let mut d = wysiwyg_doc("cb_undo", "hello\n");
18605        d.caret = 2;
18606        d.toggle_code_block();
18607        d.undo();
18608        assert_eq!(render_caret(&d), "he|llo\n");
18609    }
18610
18611    #[test]
18612    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
18613        // A paragraph broken over two source lines is one paragraph. Selecting
18614        // it must not stop at the newline inside it — that newline is markup the
18615        // rich-text view exists to hide.
18616        let src = "one two\nthree four\n\nnext\n";
18617        let mut d = wysiwyg_doc("triple_para", src);
18618        d.select_block_at(2);
18619        assert_eq!(
18620            d.selected_text(),
18621            Some("one two\nthree four"),
18622            "stopped at the soft break"
18623        );
18624    }
18625
18626    #[test]
18627    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
18628        // The reader scrolls down past the caret's row. Nothing moved the
18629        // caret, so the view must stay where it was put — the old code revealed
18630        // the caret every frame, which dragged the view straight back and made
18631        // the document unscrollable past the caret.
18632        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
18633        d.caret = 0;
18634        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
18635        d.scroll = 4; // the wheel
18636        d.follow_caret(0, 3, 9);
18637        assert_eq!(
18638            d.scroll, 4,
18639            "the wheel was overruled by a caret that never moved"
18640        );
18641    }
18642
18643    #[test]
18644    fn moving_the_caret_brings_the_view_back_to_it() {
18645        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
18646        d.caret = 0;
18647        d.follow_caret(0, 3, 9);
18648        d.scroll = 6; // scrolled away
18649        d.move_right(false); // ...and now the caret moves
18650        let (row, _) = d.caret_pos();
18651        d.follow_caret(row, 3, 9);
18652        assert!(
18653            d.scroll <= row && row < d.scroll + 3,
18654            "caret row {row} off screen at scroll {}",
18655            d.scroll
18656        );
18657    }
18658
18659    #[test]
18660    fn scrolling_stops_at_the_last_row() {
18661        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
18662        d.caret = 0;
18663        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
18664        d.scroll = 999; // the wheel, spun hard
18665        d.follow_caret(0, 3, 3);
18666        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
18667    }
18668
18669    #[test]
18670    fn every_cell_of_a_wide_table_is_reachable() {
18671        // A table whose cells are far wider than the surface: the columns are
18672        // cut to fit and the text wraps inside them, so no cell hangs off the
18673        // right edge where the caret can never go.
18674        let src = "| Ingredient | Notes |\n|---|---|\n\
18675                   | flour milled coarse | sift it twice before folding it in |\n";
18676        let mut d = wysiwyg_doc("wide_table_walk", src);
18677        d.build_visual(30);
18678        d.caret = 0;
18679        let seen = walk_right(&mut d);
18680        for word in ["Ingredient", "Notes", "coarse", "folding"] {
18681            let at = src.find(word).unwrap();
18682            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
18683        }
18684    }
18685
18686    // ── view parity ──────────────────────────────────────────────────────────
18687    // `doc_with` pins the source view, so everything above tests a view users
18688    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
18689    // deletion golden cases through *both*, plus the WYSIWYG cases the two
18690    // can't share: where the source carries markup the rendered text is a
18691    // different string, and the views agreeing would itself be the bug.
18692
18693    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
18694
18695    /// Run `action` in both views on one `|`-marked fixture and assert they
18696    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
18697    /// source verbatim, so the two views are looking at the same text and any
18698    /// disagreement is one of them having lost the plot.
18699    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
18700        let (src, caret) = parse_caret(marked);
18701        let run = |view: View, tag: &str| {
18702            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
18703            d.caret = caret;
18704            action(&mut d);
18705            render_caret(&d)
18706        };
18707        let source = run(VIEWS[0].0, VIEWS[0].1);
18708        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
18709        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
18710        source
18711    }
18712
18713    #[test]
18714    fn word_motion_agrees_across_the_views_on_plain_prose() {
18715        let g = both_views;
18716        assert_eq!(
18717            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
18718            "hello |world"
18719        );
18720        assert_eq!(
18721            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
18722            "|hello world"
18723        );
18724        assert_eq!(
18725            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
18726            "hello| world"
18727        );
18728        assert_eq!(
18729            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
18730            "hello world|"
18731        );
18732        assert_eq!(
18733            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
18734            "foo|.bar"
18735        );
18736        assert_eq!(
18737            g("par_ext", "hello |world", |d| d.move_word_right(true)),
18738            "hello [world|]"
18739        );
18740    }
18741
18742    #[test]
18743    fn word_deletion_agrees_across_the_views_on_plain_prose() {
18744        let g = both_views;
18745        assert_eq!(
18746            g("par_db", "hello world|", |d| d.delete_word_back()),
18747            "hello |"
18748        );
18749        assert_eq!(
18750            g("par_df", "hello |world", |d| d.delete_word_forward()),
18751            "hello |"
18752        );
18753        assert_eq!(
18754            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
18755            "|bar baz"
18756        );
18757        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
18758    }
18759
18760    #[test]
18761    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
18762        let g = both_views;
18763        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
18764        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
18765        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
18766        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
18767    }
18768
18769    #[test]
18770    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
18771        // The reproduction: the stop table was built one stop per `char`, so
18772        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
18773        // source view, which steps by grapheme, can't reach and backspace can't
18774        // survive. The two views must land on the same offset.
18775        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
18776        for (view, tag) in VIEWS {
18777            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
18778            d.caret = 1;
18779            d.move_right(false);
18780            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
18781
18782            // ...and the edit that used to sever a joiner off the front of it.
18783            d.backspace();
18784            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
18785            assert_eq!(d.caret, 1);
18786        }
18787    }
18788
18789    #[test]
18790    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
18791        for (view, tag) in VIEWS {
18792            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
18793            d.caret = 0;
18794            d.move_right(false);
18795            assert_eq!(
18796                d.caret,
18797                "e\u{0301}".len(),
18798                "{tag} stopped on the combining mark"
18799            );
18800        }
18801    }
18802
18803    #[test]
18804    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
18805        // The general form: whatever route the caret takes through a document
18806        // full of clusters, it never lands between the codepoints of one — so no
18807        // motion-then-backspace sequence can leave a dangling joiner behind.
18808        use unicode_segmentation::UnicodeSegmentation;
18809
18810        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
18811        let mut d = wysiwyg_doc("cluster_walk", src);
18812        d.caret = 0;
18813        let boundaries: Vec<usize> = src
18814            .grapheme_indices(true)
18815            .map(|(i, _)| i)
18816            .chain(std::iter::once(src.len()))
18817            .collect();
18818        for off in walk_right(&mut d) {
18819            assert!(
18820                boundaries.contains(&off),
18821                "Right stopped at {off}, inside a grapheme cluster"
18822            );
18823        }
18824    }
18825
18826    #[test]
18827    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
18828        // The reproduction: ⌥→ from inside the opening `**` computed its
18829        // boundary over the raw source and landed on byte 8 — inside the
18830        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
18831        // "bold". The caret drew past the bold word and sat inside it.
18832        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
18833        d.caret = 2;
18834        d.move_word_right(false);
18835        assert!(
18836            d.vmap.is_stop(d.caret),
18837            "landed at {}, not a caret stop",
18838            d.caret
18839        );
18840        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
18841        // The rendered row is "a bold c": column 6 is the space just past "bold",
18842        // and now the caret is really there rather than only drawn there.
18843        assert_eq!(d.caret_pos(), (0, 6));
18844
18845        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
18846        d.move_word_left(false);
18847        assert_eq!(d.caret, 4);
18848        assert_eq!(d.caret_pos(), (0, 2));
18849    }
18850
18851    #[test]
18852    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
18853        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
18854        // inside the closing `**`, and left "a ** c\n" — delimiters with no
18855        // opener. Glyph space covers the word alone, which would leave
18856        // "a **** c": markup wrapped around nothing. The word and the styling
18857        // that was only ever the word's go together.
18858        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
18859        d.caret = 10;
18860        d.delete_word_back();
18861        assert_eq!(d.source, "a  c\n");
18862        assert_eq!(d.caret, 2);
18863
18864        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
18865        d.caret = 4; // the "b"
18866        d.delete_word_forward();
18867        assert_eq!(d.source, "a  c\n");
18868    }
18869
18870    #[test]
18871    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
18872        let src = "a ***bold*** c\n";
18873        let mut d = wysiwyg_doc("wys_word_del_nest", src);
18874        d.caret = src.find(" c").unwrap();
18875        d.delete_word_back();
18876        assert_eq!(
18877            d.source, "a  c\n",
18878            "the emph inside the strong empties it too"
18879        );
18880
18881        let src = "a `code` c\n";
18882        let mut d = wysiwyg_doc("wys_word_del_code", src);
18883        d.caret = src.find(" c").unwrap();
18884        d.delete_word_back();
18885        assert_eq!(d.source, "a  c\n");
18886    }
18887
18888    #[test]
18889    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
18890        // Only an *emptied* node goes. Take one word of two and the `**` still
18891        // has a job to do — over the word that's left, with the space the delete
18892        // pushed against the opening delimiter moved out in front of it, or the
18893        // run would be no run at all (`** words**` is literal asterisks — see
18894        // the mark-edge rule on `splice`).
18895        let src = "a **two words** c\n";
18896        let mut d = wysiwyg_doc("wys_word_del_partial", src);
18897        d.caret = src.find(" words").unwrap();
18898        d.delete_word_back();
18899        assert_eq!(d.source, "a  **words** c\n");
18900    }
18901
18902    #[test]
18903    fn source_view_word_motion_still_walks_the_markup() {
18904        // The other half of the decision: in the source view the `**` are
18905        // characters like any other — they're on the screen, so word motion has
18906        // to stop at them and a word-delete has to leave them behind. Only
18907        // WYSIWYG hides them, so only WYSIWYG steps over them.
18908        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
18909        assert_eq!(
18910            g("src_word_motion", "a |**bold** c\n", |d| d
18911                .move_word_right(false)),
18912            "a **bold|** c\n"
18913        );
18914        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
18915        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
18916        assert_eq!(
18917            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
18918            "a **| c\n"
18919        );
18920    }
18921
18922    #[test]
18923    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
18924        // The single invariant both bugs violated: the caret draws and edits at
18925        // the same place only when it's on a stop. `debug_assert_on_a_stop`
18926        // makes the same claim in-place; this pins it from the outside, over a
18927        // document with every kind of thing the map has to be careful about.
18928        // At two widths: the wide one every other test builds at, where no
18929        // fixture folds, and one narrow enough that they all do. A soft wrap is
18930        // where an offset stops being on exactly one row, and testing only the
18931        // width that never wraps is how the caret came to be pinned at the first
18932        // one Down reached.
18933        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
18934                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
18935        // A table of named operations, which is what it looks like.
18936        #[allow(clippy::type_complexity)]
18937        let motions: [(&str, fn(&mut Doc)); 8] = [
18938            ("right", |d| d.move_right(false)),
18939            ("left", |d| d.move_left(false)),
18940            ("word_right", |d| d.move_word_right(false)),
18941            ("word_left", |d| d.move_word_left(false)),
18942            ("down", |d| d.move_down(false)),
18943            ("up", |d| d.move_up(false)),
18944            ("home", |d| d.move_home(false)),
18945            ("end", |d| d.move_end(false)),
18946        ];
18947        for width in [80, 12] {
18948            let mut d = wysiwyg_doc("stop_invariant", src);
18949            d.build_visual(width);
18950            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
18951            assert!(stops.len() > 20, "fixture should have plenty of stops");
18952            for start in stops {
18953                for (name, motion) in &motions {
18954                    d.caret = start;
18955                    d.anchor = None;
18956                    motion(&mut d);
18957                    assert!(
18958                        d.vmap.is_stop(d.caret),
18959                        "{name} from {start} at width {width} landed at {} — not a caret stop",
18960                        d.caret
18961                    );
18962                }
18963            }
18964        }
18965    }
18966
18967    #[test]
18968    fn no_wysiwyg_motion_is_a_dead_end() {
18969        // Down held to the bottom of a document reaches the bottom, and Up held
18970        // to the top reaches the top — from anywhere, at a width that wraps. The
18971        // invariant above says a motion lands somewhere legal; this one says it
18972        // gets somewhere at all, which is what a caret pinned at a wrap boundary
18973        // was quietly failing to do while every assertion around it held.
18974        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
18975                   - item one two three four five\n\nlast\n";
18976        for width in [80, 12] {
18977            let mut d = wysiwyg_doc("no_dead_end", src);
18978            d.build_visual(width);
18979            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
18980            let (first, last) = (stops[0], stops[stops.len() - 1]);
18981            for &start in &stops {
18982                for (name, motion, want) in [
18983                    (
18984                        "down",
18985                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
18986                        last,
18987                    ),
18988                    ("up", |d: &mut Doc| d.move_up(false), first),
18989                ] {
18990                    d.caret = start;
18991                    d.anchor = None;
18992                    d.goal_col = None;
18993                    // Every row, plus the presses the edges take, plus slack.
18994                    for _ in 0..d.vmap.num_rows() + 4 {
18995                        motion(&mut d);
18996                    }
18997                    assert_eq!(
18998                        d.caret, want,
18999                        "{name} held from {start} at width {width} never arrived"
19000                    );
19001                }
19002            }
19003        }
19004    }
19005    // ── display columns ──────────────────────────────────────────────────────
19006    // A `col` is a terminal cell, not a character. The two are the same number
19007    // for the ASCII the fixtures above are written in, which is how they came
19008    // apart in the first place: `你` is one character drawn in two cells, so a
19009    // column counted in characters names a cell the text isn't in — one earlier
19010    // for every wide character to its left.
19011
19012    #[test]
19013    fn a_wide_character_is_two_columns_wide() {
19014        // The reproduction: `你` is one char and two cells, so the caret just
19015        // past it drew at column 1 — inside the character it had already left.
19016        for (view, tag) in VIEWS {
19017            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
19018            d.caret = "你".len();
19019            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
19020            d.caret = "你好".len();
19021            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
19022        }
19023    }
19024
19025    #[test]
19026    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
19027        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
19028        // two-cell — measuring six cells one at a time, but the character they
19029        // spell is drawn in two. Width belongs to the cluster, not the glyph,
19030        // and the frontends measure it the same way.
19031        let family = "👨‍👩‍👧";
19032        for (view, tag) in VIEWS {
19033            let src = format!("a{family}b\n");
19034            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
19035            d.caret = 1 + family.len();
19036            assert_eq!(
19037                d.caret_pos(),
19038                (0, 3),
19039                "{tag}: 'a' is one cell, the family two"
19040            );
19041        }
19042    }
19043
19044    #[test]
19045    fn both_cells_of_a_wide_character_mean_the_character() {
19046        // Clicking the far half of `好` is still clicking `好`: half a character
19047        // is not a place the caret can be, so it comes to rest at the
19048        // character's start — the column it would have been drawn at anyway.
19049        for (view, tag) in VIEWS {
19050            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
19051            for col in [2, 3] {
19052                d.caret = 0;
19053                d.click(0, col, false);
19054                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
19055                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
19056            }
19057            // Past the last cell is the line's end, as it is for ASCII.
19058            d.click(0, 9, false);
19059            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
19060        }
19061    }
19062
19063    #[test]
19064    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
19065        // The mapping is only a mapping if it inverts: the cell the caret is
19066        // drawn in has to be the cell that brings it back to the same offset.
19067        // Over a fixture where a character may be one cell or two, and one
19068        // codepoint or five.
19069        use unicode_segmentation::UnicodeSegmentation;
19070
19071        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
19072
19073        let mut d = doc_in(View::Source, "roundtrip_source", src);
19074        // Every offset the source view's caret can occupy: it steps by grapheme
19075        // cluster, so those are its boundaries.
19076        for (off, _) in src
19077            .grapheme_indices(true)
19078            .chain(std::iter::once((src.len(), "")))
19079        {
19080            d.caret = off;
19081            let (row, col) = d.caret_pos();
19082            d.click(row, col, false);
19083            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
19084        }
19085
19086        // And in WYSIWYG, where the offsets the caret can occupy are the map's
19087        // stops rather than every boundary.
19088        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
19089        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
19090        assert!(stops.len() > 20, "fixture should have plenty of stops");
19091        for off in stops {
19092            d.caret = off;
19093            let (row, col) = d.caret_pos();
19094            d.click(row, col, false);
19095            assert_eq!(
19096                d.caret, off,
19097                "wysiwyg: {off} → ({row}, {col}) → {}",
19098                d.caret
19099            );
19100        }
19101    }
19102
19103    #[test]
19104    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
19105        // Down from under `世` lands under the glyph in that cell, not two
19106        // characters further along the line. The goal is a column, so a line of
19107        // wide characters and a line of ASCII line up the way they're drawn.
19108        //
19109        // The gap differs by view: a bare newline inside a paragraph is a soft
19110        // break, which WYSIWYG draws as a space on a single row. The views share
19111        // a grid only where the source's lines are the renderer's rows too.
19112        for (view, tag) in VIEWS {
19113            let gap = if view == View::Source { "\n" } else { "\n\n" };
19114            let src = format!("你好世{gap}abcdef\n");
19115            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
19116            d.caret = "你好".len();
19117            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
19118            d.move_down(false);
19119            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
19120            assert!(
19121                d.source[d.caret..].starts_with('e'),
19122                "{tag}: landed on the wrong glyph"
19123            );
19124        }
19125    }
19126
19127    #[test]
19128    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
19129        // Down from column 3 onto `你好`, whose characters start at columns 0
19130        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
19131        // between the cells of one character, so the caret rests on it — and on
19132        // its start, which is the only offset there that is a caret stop.
19133        for (view, tag) in VIEWS {
19134            let gap = if view == View::Source { "\n" } else { "\n\n" };
19135            let src = format!("abcdef{gap}你好\n");
19136            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
19137            let line = src.find('你').unwrap();
19138            d.caret = 3;
19139            d.move_down(false);
19140            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
19141            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
19142        }
19143    }
19144
19145    #[test]
19146    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
19147        // The column the cell's text is laid out in is measured in cells, so the
19148        // caret walking that text has to be too — the two agreeing is the whole
19149        // point of the grid staying square.
19150        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
19151        let at = d.source.find("你").unwrap();
19152        d.caret = at;
19153        let (row, col) = d.caret_pos();
19154        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
19155        // cells further along.
19156        assert_eq!(col, 2, "the cell's first character");
19157        d.move_right(false);
19158        assert_eq!(
19159            d.caret_pos(),
19160            (row, 4),
19161            "`好` is drawn past `你`'s two cells"
19162        );
19163        assert_eq!(d.caret, at + "你".len());
19164    }
19165
19166    // ── active inline marks ───────────────────────────────────────────────────
19167
19168    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
19169    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
19170        let (src, caret) = parse_caret(marked);
19171        let mut d = doc_in(view, name, &src);
19172        d.caret = caret;
19173        d.active_inline_marks().iter().collect()
19174    }
19175
19176    /// The marks over the selection `[start, end)`.
19177    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
19178        let mut d = doc_in(view, name, src);
19179        d.anchor = Some(start);
19180        d.caret = end;
19181        d.active_inline_marks().iter().collect()
19182    }
19183
19184    #[test]
19185    fn a_caret_in_a_mark_reports_it() {
19186        for (view, tag) in VIEWS {
19187            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
19188            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
19189            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
19190            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
19191            // Plain text under no mark lights nothing — the toolbar's resting state.
19192            assert_eq!(m("a| **bold** b"), [], "{tag}");
19193            assert!(m("plain t|ext").is_empty(), "{tag}");
19194        }
19195    }
19196
19197    #[test]
19198    fn nested_marks_all_report() {
19199        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
19200        // the ancestor chain is a chain, and every mark on it is in force.
19201        for (view, tag) in VIEWS {
19202            assert_eq!(
19203                marks(
19204                    view,
19205                    &format!("marks_nested_{tag}"),
19206                    "**bold and *bo|th*** end"
19207                ),
19208                [InlineKind::Strong, InlineKind::Emph],
19209                "{tag}"
19210            );
19211        }
19212    }
19213
19214    #[test]
19215    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
19216        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
19217        // the first byte of its text and the byte after its last — both inside
19218        // the mark's span, both places typing lands inside the bold. The offset
19219        // past the closing delimiter is the next text, and reports nothing.
19220        let src = "a **bold** b";
19221        let inner_start = src.find("bold").unwrap(); // 4
19222        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
19223        for (view, tag) in VIEWS {
19224            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
19225            for off in [2, 3, inner_start, inner_end, 9] {
19226                d.caret = off;
19227                assert!(
19228                    d.active_inline_marks().contains(InlineKind::Strong),
19229                    "{tag}: offset {off} is inside the strong span"
19230                );
19231            }
19232            for off in [0, 1, 10, 11, 12] {
19233                d.caret = off;
19234                assert!(
19235                    !d.active_inline_marks().contains(InlineKind::Strong),
19236                    "{tag}: offset {off} is outside the strong run"
19237                );
19238            }
19239        }
19240    }
19241
19242    #[test]
19243    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
19244        // Regression: twig resolves an offset that is one node's end and the
19245        // next one's start to the node that *starts* there, so `**bold**|\n`
19246        // isn't bold. With nothing following there's no tie to break and the
19247        // chain still ended at the mark, which made a trailing `\n` — not the
19248        // text — decide whether the caret after a bold word reported bold. It's
19249        // the offset past the mark either way, and typing there is plain either
19250        // way. A blank document typed into is exactly this shape.
19251        for (view, tag) in VIEWS {
19252            let m = |name: String, marked| marks(view, &name, marked);
19253            assert_eq!(
19254                m(format!("marks_eob_{tag}"), "**bold**|"),
19255                [],
19256                "{tag}: no trailing newline"
19257            );
19258            assert_eq!(
19259                m(format!("marks_eol_{tag}"), "**bold**|\n"),
19260                [],
19261                "{tag}: with one"
19262            );
19263            // And the last offset that *is* in the mark still is.
19264            assert_eq!(
19265                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
19266                [InlineKind::Strong],
19267                "{tag}"
19268            );
19269        }
19270    }
19271
19272    #[test]
19273    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
19274        let src = "a **bold** b";
19275        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
19276        for (view, tag) in VIEWS {
19277            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
19278            // The whole bold word, and a slice of it.
19279            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
19280            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
19281            // Ending exactly at the closing delimiter's start is still all-bold:
19282            // an exclusive end sits *past* the last selected character, so the
19283            // question is asked of the character, not the boundary.
19284            assert_eq!(
19285                m(b, d_ + 2),
19286                [InlineKind::Strong],
19287                "{tag}: through the close"
19288            );
19289            // Half in, half out: Bold lit here would claim a press turns it off.
19290            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
19291            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
19292        }
19293    }
19294
19295    #[test]
19296    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
19297        // Both ends are bold, but the space between them isn't — two runs are two
19298        // nodes, which is exactly what the node id catches and a kind-only
19299        // comparison would not.
19300        let src = "**one** **two**";
19301        for (view, tag) in VIEWS {
19302            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
19303            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
19304        }
19305    }
19306
19307    #[test]
19308    fn marks_read_the_document_as_it_is_edited() {
19309        // The point of asking twig every frame instead of caching: the answer has
19310        // to follow the toggle that changed it.
19311        let mut d = wysiwyg_doc("marks_live", "one two\n");
19312        d.anchor = Some(0);
19313        d.caret = 3;
19314        assert!(d.active_inline_marks().is_empty(), "plain to start");
19315        d.toggle(InlineKind::Strong);
19316        assert_eq!(d.source, "**one** two\n");
19317        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
19318        assert!(d.active_inline_marks().contains(InlineKind::Strong));
19319        d.toggle(InlineKind::Strong);
19320        assert!(d.active_inline_marks().is_empty(), "and off again");
19321    }
19322
19323    #[test]
19324    fn a_link_is_not_an_inline_mark() {
19325        // `link`/`str` are inline nodes, but nothing on the inline toolbar
19326        // toggles them — a set with a "link mark" in it would have no button.
19327        for (view, tag) in VIEWS {
19328            assert_eq!(
19329                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
19330                [],
19331                "{tag}"
19332            );
19333        }
19334    }
19335
19336    // ── blank documents ───────────────────────────────────────────────────────
19337
19338    #[test]
19339    fn a_blank_document_is_untitled_empty_and_markdown() {
19340        let mut d = Doc::blank().unwrap();
19341        assert!(d.is_untitled());
19342        assert_eq!(d.path, PathBuf::new());
19343        assert_eq!(
19344            d.file_name(),
19345            "untitled",
19346            "the header has to show something"
19347        );
19348        assert_eq!(d.format_name(), "markdown");
19349        assert_eq!(d.source, "");
19350        assert!(!d.dirty, "nothing typed yet is nothing to lose");
19351        assert_eq!(d.disk_state(), DiskState::Untitled);
19352        // And it's a document you can be in: the default view renders it.
19353        d.build_visual(80);
19354        assert_eq!(d.caret, 0);
19355    }
19356
19357    #[test]
19358    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
19359        let mut d = Doc::blank().unwrap();
19360        d.insert("hello");
19361        assert!(d.dirty);
19362        d.save();
19363        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
19364        assert!(d.dirty, "it must not come away believing it saved");
19365        assert!(d.is_untitled(), "and it still has no file");
19366    }
19367
19368    #[test]
19369    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
19370        let p = temp_path("blank_save_as");
19371        let mut d = Doc::blank().unwrap();
19372        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
19373        // be kept literal (`\#`); this test is about save-as, not escaping (which
19374        // has its own test), so it types nothing that escaping would touch.
19375        d.insert("hi");
19376        d.save_as(p.clone());
19377        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
19378        assert!(!d.is_untitled());
19379        assert!(!d.dirty);
19380        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
19381        assert_eq!(
19382            d.disk_state(),
19383            DiskState::Unchanged,
19384            "the watermark is stamped"
19385        );
19386        // And ⌘S is a plain save from here on.
19387        d.insert("!");
19388        d.save();
19389        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
19390        let _ = std::fs::remove_file(&p);
19391    }
19392
19393    // ── a file that isn't there yet ───────────────────────────────────────────
19394
19395    /// A unique path in the temp dir with the given extension, guaranteed not to
19396    /// exist — what `leaf notes.md` is handed when the file has never been made.
19397    fn missing_path(name: &str, ext: &str) -> PathBuf {
19398        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19399        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
19400        let mut p = std::env::temp_dir();
19401        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
19402        let _ = std::fs::remove_file(&p);
19403        p
19404    }
19405
19406    #[test]
19407    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
19408        let p = missing_path("named", "md");
19409        let mut d = Doc::open_or_create(p.clone()).unwrap();
19410
19411        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
19412        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
19413        assert!(
19414            !d.is_untitled(),
19415            "it has the name the user asked for — ^S must not detour to Save As"
19416        );
19417        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
19418        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
19419        assert!(!p.exists(), "and opening it wrote nothing");
19420        // And it's a document you can be in.
19421        d.build_visual(80);
19422        assert_eq!(d.caret, 0);
19423    }
19424
19425    #[test]
19426    fn a_new_file_is_created_by_its_first_save() {
19427        let p = missing_path("first_save", "md");
19428        let mut d = Doc::open_or_create(p.clone()).unwrap();
19429        d.insert("hello\n");
19430        assert!(d.dirty);
19431        d.save();
19432
19433        assert_eq!(
19434            std::fs::read_to_string(&p).unwrap(),
19435            "hello\n",
19436            "a plain ^S wrote it — no Save As, no name to invent"
19437        );
19438        assert!(!d.dirty);
19439        assert_eq!(d.disk_state(), DiskState::Unchanged);
19440        let _ = std::fs::remove_file(&p);
19441    }
19442
19443    #[test]
19444    fn a_new_file_takes_its_format_from_the_extension() {
19445        // The one thing `blank` can't do: with no name it has to assume Markdown,
19446        // and typing djot into a Markdown parse is the wrong buffer.
19447        let dj = missing_path("format", "dj");
19448        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
19449        let md = missing_path("format", "md");
19450        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
19451    }
19452
19453    #[test]
19454    fn a_new_file_reports_itself_missing_until_it_is_saved() {
19455        // Not `Untitled` — that's the answer for a document with no path, and it
19456        // would tell a frontend there is nothing a save could collide with. Here
19457        // there is a path, and the file simply isn't at it yet.
19458        let p = missing_path("disk_state", "md");
19459        let mut d = Doc::open_or_create(p.clone()).unwrap();
19460        assert_eq!(d.disk_state(), DiskState::Missing);
19461
19462        // Somebody else creates it while the buffer is open: that's an overwrite
19463        // the frontend has to be able to prompt about, exactly as for an opened
19464        // file. Their bytes, not ours, so `Changed`.
19465        std::fs::write(&p, "theirs\n").unwrap();
19466        assert_eq!(d.disk_state(), DiskState::Changed);
19467
19468        // Saving makes the file ours and re-stamps the watermark.
19469        d.insert("ours\n");
19470        d.save();
19471        assert_eq!(d.disk_state(), DiskState::Unchanged);
19472        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
19473        let _ = std::fs::remove_file(&p);
19474    }
19475
19476    #[test]
19477    fn open_or_create_still_opens_a_file_that_is_there() {
19478        let d = doc_with("open_or_create_existing", "body\n");
19479        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
19480        assert_eq!(reopened.source, "body\n");
19481        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
19482    }
19483
19484    #[test]
19485    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
19486        // A mistyped flag or a stray argument must not become a buffer promising
19487        // to save somewhere — the same refusal `open` gives a real file.
19488        let mut p = std::env::temp_dir();
19489        p.push("leaf_test_new_bad_ext.wat");
19490        assert!(Doc::open_or_create(p).is_err());
19491        let mut none = std::env::temp_dir();
19492        none.push("leaf_test_new_no_ext");
19493        assert!(Doc::open_or_create(none).is_err());
19494    }
19495
19496    #[test]
19497    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
19498        // Opening reads nothing, so there is nothing to fail on yet; the write is
19499        // where it fails, and it says so rather than claiming a save.
19500        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
19501        let mut d = Doc::open_or_create(p).unwrap();
19502        d.insert("x");
19503        d.save();
19504        assert!(
19505            d.status.as_deref().unwrap().starts_with("save failed:"),
19506            "got {:?}",
19507            d.status
19508        );
19509        assert!(d.dirty, "it must not come away believing it saved");
19510    }
19511
19512    // ── save as ───────────────────────────────────────────────────────────────
19513
19514    /// A unique path in the temp dir that no fixture wrote — a Save As target.
19515    fn temp_path(name: &str) -> PathBuf {
19516        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19517        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
19518        let mut p = std::env::temp_dir();
19519        p.push(format!("leaf_test_target_{name}_{seq}.md"));
19520        let _ = std::fs::remove_file(&p);
19521        p
19522    }
19523
19524    #[test]
19525    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
19526        let mut d = doc_with("save_as_move", "original\n");
19527        let old = d.path.clone();
19528        let new = temp_path("save_as_move");
19529        d.insert("edited: ");
19530        d.save_as(new.clone());
19531
19532        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
19533        assert_eq!(
19534            std::fs::read_to_string(&old).unwrap(),
19535            "original\n",
19536            "Save As doesn't touch the file it came from"
19537        );
19538        assert_eq!(d.path, new, "the document moved");
19539        assert!(!d.dirty);
19540        assert_eq!(
19541            d.status.as_deref(),
19542            Some(&*format!("saved {}", d.file_name()))
19543        );
19544
19545        // Every later save follows it, which is the whole difference from a copy.
19546        d.caret = 0;
19547        d.insert("re-");
19548        d.save();
19549        assert_eq!(
19550            std::fs::read_to_string(&new).unwrap(),
19551            "re-edited: original\n"
19552        );
19553        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
19554        let _ = std::fs::remove_file(&new);
19555    }
19556
19557    #[test]
19558    fn save_as_overwrites_an_existing_target() {
19559        // The picker already asked; asking again down here is the same question
19560        // twice, and the second one has no way to be answered.
19561        let new = temp_path("save_as_over");
19562        std::fs::write(&new, "theirs\n").unwrap();
19563        let mut d = doc_with("save_as_over", "ours\n");
19564        d.save_as(new.clone());
19565        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
19566        let _ = std::fs::remove_file(&new);
19567    }
19568
19569    #[test]
19570    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
19571        let mut d = doc_with("save_as_fail", "body\n");
19572        let old = d.path.clone();
19573        d.insert("x");
19574        // A directory that doesn't exist: the write can't land.
19575        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
19576        d.save_as(bad);
19577
19578        assert_eq!(
19579            d.path, old,
19580            "the document must not move to a file that isn't there"
19581        );
19582        assert!(d.dirty, "and must not believe it saved");
19583        assert!(
19584            d.status.as_deref().unwrap().starts_with("save failed:"),
19585            "the same failure a plain save reports, got {:?}",
19586            d.status
19587        );
19588        // The original is still the document's file, and still saveable.
19589        d.save();
19590        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
19591        assert!(!d.dirty);
19592    }
19593
19594    #[test]
19595    fn save_as_renames_without_reparsing_the_format() {
19596        // `.dj` on the name doesn't make the buffer djot: it was parsed as
19597        // Markdown and still is, and saying otherwise would be a conversion the
19598        // user never asked for (and an undo history thrown away to do it).
19599        let mut d = doc_with("save_as_format", "**b**\n");
19600        let mut new = temp_path("save_as_format");
19601        new.set_extension("dj");
19602        d.save_as(new.clone());
19603        assert_eq!(d.format_name(), "markdown");
19604        let _ = std::fs::remove_file(&new);
19605    }
19606
19607    // ── external change / reload ──────────────────────────────────────────────
19608
19609    #[test]
19610    fn an_untouched_file_reports_unchanged() {
19611        let mut d = doc_with("disk_clean", "body\n");
19612        assert_eq!(d.disk_state(), DiskState::Unchanged);
19613        // Editing the buffer is not editing the file.
19614        d.insert("x");
19615        assert_eq!(d.disk_state(), DiskState::Unchanged);
19616        assert!(d.dirty);
19617        // Saving re-stamps the watermark rather than reporting our own bytes back.
19618        d.save();
19619        assert_eq!(d.disk_state(), DiskState::Unchanged);
19620    }
19621
19622    #[test]
19623    fn a_file_written_underneath_reports_changed() {
19624        let mut d = doc_with("disk_changed", "body\n");
19625        std::fs::write(&d.path, "someone else\n").unwrap();
19626        assert_eq!(d.disk_state(), DiskState::Changed);
19627        // Dirty *and* changed is the clobber: both halves are readable, and
19628        // leaf-core takes neither side.
19629        d.insert("x");
19630        assert!(d.dirty && d.disk_state() == DiskState::Changed);
19631        // Saving anyway is allowed — the frontend asked, or chose not to.
19632        d.save();
19633        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
19634        assert_eq!(d.disk_state(), DiskState::Unchanged);
19635    }
19636
19637    #[test]
19638    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
19639        // The hash is what makes this honest: the file was written (a fresh
19640        // mtime), and nothing about the document is stale.
19641        let d = doc_with("disk_same_bytes", "body\n");
19642        std::fs::write(&d.path, "body\n").unwrap();
19643        assert_eq!(d.disk_state(), DiskState::Unchanged);
19644    }
19645
19646    #[test]
19647    fn a_deleted_file_reports_missing() {
19648        let mut d = doc_with("disk_missing", "body\n");
19649        std::fs::remove_file(&d.path).unwrap();
19650        assert_eq!(d.disk_state(), DiskState::Missing);
19651        // A save recreates it, and the document is whole again.
19652        d.save();
19653        assert_eq!(d.disk_state(), DiskState::Unchanged);
19654        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
19655    }
19656
19657    #[test]
19658    fn reload_replaces_the_document_with_the_file() {
19659        for (view, tag) in VIEWS {
19660            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
19661            d.insert("edited ");
19662            assert!(d.dirty);
19663            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
19664            d.reload();
19665
19666            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
19667            assert!(!d.dirty, "{tag}: the file is what we have");
19668            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
19669            assert_eq!(
19670                d.status.as_deref(),
19671                Some(&*format!("reloaded {}", d.file_name()))
19672            );
19673            // The reloaded tree is live, not the old parse.
19674            d.caret = d.source.find("three").unwrap();
19675            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
19676        }
19677    }
19678
19679    #[test]
19680    fn reload_clamps_the_caret_and_drops_the_selection() {
19681        let mut d = doc_with("reload_caret", "a long first line\n");
19682        d.caret = 12;
19683        d.anchor = Some(4);
19684        std::fs::write(&d.path, "short\n").unwrap();
19685        d.reload();
19686        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
19687        assert_eq!(
19688            d.anchor, None,
19689            "a selection over bytes that changed is a lie"
19690        );
19691        assert!(d.selection().is_none());
19692
19693        // A caret the file still has room for stays put.
19694        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
19695        d.caret = 2;
19696        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
19697        d.reload();
19698        assert_eq!(d.caret, 2);
19699    }
19700
19701    /// A silent reload is something that happened *to* a reader — a formatter,
19702    /// a `git checkout` — so it has to be undoable like anything else that
19703    /// changes the document, and undoable as one step rather than as however
19704    /// many the file happens to differ by.
19705    #[test]
19706    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
19707        let mut d = doc_with("reload_undo", "body\n");
19708        d.insert("x");
19709        assert_eq!(d.source, "xbody\n");
19710        std::fs::write(&d.path, "replaced\n").unwrap();
19711        d.reload();
19712        assert_eq!(d.source, "replaced\n");
19713        assert!(!d.dirty, "a reload lands clean");
19714
19715        // One ^Z takes the whole swap off, and hands back the unsaved work it
19716        // replaced — which is unsaved again, because the file no longer says it.
19717        d.undo();
19718        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
19719        assert!(d.dirty, "and what it comes back to is unsaved");
19720        // …and the history under it is still there.
19721        d.undo();
19722        assert_eq!(
19723            d.source, "body\n",
19724            "the typing before the reload undoes too"
19725        );
19726        // Redo walks back up through the reload.
19727        d.redo();
19728        d.redo();
19729        assert_eq!(d.source, "replaced\n");
19730    }
19731
19732    /// A file rewritten with the bytes it already had is not an edit, so it
19733    /// must not leave an undo step behind for something nobody did.
19734    #[test]
19735    fn reloading_identical_bytes_pushes_no_undo_step() {
19736        let mut d = doc_with("reload_same", "body\n");
19737        d.insert("x");
19738        std::fs::write(&d.path, "xbody\n").unwrap();
19739        d.reload();
19740        assert_eq!(d.source, "xbody\n");
19741        assert!(!d.dirty, "the file now says what the buffer does");
19742        d.undo();
19743        assert_eq!(
19744            d.source, "body\n",
19745            "one step back is the typing, not a no-op"
19746        );
19747    }
19748
19749    #[test]
19750    fn a_reload_that_cant_read_leaves_the_document_alone() {
19751        let mut d = doc_with("reload_gone", "body\n");
19752        d.insert("x");
19753        std::fs::remove_file(&d.path).unwrap();
19754        d.reload();
19755        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
19756        assert!(d.dirty);
19757        assert!(
19758            d.status.as_deref().unwrap().starts_with("reload failed:"),
19759            "{:?}",
19760            d.status
19761        );
19762
19763        // And an untitled document has nothing to reload from.
19764        let mut d = Doc::blank().unwrap();
19765        d.insert("typed");
19766        d.reload();
19767        assert_eq!(d.source, "typed");
19768        assert_eq!(d.status.as_deref(), Some("no file to reload"));
19769    }
19770
19771    #[test]
19772    fn a_read_only_document_refuses_every_door() {
19773        let mut d = doc_with("readonly", "one two three\n");
19774        d.insert("x");
19775        assert!(d.dirty, "writable first, so the undo step exists");
19776        d.set_read_only(true);
19777        let before = d.source.clone();
19778        d.insert("y");
19779        d.backspace();
19780        d.undo();
19781        d.redo();
19782        assert_eq!(d.source, before, "no door moved a byte");
19783        d.set_read_only(false);
19784        d.undo();
19785        assert_ne!(d.source, before, "off again, the same doors work");
19786    }
19787
19788    /// The doors that go to twig's own verbs rather than through the splice.
19789    /// Typed text in the rendered view under the default markup mode is the
19790    /// everyday one — it is what a keystroke in leaf-web or the Apple views
19791    /// becomes — and it walked straight past the gate.
19792    #[test]
19793    fn a_read_only_document_refuses_the_doors_around_the_splice() {
19794        let mut d = wysiwyg_doc(
19795            "readonly-doors",
19796            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
19797        );
19798        d.set_markup_mode(MarkupMode::None);
19799        d.set_read_only(true);
19800        let before = d.source.clone();
19801        d.place_caret(3, false);
19802        d.insert("y");
19803        d.insert_link("https://example.com");
19804        d.insert_image("a.png", "alt");
19805        d.insert_thematic_break();
19806        d.insert_footnote();
19807        d.place_caret(0, false);
19808        d.place_caret(3, true);
19809        d.toggle(InlineKind::Strong);
19810        d.toggle_heading(2);
19811        d.set_block(BlockKind::Paragraph);
19812        d.toggle_list(false);
19813        d.toggle_blockquote();
19814        d.toggle_task_item();
19815        d.newline();
19816        d.indent();
19817        d.set_code_language("rust");
19818        let in_cell = d.source.find("| c").unwrap() + 2;
19819        d.place_caret(in_cell, false);
19820        assert!(d.caret_in_table(), "the caret is in the grid");
19821        assert!(!d.cell_line_break(), "the cell break reports the refusal");
19822        assert_eq!(d.source, before, "no door moved a byte");
19823        assert!(!d.dirty, "nothing to save");
19824        d.set_read_only(false);
19825        d.place_caret(3, false);
19826        d.insert("y");
19827        assert_ne!(d.source, before, "off again, the same doors work");
19828    }
19829
19830    #[test]
19831    fn a_selection_quote_carries_its_context_on_char_boundaries() {
19832        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
19833        let start = d.source.find("exact").unwrap();
19834        d.place_caret(start, false);
19835        d.place_caret(start + "exact".len(), true);
19836        let q = d.selection_quote(3).unwrap();
19837        assert_eq!(q.exact, "exact");
19838        assert_eq!(
19839            q.prefix, "你好 ",
19840            "chars, not bytes — the multibyte pair counts as two"
19841        );
19842        assert_eq!(q.suffix, " 世界");
19843        assert_eq!(&d.source[q.start..q.end], "exact");
19844        // At the edges the context clips rather than erring.
19845        d.place_caret(0, false);
19846        d.place_caret(6, true);
19847        let q = d.selection_quote(40).unwrap();
19848        assert_eq!(q.prefix, "");
19849        assert_eq!(q.exact, "before");
19850        // No selection is no quote.
19851        d.place_caret(0, false);
19852        assert!(d.selection_quote(3).is_none());
19853    }
19854
19855    #[test]
19856    fn highlights_are_kept_sorted_and_answer_point_queries() {
19857        let mut d = doc_with("hl", "one two three\n");
19858        d.set_highlights(vec![
19859            Highlight {
19860                start: 8,
19861                end: 13,
19862                id: "b".into(),
19863                color: None,
19864                marker: None,
19865            },
19866            Highlight {
19867                start: 0,
19868                end: 3,
19869                id: "a".into(),
19870                color: Some("#ffe066".into()),
19871                marker: None,
19872            },
19873            Highlight {
19874                start: 5,
19875                end: 5,
19876                id: "empty".into(),
19877                color: None,
19878                marker: None,
19879            },
19880        ]);
19881        assert_eq!(
19882            d.highlights()
19883                .iter()
19884                .map(|h| h.id.as_str())
19885                .collect::<Vec<_>>(),
19886            ["a", "b"],
19887            "sorted by start, the empty range dropped"
19888        );
19889        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
19890        assert_eq!(d.highlight_at(3), None, "end is exclusive");
19891        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
19892        d.set_highlights(Vec::new());
19893        assert!(d.highlights().is_empty(), "a replace is a replace");
19894    }
19895
19896    /// `Highlight::covering` and the cursor over it are what both painters ask
19897    /// per glyph, so they have to answer the same as the scan they replaced —
19898    /// including in the gaps, which is where most glyphs are.
19899    #[test]
19900    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
19901        let hl = |start: usize, end: usize, id: &str| Highlight {
19902            start,
19903            end,
19904            id: id.into(),
19905            color: None,
19906            marker: None,
19907        };
19908        // Disjoint, as search hits are: in a range, in a gap, and past the end.
19909        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
19910        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
19911        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
19912        assert_eq!(
19913            Highlight::covering(&hits, 105),
19914            None,
19915            "a gap covers nothing"
19916        );
19917        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
19918        assert_eq!(Highlight::covering(&hits, 9_999), None);
19919        assert_eq!(Highlight::covering(&[], 0), None);
19920
19921        // Nested: first by start, so a hit inside an annotation still resolves
19922        // to the annotation — and the range that stops short doesn't mask it.
19923        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
19924        assert_eq!(
19925            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
19926            Some("outer")
19927        );
19928        assert_eq!(
19929            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
19930            Some("outer")
19931        );
19932    }
19933
19934    /// The cursor is an optimisation, so the only thing worth asserting is that
19935    /// it is not also a change of answer — at every offset, over a list with a
19936    /// nest in it, walked forwards and then backwards.
19937    #[test]
19938    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
19939        let hl = |start: usize, end: usize, id: &str| Highlight {
19940            start,
19941            end,
19942            id: id.into(),
19943            color: None,
19944            marker: None,
19945        };
19946        let mut list = vec![
19947            hl(0, 20, "outer"),
19948            hl(5, 10, "inner"),
19949            hl(30, 33, "hit"),
19950            hl(40, 43, "hit"),
19951        ];
19952        list.sort_by_key(|h| (h.start, h.end));
19953
19954        let mut cursor = HighlightCursor::new(&list);
19955        for offset in 0..50 {
19956            assert_eq!(
19957                cursor.at(offset).map(|h| h.id.as_str()),
19958                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
19959                "cursor disagrees at {offset}"
19960            );
19961        }
19962        // Backwards: the cursor re-seats rather than answering from where it
19963        // had got to, so a painter that revisits a row is still told the truth.
19964        for offset in (0..50).rev() {
19965            assert_eq!(
19966                cursor.at(offset).map(|h| h.id.as_str()),
19967                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
19968                "cursor disagrees walking back at {offset}"
19969            );
19970        }
19971    }
19972
19973    // ── the presentation vocabulary ─────────────────────────────────────────
19974
19975    /// A document in `format`, for the gesture tests that want more than the
19976    /// Markdown `doc_with` writes.
19977    fn fmt_doc(body: &str, format: Format) -> Doc {
19978        Doc::from_source(body.to_string(), format).unwrap()
19979    }
19980
19981    /// Alignment is a block property, so the gesture is `set_block_attrs` on
19982    /// the caret's block whatever is selected — and each format spells it its
19983    /// own way: djot's `{…}` line above the block, a `<div>` around it in
19984    /// Markdown (the format has nowhere else to put it), the tag in HTML.
19985    #[test]
19986    fn set_alignment_spells_the_class_the_format_s_own_way() {
19987        let mut dj = fmt_doc("hello\n", Format::Djot);
19988        dj.caret = 1;
19989        dj.set_alignment(Some(Align::Center));
19990        assert_eq!(dj.source, "{.center}\nhello\n");
19991        assert!(dj.dirty);
19992        assert_eq!(dj.status, None);
19993
19994        let mut md = fmt_doc("hello\n", Format::Markdown);
19995        md.caret = 1;
19996        md.set_alignment(Some(Align::Right));
19997        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
19998
19999        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
20000        html.caret = html.source.find("hello").unwrap();
20001        html.set_alignment(Some(Align::Justify));
20002        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
20003    }
20004
20005    /// Each gesture edits **one key and keeps the rest** — twig's contract is
20006    /// replace-not-merge, so leaf reads the node's attributes, edits its own
20007    /// key out of them, and passes the list back whole. A document from
20008    /// elsewhere passes through the editor unharmed.
20009    #[test]
20010    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
20011        let mut d = fmt_doc(
20012            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
20013            Format::Djot,
20014        );
20015        d.caret = d.source.find("hello").unwrap();
20016        d.set_alignment(Some(Align::Right));
20017        // `center` goes, `lead` stays, and neither the id nor the spacing is
20018        // touched.
20019        // The serializer picks the order; what matters is which keys survive.
20020        assert!(d.source.contains(".lead"), "{:?}", d.source);
20021        assert!(d.source.contains(".right"), "{:?}", d.source);
20022        assert!(!d.source.contains(".center"), "{:?}", d.source);
20023        assert!(d.source.contains("#intro"), "{:?}", d.source);
20024        assert!(
20025            d.source.contains("data-line-height=\"1.5\""),
20026            "{:?}",
20027            d.source
20028        );
20029        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
20030        assert_eq!(
20031            d.line_spacing_at_caret(),
20032            Some(LineHeight::Step(LineSpacing::OneHalf))
20033        );
20034
20035        // And the other way round: the spacing gesture leaves the classes be.
20036        d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20037        assert!(d.source.contains(".lead"), "{:?}", d.source);
20038        assert!(d.source.contains(".right"), "{:?}", d.source);
20039        assert_eq!(
20040            d.line_spacing_at_caret(),
20041            Some(LineHeight::Step(LineSpacing::Double))
20042        );
20043    }
20044
20045    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
20046    /// owns go out of `class`, and a block left with nothing at all is spelled
20047    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
20048    #[test]
20049    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
20050        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
20051        dj.caret = dj.source.find("hello").unwrap();
20052        dj.set_alignment(None);
20053        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
20054        assert_eq!(dj.alignment_at_caret(), None);
20055
20056        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
20057        bare.caret = bare.source.find("hello").unwrap();
20058        bare.set_alignment(None);
20059        assert_eq!(
20060            bare.source, "hello\n",
20061            "the last key takes the line with it"
20062        );
20063
20064        let mut md = fmt_doc("hello\n", Format::Markdown);
20065        md.caret = 1;
20066        md.set_alignment(Some(Align::Center));
20067        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
20068        md.caret = md.source.find("hello").unwrap();
20069        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
20070        assert_eq!(
20071            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
20072            "the second key rewrites the div rather than nesting a second"
20073        );
20074        md.caret = md.source.find("hello").unwrap();
20075        md.set_alignment(None);
20076        md.caret = md.source.find("hello").unwrap();
20077        md.set_line_spacing(None);
20078        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
20079    }
20080
20081    /// Size, face and colour are the run's over a selection and the block's
20082    /// with none — so "make this paragraph larger" is a click with the caret in
20083    /// it rather than a select-all first.
20084    #[test]
20085    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
20086        // With a selection: a span, in each format's own spelling.
20087        let mut dj = fmt_doc("a big b\n", Format::Djot);
20088        dj.anchor = Some(2);
20089        dj.caret = 5;
20090        dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20091        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
20092        assert_eq!(
20093            dj.font_size_at_caret(),
20094            Some(FontSize::Step(SizeStep::Large))
20095        );
20096
20097        let mut md = fmt_doc("a big b\n", Format::Markdown);
20098        md.anchor = Some(2);
20099        md.caret = 5;
20100        md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
20101        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
20102        assert_eq!(
20103            md.text_color_at_caret(),
20104            Some(TextColor::Named(MarkColor::Blue))
20105        );
20106
20107        // Without one: the caret's block, through the block gesture.
20108        let mut block = fmt_doc("a big b\n", Format::Djot);
20109        block.caret = 3;
20110        block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
20111        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
20112        assert_eq!(
20113            block.font_family_at_caret(),
20114            Some(FontFace::Generic(FontFamily::Monospace))
20115        );
20116    }
20117
20118    /// The *Other…* row of each of the four menus: a value goes into the
20119    /// document in its canonical spelling and comes back out of the query as
20120    /// the same value. One round trip per property, because the four go out
20121    /// through different doors — two block gestures, and the run three through
20122    /// the span that `wrap_range_attrs` mints.
20123    #[test]
20124    fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
20125        // Size: the run three, over a selection.
20126        let mut d = fmt_doc("a big b\n", Format::Djot);
20127        d.anchor = Some(2);
20128        d.caret = 5;
20129        d.set_font_size(FontSize::points(14.0));
20130        assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
20131        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
20132
20133        // Colour, onto the same span — the gesture keeps the size it finds.
20134        d.set_text_color(Some(TextColor::Rgb {
20135            r: 0xc0,
20136            g: 0x30,
20137            b: 0x30,
20138        }));
20139        assert_eq!(
20140            d.source,
20141            "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
20142        );
20143        assert_eq!(
20144            d.text_color_at_caret(),
20145            Some(TextColor::Rgb {
20146                r: 0xc0,
20147                g: 0x30,
20148                b: 0x30
20149            })
20150        );
20151
20152        // Face: a family name, as given.
20153        d.set_font_family(Some(FontFace::Named("Garamond".into())));
20154        assert!(
20155            d.source.contains("data-font=\"Garamond\""),
20156            "{:?}",
20157            d.source
20158        );
20159        assert_eq!(
20160            d.font_family_at_caret(),
20161            Some(FontFace::Named("Garamond".into()))
20162        );
20163
20164        // Line spacing: a block gesture, and an exact ratio.
20165        let mut block = fmt_doc("hello\n", Format::Djot);
20166        block.caret = 1;
20167        block.set_line_spacing(LineHeight::ratio(1.3));
20168        assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
20169        assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
20170
20171        // And a value spelled long is written back short, so the same press
20172        // twice writes the same bytes: `14.0pt` in, `14pt` out.
20173        let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
20174        long.caret = long.source.find("hello").unwrap();
20175        assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
20176        let in_force = long.font_size_at_caret();
20177        long.set_font_size(in_force);
20178        assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
20179    }
20180
20181    /// A value the grammar does not cover is what it was before the vocabulary
20182    /// opened: carried untouched by the document, answered `None` by the query
20183    /// so the menu ticks *Default*, and rewritten only by a gesture on its own
20184    /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
20185    #[test]
20186    fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
20187        let src =
20188            "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
20189        let mut d = fmt_doc(src, Format::Djot);
20190        d.caret = d.source.find("hello").unwrap();
20191        assert_eq!(d.font_size_at_caret(), None);
20192        assert_eq!(d.text_color_at_caret(), None);
20193        assert_eq!(d.line_spacing_at_caret(), None);
20194
20195        // The keys are still there, untouched, after a gesture on a *different*
20196        // key — "edit one key and keep the rest" holds for a value it cannot
20197        // read as readily as for one it can.
20198        d.set_alignment(Some(Align::Center));
20199        assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
20200        assert!(
20201            d.source.contains("data-color=\"rgb(1,2,3)\""),
20202            "{:?}",
20203            d.source
20204        );
20205        assert!(
20206            d.source.contains("data-line-height=\"1.3em\""),
20207            "{:?}",
20208            d.source
20209        );
20210        // And the gesture on its *own* key replaces it, which is the one way a
20211        // carried value ever changes.
20212        d.caret = d.source.find("hello").unwrap();
20213        d.set_font_size(FontSize::points(12.0));
20214        assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
20215        assert!(!d.source.contains("huge"), "{:?}", d.source);
20216    }
20217
20218    /// The nearest node wins whichever *form* either node wrote: a value inside
20219    /// a name, a name inside a value. The fold has one rule and does not learn
20220    /// a second one for exact values.
20221    #[test]
20222    fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
20223        // A value inside a name: the block says `small`, the span says `14pt`.
20224        let mut d = fmt_doc(
20225            "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
20226            Format::Djot,
20227        );
20228        d.caret = d.source.find('y').unwrap();
20229        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
20230        d.caret = d.source.find('x').unwrap();
20231        assert_eq!(
20232            d.font_size_at_caret(),
20233            Some(FontSize::Step(SizeStep::Small))
20234        );
20235
20236        // And a name inside a value, which is the same rule read the other way.
20237        let mut e = fmt_doc(
20238            "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
20239            Format::Djot,
20240        );
20241        e.caret = e.source.find('y').unwrap();
20242        assert_eq!(
20243            e.font_size_at_caret(),
20244            Some(FontSize::Step(SizeStep::Small))
20245        );
20246        assert_eq!(
20247            e.text_color_at_caret(),
20248            Some(TextColor::Rgb {
20249                r: 0xc0,
20250                g: 0x30,
20251                b: 0x30
20252            }),
20253            "the block's colour still reaches the span"
20254        );
20255        e.caret = e.source.find('x').unwrap();
20256        assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
20257    }
20258
20259    /// twig re-styles the span a range already lies in rather than nesting a
20260    /// second, and an empty set unwraps it — so a second press of the menu
20261    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
20262    /// means "the theme's own" takes the span away.
20263    #[test]
20264    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
20265        let mut d = fmt_doc("a big b\n", Format::Djot);
20266        d.anchor = Some(2);
20267        d.caret = 5;
20268        d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20269        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
20270
20271        // The selection `wrap_range_attrs` left behind covers the whole span;
20272        // colouring it now keeps the size, because the gesture reads the span's
20273        // attributes before it edits its own key.
20274        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
20275        assert_eq!(
20276            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
20277            "one span, both keys"
20278        );
20279        assert_eq!(
20280            d.font_size_at_caret(),
20281            Some(FontSize::Step(SizeStep::Large))
20282        );
20283        assert_eq!(
20284            d.text_color_at_caret(),
20285            Some(TextColor::Named(MarkColor::Red))
20286        );
20287
20288        d.set_text_color(None);
20289        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
20290        d.set_font_size(None);
20291        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
20292        assert_eq!(d.font_size_at_caret(), None);
20293    }
20294
20295    /// The queries read the nearest node that names the property: the span the
20296    /// caret is in, then its block, then the `div`s around it.
20297    #[test]
20298    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
20299        let mut d = fmt_doc(
20300            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
20301            Format::Djot,
20302        );
20303        // In the span: its own size, the block's face and alignment.
20304        d.caret = d.source.find('y').unwrap();
20305        assert_eq!(
20306            d.font_size_at_caret(),
20307            Some(FontSize::Step(SizeStep::XxLarge))
20308        );
20309        assert_eq!(
20310            d.font_family_at_caret(),
20311            Some(FontFace::Generic(FontFamily::Serif))
20312        );
20313        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
20314        assert_eq!(d.line_spacing_at_caret(), None);
20315        assert_eq!(d.text_color_at_caret(), None);
20316
20317        // Outside it: the block's size.
20318        d.caret = d.source.find('x').unwrap();
20319        assert_eq!(
20320            d.font_size_at_caret(),
20321            Some(FontSize::Step(SizeStep::Small))
20322        );
20323
20324        // And through a Markdown div, which is where a Markdown block's
20325        // attributes live.
20326        let mut md = fmt_doc(
20327            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
20328            Format::Markdown,
20329        );
20330        md.caret = md.source.find("hello").unwrap();
20331        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20332        assert_eq!(
20333            md.font_size_at_caret(),
20334            Some(FontSize::Step(SizeStep::Large))
20335        );
20336
20337        // A document that names none of it answers `None` everywhere, which is
20338        // "the theme's own" and what every toolbar draws unlit.
20339        let mut plain = doc_with("plain_presentation", "hello\n");
20340        plain.caret = 1;
20341        assert_eq!(plain.alignment_at_caret(), None);
20342        assert_eq!(plain.line_spacing_at_caret(), None);
20343        assert_eq!(plain.font_size_at_caret(), None);
20344        assert_eq!(plain.font_family_at_caret(), None);
20345        assert_eq!(plain.text_color_at_caret(), None);
20346    }
20347
20348    /// A djot fenced div is anonymous the way an attributed span is, and is a
20349    /// block all the same — the *form* is the whole of what tells them apart.
20350    /// Read as a span it poisoned both halves: the run gesture copied the div's
20351    /// entire attribute set onto the span it minted, duplicating the `id`, and
20352    /// the run and block queries answered off a node the walker draws nothing
20353    /// for.
20354    #[test]
20355    fn a_djot_fenced_div_is_not_an_attributed_span() {
20356        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
20357        let mut d = fmt_doc(src, Format::Djot);
20358        let at = d.source.find("world").unwrap();
20359        d.anchor = Some(at);
20360        d.caret = at + "world".len();
20361        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
20362        assert_eq!(
20363            d.source,
20364            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
20365            "the span carries its own key and nothing of the div's"
20366        );
20367
20368        // And the queries stop at the block: a djot div is not a `<div>`, the
20369        // walker lends its keys to nothing inside it, and a query that said
20370        // otherwise would tick a menu entry no glyph on screen obeys.
20371        assert_eq!(
20372            d.text_color_at_caret(),
20373            Some(TextColor::Named(MarkColor::Red))
20374        );
20375        assert_eq!(d.font_size_at_caret(), None);
20376        assert_eq!(d.alignment_at_caret(), None);
20377    }
20378
20379    /// Clearing a property the block does not name and a `div` around it does
20380    /// would write nothing and change nothing — twig's `set_block_attrs`
20381    /// reaches one node, and the div is not it. The gesture says so instead of
20382    /// leaving the author pressing an entry that never ticks.
20383    #[test]
20384    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
20385        // Markdown, two paragraphs in one div: not the sole-child shape twig
20386        // writes, so `block_attrs_at_caret` reads the paragraph and the
20387        // paragraph names none of it.
20388        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
20389        let mut md = fmt_doc(src, Format::Markdown);
20390        md.caret = md.source.find("hello").unwrap();
20391        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20392
20393        md.set_alignment(None);
20394        assert_eq!(md.source, src, "nothing written");
20395        assert!(!md.dirty);
20396        assert_eq!(
20397            md.status.as_deref(),
20398            Some("alignment: set on the div around the block")
20399        );
20400        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20401
20402        // The same for a `data-` key, at both levels — the block pair and the
20403        // run three, the run three at a bare caret being the block gesture.
20404        md.set_line_spacing(None);
20405        assert_eq!(md.source, src);
20406        assert_eq!(
20407            md.status.as_deref(),
20408            Some("line spacing: set on the div around the block")
20409        );
20410        md.set_font_size(None);
20411        assert_eq!(md.source, src);
20412        assert_eq!(
20413            md.status.as_deref(),
20414            Some("size: set on the div around the block")
20415        );
20416
20417        // HTML has no sole-child fold at all: a block's attributes go on the
20418        // block, so the div around one is always out of reach.
20419        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
20420        let mut html = fmt_doc(html_src, Format::Html);
20421        html.caret = html.source.find("hi").unwrap();
20422        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
20423        html.set_alignment(None);
20424        assert_eq!(html.source, html_src);
20425        assert!(!html.dirty);
20426        assert_eq!(
20427            html.status.as_deref(),
20428            Some("alignment: set on the div around the block")
20429        );
20430
20431        // And it is a refusal, not a rule against clearing: a block that names
20432        // the property itself still loses it, div or no div.
20433        let mut own = fmt_doc(
20434            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
20435            Format::Html,
20436        );
20437        own.caret = own.source.find("hi").unwrap();
20438        own.set_alignment(None);
20439        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
20440        assert_eq!(own.status, None);
20441    }
20442
20443    /// An edited key is rewritten **where it stands**. The proposal's worked
20444    /// example is the test: a paragraph that came in as `id="intro"
20445    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
20446    /// out as the same list with one token changed. Removing the key and
20447    /// pushing it back shuffled a document's attributes on every press.
20448    #[test]
20449    fn an_edited_key_keeps_its_place_among_the_attributes() {
20450        let mut html = fmt_doc(
20451            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
20452            Format::Html,
20453        );
20454        html.caret = html.source.find("hello").unwrap();
20455        html.set_alignment(Some(Align::Right));
20456        assert_eq!(
20457            html.source,
20458            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
20459        );
20460
20461        // A `data-` key the same way, and a key the block did not have still
20462        // goes on the end.
20463        html.caret = html.source.find("hello").unwrap();
20464        html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20465        assert_eq!(
20466            html.source,
20467            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
20468        );
20469        html.caret = html.source.find("hello").unwrap();
20470        html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20471        assert_eq!(
20472            html.source,
20473            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
20474        );
20475
20476        // Djot writes the same list in its own spelling, and the order is the
20477        // author's there too.
20478        let mut dj = fmt_doc(
20479            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
20480            Format::Djot,
20481        );
20482        dj.caret = dj.source.find("hello").unwrap();
20483        dj.set_alignment(Some(Align::Right));
20484        assert_eq!(
20485            dj.source,
20486            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
20487        );
20488    }
20489
20490    /// A page break is a block, so twig alone lands one after the caret's whole
20491    /// block; the paragraph is parted at the caret first, exactly as
20492    /// `insert_thematic_break` parts it, and each format spells the directive
20493    /// its own way.
20494    #[test]
20495    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
20496        let mut md = doc_with("page_break_md", "hello world\n");
20497        md.caret = 5;
20498        md.insert_page_break();
20499        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
20500        assert!(md.dirty);
20501        assert_eq!(md.status, None);
20502
20503        let mut dj = fmt_doc("hello world\n", Format::Djot);
20504        dj.caret = 5;
20505        dj.insert_page_break();
20506        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
20507
20508        // At a block's end there is no second half to mint, so the break simply
20509        // follows the block — the rule the rule button already has.
20510        let mut end = doc_with("page_break_end", "hello\n");
20511        end.caret = 5;
20512        end.insert_page_break();
20513        assert_eq!(end.source, "hello\n\n::page-break\n\n");
20514        assert_eq!(end.caret, end.source.len(), "on the line under the break");
20515
20516        // And it reaches the map as the placeholder row a frontend paginates on.
20517        end.view = View::Wysiwyg;
20518        end.build_visual(80);
20519        assert_eq!(
20520            end.vmap
20521                .rows
20522                .iter()
20523                .find_map(|r| r.leaf_directive.as_ref())
20524                .map(|m| m.name.as_str()),
20525            Some(PAGE_BREAK)
20526        );
20527
20528        // HTML and AsciiDoc spell it their own way, and draw it the same.
20529        for (fmt, src, spelled) in [
20530            (Format::Html, "<p>hello</p>\n", "<page-break></page-break>"),
20531            (Format::Asciidoc, "hello\n", "<<<"),
20532        ] {
20533            let mut d = fmt_doc(src, fmt);
20534            d.caret = d.source.find("hello").unwrap() + 5;
20535            d.insert_page_break();
20536            assert!(d.source.contains(spelled), "{fmt:?}: {:?}", d.source);
20537            d.view = View::Wysiwyg;
20538            d.build_visual(80);
20539            let marks = d
20540                .vmap
20541                .rows
20542                .iter()
20543                .filter_map(|r| r.leaf_directive.as_ref())
20544                .map(|m| m.name.as_str())
20545                .collect::<Vec<_>>();
20546            assert_eq!(marks, [PAGE_BREAK], "{fmt:?}");
20547        }
20548    }
20549
20550    /// The general gesture a host's catalogue calls: a directive named by the
20551    /// host, with a label and attributes, written where the caret parts the
20552    /// paragraph, and read back into the map with all three — the way the
20553    /// view hands it to the host that draws it.
20554    #[test]
20555    fn insert_directive_round_trips_name_label_and_attrs() {
20556        let attrs: Attrs = vec![
20557            ("src".into(), Some("https://x.org/a".into())),
20558            ("title".into(), Some("a \"quoted\" b".into())),
20559            ("wide".into(), None),
20560        ];
20561        let mut md = doc_with("directive_md", "hello world\n");
20562        md.caret = 5;
20563        md.insert_directive("x-card", Some("A card"), &attrs);
20564        assert_eq!(md.status, None);
20565        assert_eq!(
20566            md.source,
20567            "hello\n\n::x-card[A card]{src=\"https://x.org/a\" title=\"a \\\"quoted\\\" b\" wide}\n\nworld\n"
20568        );
20569        assert!(md.dirty);
20570        md.view = View::Wysiwyg;
20571        md.build_visual(80);
20572        let [d] = md.vmap.directives.as_slice() else {
20573            panic!("one directive, got {:?}", md.vmap.directives)
20574        };
20575        assert_eq!(d.name, "x-card");
20576        assert_eq!(d.label, "A card");
20577        // A bare attribute reads back with an empty value: that is what twig
20578        // reports for Markdown's `{wide}`.
20579        assert_eq!(
20580            d.key().attrs,
20581            [
20582                ("src".to_string(), "https://x.org/a".to_string()),
20583                ("title".to_string(), "a \"quoted\" b".to_string()),
20584                ("wide".to_string(), String::new()),
20585            ]
20586        );
20587        assert_eq!(d.attr("src"), Some("https://x.org/a"));
20588
20589        // djot: the attributes on a line of their own over the fence, a bare
20590        // one spelled `key=""` so that djot reads it as an attribute at all,
20591        // and a class kept apart from the name the fence gives.
20592        let mut dj = fmt_doc("hello world\n", Format::Djot);
20593        dj.caret = 5;
20594        let dj_attrs: Attrs = vec![
20595            ("class".into(), Some("wide".into())),
20596            ("src".into(), Some("https://x.org/a".into())),
20597            ("flag".into(), None),
20598        ];
20599        dj.insert_directive("x-card", None, &dj_attrs);
20600        assert_eq!(dj.status, None);
20601        assert_eq!(
20602            dj.source,
20603            "hello\n\n{.wide src=\"https://x.org/a\" flag=\"\"}\n::: x-card\n:::\n\nworld\n"
20604        );
20605        dj.view = View::Wysiwyg;
20606        dj.build_visual(80);
20607        let [d] = dj.vmap.directives.as_slice() else {
20608            panic!("one directive, got {:?}", dj.vmap.directives)
20609        };
20610        assert_eq!(
20611            d.name, "x-card",
20612            "the fence's word, not the attribute line's class"
20613        );
20614        assert_eq!(d.label, "");
20615        assert_eq!(d.attr("class"), Some("wide"));
20616        assert_eq!(d.attr("src"), Some("https://x.org/a"));
20617        assert_eq!(d.attr("flag"), Some(""));
20618    }
20619
20620    /// `insert_page_break` is `insert_directive` with leaf's own name: the
20621    /// same bytes, the same caret, the same one undo step.
20622    #[test]
20623    fn insert_directive_places_the_caret_as_a_page_break_does() {
20624        for (fmt, body, caret) in [
20625            (Format::Markdown, "a\n\nb\n", 1),
20626            (Format::Markdown, "a\n", 1),
20627            (Format::Markdown, "hello world\n", 5),
20628            (Format::Djot, "a\n\nb\n", 1),
20629            (Format::Djot, "hello world\n", 5),
20630        ] {
20631            let run = |f: &dyn Fn(&mut Doc)| {
20632                let mut d = Doc::from_source(body.into(), fmt).unwrap();
20633                d.view = View::Wysiwyg;
20634                d.caret = caret;
20635                f(&mut d);
20636                (d.source.clone(), d.caret)
20637            };
20638            assert_eq!(
20639                run(&|d| d.insert_page_break()),
20640                run(&|d| d.insert_directive(PAGE_BREAK, None, &[])),
20641                "{fmt:?} {body:?}"
20642            );
20643        }
20644
20645        // And a host's name lands the caret on a line under it, typed into
20646        // there, and taken back in one step with that line.
20647        let mut d = Doc::from_source("a\n\nb\n".into(), Format::Markdown).unwrap();
20648        d.view = View::Wysiwyg;
20649        d.caret = 1;
20650        d.insert_directive("x-card", Some("A"), &[("k".into(), Some("v".into()))]);
20651        assert_eq!(d.source, "a\n\n::x-card[A]{k=v}\n\n\n\nb\n");
20652        d.build_visual(80);
20653        let (row, col) = d.vmap.pos_of_offset(d.caret);
20654        assert_eq!(col, 0);
20655        assert!(d.vmap.rows[row].glyphs.is_empty(), "an empty line");
20656        assert!(d.vmap.rows[row - 2].leaf_directive.is_some(), "under it");
20657        d.insert("X");
20658        assert_eq!(d.source, "a\n\n::x-card[A]{k=v}\n\nX\n\nb\n");
20659        d.undo();
20660        d.undo();
20661        assert_eq!(
20662            d.source, "a\n\nb\n",
20663            "the directive and its line are one step"
20664        );
20665    }
20666
20667    /// A name or a label twig will not write is refused before anything is
20668    /// touched — the selection it would have replaced and the paragraph it
20669    /// would have parted are both left as they were.
20670    #[test]
20671    fn insert_directive_refuses_a_bad_name_or_label_and_writes_nothing() {
20672        let src = "hello world\n";
20673        for (fmt, name, label) in [
20674            (Format::Markdown, "1x", None),
20675            (Format::Markdown, "a b", None),
20676            (Format::Markdown, "a:b", None),
20677            (Format::Markdown, "", None),
20678            (Format::Markdown, "ok", Some("a]b")),
20679            (Format::Markdown, "ok", Some("a\nb")),
20680            (Format::Djot, "a b", None),
20681            // djot has nowhere for a label to go.
20682            (Format::Djot, "ok", Some("A card")),
20683        ] {
20684            let mut d = fmt_doc(src, fmt);
20685            d.anchor = Some(2);
20686            d.caret = 5;
20687            d.insert_directive(name, label, &[]);
20688            assert_eq!(d.source, src, "{fmt:?} {name:?} {label:?}");
20689            assert!(!d.dirty, "{fmt:?} {name:?} {label:?}");
20690            assert!(!d.can_undo(), "{fmt:?} {name:?} {label:?}");
20691            let status = d.status.as_deref().unwrap_or("");
20692            assert!(
20693                status.starts_with("directive: "),
20694                "{fmt:?} {name:?} {label:?}: {status:?}"
20695            );
20696        }
20697    }
20698
20699    /// `directives` is claimed only where what is written comes back as a
20700    /// directive with its name. twig spells one in HTML and AsciiDoc too — and
20701    /// `page_break` is true there — but the walker reads their spellings of
20702    /// `page-break` alone, so an arbitrary name would be written and then
20703    /// drawn as something else, or not at all.
20704    #[test]
20705    fn directives_are_offered_only_where_the_walker_reads_them_back() {
20706        for (fmt, src) in [
20707            (Format::Markdown, "hello\n"),
20708            (Format::Djot, "hello\n"),
20709            (Format::Html, "<p>hello</p>\n"),
20710            (Format::Asciidoc, "hello\n"),
20711        ] {
20712            // What twig writes for a host's name, read back by the walker.
20713            let mut ed = twig::Editor::new_ext(src.as_bytes(), fmt, parse_extensions()).unwrap();
20714            ed.insert_directive(src.len() - 1, "x-card", None, &[("k", Some("v"))])
20715                .unwrap_or_else(|e| panic!("{fmt:?}: twig spells it: {e}"));
20716            let written = String::from_utf8(ed.source().unwrap()).unwrap();
20717            let mut d = fmt_doc(&written, fmt);
20718            d.view = View::Wysiwyg;
20719            d.build_visual(80);
20720            let read_back = d
20721                .vmap
20722                .directives
20723                .iter()
20724                .any(|m| m.name == "x-card" && m.attr("k") == Some("v"));
20725            let c = Capabilities::of(fmt);
20726            assert_eq!(c.directives, read_back, "{fmt:?}: {written:?}");
20727            assert!(c.page_break, "{fmt:?}");
20728
20729            // And where it is not offered, it is refused in the format's name.
20730            if !c.directives {
20731                let mut d = fmt_doc(src, fmt);
20732                d.caret = d.source.find("hello").unwrap() + 5;
20733                d.insert_directive("x-card", None, &[]);
20734                assert_eq!(d.source, src, "{fmt:?}");
20735                let status = d.status.as_deref().unwrap_or("");
20736                assert!(status.contains("not supported"), "{fmt:?}: {status:?}");
20737            }
20738        }
20739        assert!(!Capabilities::of(Format::Xml).directives);
20740    }
20741
20742    /// A terminal draws a host's directive as lines of its own and reports how
20743    /// many, keyed by what the directive says; the placeholder grows to that
20744    /// height the way a picture does, with blank fillers holding no caret.
20745    #[test]
20746    fn set_directive_rows_reserves_filler_rows_for_a_host_drawing() {
20747        let body = "intro\n\n::x-card[A]{src=\"u\"}\n\n::x-card[B]\n\nend\n";
20748        let mut d = wysiwyg_doc("directive_rows", body);
20749        assert_eq!(d.vmap.directives.len(), 2);
20750        assert!(d.vmap.directives.iter().all(|i| i.rows_span.len() == 1));
20751        let stops = |d: &mut Doc| {
20752            d.caret = 0;
20753            let mut seen = vec![d.caret];
20754            loop {
20755                d.move_right(false);
20756                if *seen.last().unwrap() == d.caret {
20757                    break;
20758                }
20759                seen.push(d.caret);
20760            }
20761            seen
20762        };
20763        let before = stops(&mut d);
20764
20765        let key = d.vmap.directives[0].key();
20766        assert_eq!(
20767            key,
20768            wysiwyg::DirectiveKey {
20769                name: "x-card".into(),
20770                label: "A".into(),
20771                attrs: vec![("src".into(), "u".into())],
20772            }
20773        );
20774        d.set_directive_rows(HashMap::from([(key, 4)]));
20775        d.build_visual(80);
20776        let span = d.vmap.directives[0].rows_span.clone();
20777        assert_eq!(span.len(), 4, "the four rows asked for");
20778        assert_eq!(
20779            d.vmap.rows[span.start]
20780                .leaf_directive
20781                .as_ref()
20782                .map(|m| m.rows),
20783            Some(4)
20784        );
20785        for r in span.start + 1..span.end {
20786            let row = &d.vmap.rows[r];
20787            assert!(row.decoration && row.glyphs.is_empty(), "filler {r}");
20788            assert!(row.leaf_directive.is_none(), "only the first row is marked");
20789        }
20790        assert_eq!(
20791            d.vmap.directives[1].rows_span.len(),
20792            1,
20793            "another label is another key"
20794        );
20795        assert_eq!(stops(&mut d), before, "reserving rows adds no stops");
20796
20797        // Keyed by what it says, not where it stands: an edit above keeps it.
20798        d.caret = 0;
20799        d.insert("more ");
20800        d.build_visual(80);
20801        assert_eq!(d.vmap.directives[0].rows_span.len(), 4);
20802    }
20803
20804    /// The vocabulary's capabilities, per format. The two block properties are
20805    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
20806    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
20807    /// keeps an id and a role and has no slot for a `data-` key.
20808    #[test]
20809    fn the_presentation_capabilities_are_ragged_per_format() {
20810        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
20811            let c = Capabilities::of(fmt);
20812            assert!(c.alignment, "{fmt:?} alignment");
20813            assert!(c.line_spacing, "{fmt:?} line spacing");
20814            assert!(c.font_size, "{fmt:?} size");
20815            assert!(c.font_family, "{fmt:?} face");
20816            assert!(c.text_color, "{fmt:?} colour");
20817        }
20818        // Markdown spells both only under the extensions leaf parses with — a
20819        // `<div>` and a `<span>` read back as containers under `html_elements`,
20820        // and `::page-break` as a directive under `directives`. Ask twig's own
20821        // defaults and the answer is no, which is why `Capabilities` is built
20822        // with `supports_with`.
20823        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
20824        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
20825        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
20826
20827        let adoc = Capabilities::of(Format::Asciidoc);
20828        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
20829        assert!(
20830            !adoc.font_size && !adoc.font_family && !adoc.text_color,
20831            "AsciiDoc has no inline spelling that keeps a data- key"
20832        );
20833
20834        // XML spells none of it, and neither page break — nor has it blocks a
20835        // caret could name for a move.
20836        let xml = Capabilities::of(Format::Xml);
20837        assert!(!xml.alignment && !xml.font_size && !xml.page_break && !xml.move_block);
20838        for f in [Format::Markdown, Format::Djot, Format::Html] {
20839            assert!(Capabilities::of(f).move_block, "{f:?} moves blocks");
20840        }
20841        for f in [
20842            Format::Markdown,
20843            Format::Djot,
20844            Format::Html,
20845            Format::Asciidoc,
20846        ] {
20847            assert!(Capabilities::of(f).page_break, "{f:?} breaks pages");
20848        }
20849    }
20850
20851    /// A format that cannot spell a property refuses in its own words and
20852    /// writes nothing — the guard every other gesture has.
20853    #[test]
20854    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
20855        let src = "<doc><p>hello</p></doc>\n";
20856        #[allow(clippy::type_complexity)]
20857        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
20858            ("alignment", &|d: &mut Doc| {
20859                d.set_alignment(Some(Align::Center))
20860            }),
20861            ("line spacing", &|d: &mut Doc| {
20862                d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
20863            }),
20864            ("size", &|d: &mut Doc| {
20865                d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
20866            }),
20867            ("face", &|d: &mut Doc| {
20868                d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
20869            }),
20870            ("colour", &|d: &mut Doc| {
20871                d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
20872            }),
20873            ("page break", &|d: &mut Doc| d.insert_page_break()),
20874        ];
20875        for (name, op) in ops {
20876            let mut d = fmt_doc(src, Format::Xml);
20877            let at = d.source.find("hello").unwrap();
20878            d.caret = at;
20879            d.anchor = Some(at + 5);
20880            op(&mut d);
20881            assert_eq!(d.source, src, "{name} edited an XML document");
20882            assert!(!d.dirty, "{name} marked the document dirty");
20883            let status = d.status.as_deref().unwrap_or("");
20884            assert!(
20885                status.contains("xml"),
20886                "{name}: the refusal should name the format, got {status:?}"
20887            );
20888        }
20889
20890        // AsciiDoc is the ragged one: the block gesture works where the run
20891        // gesture does not, and a *selection* is what tells the two apart.
20892        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
20893        adoc.anchor = Some(0);
20894        adoc.caret = 5;
20895        adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20896        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
20897        assert!(adoc.status.is_some());
20898    }
20899
20900    /// A read-only document takes none of it, and a caret on a blank line has
20901    /// no block to carry an attribute — both say so rather than writing.
20902    #[test]
20903    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
20904        let mut ro = fmt_doc("hello\n", Format::Djot);
20905        ro.read_only = true;
20906        ro.caret = 1;
20907        ro.set_alignment(Some(Align::Center));
20908        assert_eq!(ro.source, "hello\n");
20909
20910        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
20911        blank.caret = 2; // the empty line between the two paragraphs
20912        blank.set_alignment(Some(Align::Center));
20913        assert_eq!(blank.source, "a\n\n\nb\n");
20914        assert!(
20915            blank.status.as_deref().unwrap_or("").contains("no block"),
20916            "got {:?}",
20917            blank.status
20918        );
20919    }
20920
20921    /// A block attribute gesture keeps the caret on the **text** it was on, not
20922    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
20923    /// attributes but a `<div>` around it, and twig splices the div and the
20924    /// block it wraps as one region — so a caret that kept its offset landed in
20925    /// the markup, and every press after the first answered "no block at the
20926    /// caret" with the toolbar's queries reading nothing.
20927    #[test]
20928    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
20929        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
20930        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
20931        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
20932
20933        // Wrapping: the div and two blank lines open above the block.
20934        md.set_alignment(Some(Align::Center));
20935        assert_eq!(
20936            md.source,
20937            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
20938        );
20939        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
20940        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20941
20942        // Re-styling: the attribute line changes length under the same caret,
20943        // and the second press reaches the same block rather than nothing.
20944        md.set_alignment(Some(Align::Right));
20945        assert_eq!(
20946            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
20947            "a second press re-styles the div"
20948        );
20949        assert_eq!(md.status, None);
20950        assert_eq!(word(&md), 2);
20951
20952        // A second key on the same div — the line grows, the caret rides it.
20953        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20954        assert_eq!(
20955            md.source,
20956            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
20957        );
20958        assert_eq!(word(&md), 2);
20959        assert_eq!(
20960            md.line_spacing_at_caret(),
20961            Some(LineHeight::Step(LineSpacing::Double))
20962        );
20963
20964        // Unwrapping: the line shrinks, and then the div goes altogether.
20965        md.set_alignment(None);
20966        assert_eq!(
20967            md.source,
20968            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
20969        );
20970        assert_eq!(word(&md), 2);
20971        md.set_line_spacing(None);
20972        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
20973        assert_eq!(word(&md), 2, "the caret came back down with the block");
20974        assert_eq!(md.alignment_at_caret(), None);
20975        assert_eq!(md.status, None);
20976    }
20977
20978    /// The same rule in djot, where the spelling is a `{…}` line *above* the
20979    /// block rather than a wrapper around it: inserting it pushes the block
20980    /// down, re-styling it changes the line's length, and clearing the last key
20981    /// takes the line away again. The caret rides all three.
20982    #[test]
20983    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
20984        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
20985        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
20986        dj.caret = dj.source.find("brown").unwrap() + 2;
20987
20988        dj.set_alignment(Some(Align::Center));
20989        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
20990        assert_eq!(word(&dj), 2);
20991        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
20992
20993        dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20994        assert_eq!(
20995            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
20996            "a second press edits the line the first wrote"
20997        );
20998        assert_eq!(word(&dj), 2);
20999
21000        dj.set_alignment(None);
21001        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
21002        assert_eq!(word(&dj), 2);
21003
21004        dj.set_line_spacing(None);
21005        assert_eq!(dj.source, "the quick brown fox\n");
21006        assert_eq!(word(&dj), 2);
21007        assert_eq!(dj.status, None);
21008    }
21009
21010    /// The run gestures with no selection are the block gesture, so they keep
21011    /// the caret the same way — and a heading keeps it inside the heading's own
21012    /// text, past the `# ` its content span starts after. A selection rides
21013    /// along whole: a block gesture is not a run gesture, and what was selected
21014    /// before the press is still selected after it.
21015    #[test]
21016    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
21017        // No selection: the run gesture goes through the block door.
21018        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
21019        md.caret = md.source.find("brown").unwrap() + 2;
21020        md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
21021        assert_eq!(
21022            md.source,
21023            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
21024        );
21025        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
21026        assert_eq!(
21027            md.font_size_at_caret(),
21028            Some(FontSize::Step(SizeStep::Large))
21029        );
21030        md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
21031        assert_eq!(
21032            md.font_size_at_caret(),
21033            Some(FontSize::Step(SizeStep::Small)),
21034            "the second press reached the same block"
21035        );
21036
21037        // A selection: alignment is the block's whatever is selected, and the
21038        // words stay selected.
21039        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
21040        let at = sel.source.find("brown").unwrap();
21041        sel.anchor = Some(at);
21042        sel.caret = at + 5;
21043        sel.set_alignment(Some(Align::Center));
21044        let now = sel.source.find("brown").unwrap();
21045        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
21046
21047        // A heading: the content span starts past the `# `.
21048        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
21049        h.caret = h.source.find("there").unwrap() + 1;
21050        h.set_alignment(Some(Align::Right));
21051        assert_eq!(
21052            h.source,
21053            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
21054        );
21055        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
21056        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
21057    }
21058
21059    /// A djot document open in the rich view, with its map built as
21060    /// [`wysiwyg_doc`] builds a Markdown one's.
21061    fn wysiwyg_djot(body: &str) -> Doc {
21062        let mut d = fmt_doc(body, Format::Djot);
21063        d.view = View::Wysiwyg;
21064        d.build_visual(80);
21065        d
21066    }
21067
21068    /// Backspace at the start of a block whose presentation is spelled as
21069    /// hidden markup before it strips that presentation, the way Backspace at
21070    /// a heading's start strips its `#`. The ordinary delete fused djot's
21071    /// `{.center}` line onto the text and took the blank line a Markdown div
21072    /// needs between its tag and its paragraph.
21073    #[test]
21074    fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
21075        let mut md = wysiwyg_doc(
21076            "wys_attr_bksp",
21077            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
21078        );
21079        md.caret = md.source.find("hello").unwrap();
21080        md.backspace();
21081        assert_eq!(
21082            md.source, "above\n\nhello\n\nbelow\n",
21083            "the div is unwrapped"
21084        );
21085        assert_eq!(md.caret, 7, "the caret stays at the start of its text");
21086        md.backspace();
21087        assert_eq!(
21088            md.source, "above\nhello\n\nbelow\n",
21089            "the next press joins the paragraphs, as it always did"
21090        );
21091
21092        let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
21093        dj.caret = dj.source.find("hello").unwrap();
21094        dj.backspace();
21095        assert_eq!(
21096            dj.source, "above\n\nhello\n\nbelow\n",
21097            "the attribute line goes"
21098        );
21099        assert_eq!(dj.caret, 7);
21100
21101        // A heading's own marker is the nearer hidden markup, and goes first;
21102        // the attributes are the next press's.
21103        let mut dj = wysiwyg_djot("{.center}\n# Title\n");
21104        dj.caret = dj.source.find("Title").unwrap();
21105        dj.backspace();
21106        assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
21107        dj.build_visual(80);
21108        dj.backspace();
21109        assert_eq!(dj.source, "Title\n", "then the attributes");
21110        assert_eq!(dj.caret, 0);
21111    }
21112
21113    /// A div around several blocks has no sole child for twig to unwrap, so
21114    /// at its first block the caret steps back to the stop before rather than
21115    /// taking the div apart; a later block has an ordinary paragraph above it
21116    /// and joins as any paragraph does.
21117    #[test]
21118    fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
21119        let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
21120        let mut d = wysiwyg_doc("wys_div_first", src);
21121        d.caret = d.source.find("hello").unwrap();
21122        d.backspace();
21123        assert_eq!(d.source, src, "nothing is deleted");
21124        assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
21125
21126        let mut d = wysiwyg_doc("wys_div_later", src);
21127        d.caret = d.source.find("world").unwrap();
21128        d.backspace();
21129        assert_eq!(
21130            d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
21131            "a later block joins the one above it"
21132        );
21133    }
21134
21135    /// Backspace at the start of the paragraph after a Markdown div joins it
21136    /// into the div's last paragraph — the join any two paragraphs make, with
21137    /// the hidden `</div>` carried past the joined text. The ordinary delete
21138    /// took the newline under the tag, which drew nothing different, and the
21139    /// next press took the `>` and left the div unclosed.
21140    #[test]
21141    fn backspace_after_a_div_joins_the_paragraph_into_it() {
21142        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
21143        let mut d = wysiwyg_doc("wys_div_join", src);
21144        d.caret = d.source.find("below").unwrap();
21145        d.backspace();
21146        assert_eq!(
21147            d.source,
21148            "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
21149        );
21150        assert_eq!(
21151            d.caret,
21152            d.source.find("below").unwrap(),
21153            "the caret stays at the start of the joined text"
21154        );
21155        d.build_visual(80);
21156        assert_eq!(
21157            d.alignment_at_caret(),
21158            Some(Align::Center),
21159            "and is centred now"
21160        );
21161        d.undo();
21162        assert_eq!(d.source, src, "one undo step");
21163
21164        // A list closes the div: the paragraph joins the last item's text,
21165        // under the item's continuation indent, inside the div.
21166        let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
21167        let mut d = wysiwyg_doc("wys_div_list", src);
21168        d.caret = d.source.find("below").unwrap();
21169        d.backspace();
21170        assert_eq!(
21171            d.source, "<div class=\"center\">\n\n- item\n  below\n\n</div>\n",
21172            "the paragraph joins the item"
21173        );
21174        assert_eq!(d.caret, d.source.find("below").unwrap());
21175
21176        // And where twig has nothing to join into — a code block above — the
21177        // caret steps back to the stop before, and nothing is deleted.
21178        let src = "```\ncode\n```\n\nbelow\n";
21179        let mut d = wysiwyg_doc("wys_code_then_para", src);
21180        d.caret = d.source.find("below").unwrap();
21181        d.backspace();
21182        assert_eq!(d.source, src, "nothing is deleted");
21183        assert!(
21184            d.caret < d.source.find("below").unwrap(),
21185            "the caret stepped back"
21186        );
21187    }
21188
21189    /// Backspace on a blank line collapses to the stop before it — but not
21190    /// across hidden markup, which that collapse deleted whole: a `</div>`,
21191    /// or a comment between two blocks. There the blank line goes alone, and
21192    /// the caret lands where the collapse would have put it.
21193    #[test]
21194    fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
21195        let mut d = wysiwyg_doc(
21196            "wys_div_blank",
21197            "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
21198        );
21199        d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
21200        assert!(
21201            d.vmap.is_stop(d.caret),
21202            "the empty paragraph is a caret home"
21203        );
21204        d.backspace();
21205        assert_eq!(
21206            d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
21207            "the blank line goes and the div stays closed"
21208        );
21209        assert_eq!(
21210            d.caret,
21211            d.source.find("hello").unwrap() + 5,
21212            "onto the end of `hello`"
21213        );
21214
21215        let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
21216        d.caret = d.source.find("below").unwrap() - 2;
21217        assert!(d.vmap.is_stop(d.caret));
21218        d.backspace();
21219        assert_eq!(
21220            d.source, "above\n\n<!-- note -->\n\nbelow\n",
21221            "the comment stays"
21222        );
21223        assert_eq!(d.caret, 5);
21224    }
21225
21226    /// Backspace at the end of an attributed span steps inside its hidden
21227    /// closing tag the way it steps inside a `**`, and takes the span with
21228    /// its last letter. Before, the byte-step took the `>` of `</span>`,
21229    /// which left the paragraph unparseable: it vanished from the rich view,
21230    /// and the Backspace after that joined the next block into the wreck.
21231    #[test]
21232    fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
21233        let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
21234        let mut d = wysiwyg_doc("wys_span_bs", src);
21235        d.caret = d.source.find("a test").unwrap() + 6;
21236        for _ in 0..7 {
21237            d.backspace();
21238        }
21239        assert_eq!(
21240            d.source,
21241            "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
21242        );
21243        d.backspace();
21244        assert_eq!(
21245            d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
21246            "the first Backspace after the tag takes the letter, not the `>`"
21247        );
21248        d.backspace();
21249        assert_eq!(
21250            d.source, "above\n\nThis \n\nTest 2\n",
21251            "the last letter takes the span with it"
21252        );
21253        assert_eq!(d.caret, 12, "the caret is where the letter was");
21254        d.backspace();
21255        assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
21256        assert_eq!(d.caret, 11);
21257        d.build_visual(80);
21258        assert!(
21259            d.vmap
21260                .rows
21261                .iter()
21262                .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
21263            "the paragraph is still drawn"
21264        );
21265    }
21266
21267    #[test]
21268    fn backspace_walks_into_a_djot_sized_span_too() {
21269        let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
21270        d.caret = d.source.find("\n\nTest 2").unwrap();
21271        // The caret home at the paragraph's end is inside the span, before
21272        // its `]`: the map offers no stop after `]{…}`.
21273        d.build_visual(80);
21274        assert_eq!(d.vmap.stop_before(31), Some(8));
21275        d.caret = 8;
21276        d.backspace();
21277        assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
21278        d.backspace();
21279        assert_eq!(
21280            d.source, "This \n\nTest 2\n",
21281            "the attribute block outside the span goes with it"
21282        );
21283        d.backspace();
21284        assert_eq!(d.source, "This\n\nTest 2\n");
21285        assert_eq!(d.caret, 4);
21286    }
21287
21288    /// A span that is empty as the file was written has no stop of its own;
21289    /// Backspace reaching it from behind takes it with the character before
21290    /// it, the character the key looked aimed at.
21291    #[test]
21292    fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
21293        let src = "This <span data-size=\"x-large\"></span> a test\n";
21294        let mut d = wysiwyg_doc("wys_span_empty", src);
21295        d.caret = d.source.find(" a test").unwrap();
21296        d.backspace();
21297        assert_eq!(d.source, "This a test\n");
21298        assert_eq!(d.caret, 4);
21299    }
21300
21301    /// The mirror: Delete in front of a span's opening tag takes its first
21302    /// letter, and the span with its last.
21303    #[test]
21304    fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
21305        let src = "This <span data-size=\"x-large\">is</span> a test\n";
21306        let mut d = wysiwyg_doc("wys_span_del", src);
21307        d.caret = 5;
21308        d.delete_forward();
21309        assert_eq!(
21310            d.source,
21311            "This <span data-size=\"x-large\">s</span> a test\n"
21312        );
21313        d.delete_forward();
21314        assert_eq!(d.source, "This  a test\n");
21315        assert_eq!(d.caret, 5);
21316        d.delete_forward();
21317        assert_eq!(d.source, "This a test\n");
21318        assert_eq!(d.caret, 5);
21319    }
21320
21321    /// The block version of the span's emptying rule. Centre a one-letter
21322    /// paragraph — Markdown spells that as a `<div>` around it — and
21323    /// Backspace the letter: the div goes with it, leaving a plain blank line
21324    /// the caret is at home on, in the incremental map and the from-scratch
21325    /// one alike. Before, the letter went alone; the emptied div drew a
21326    /// caret home only the stale map had, and the next Backspace collapsed
21327    /// the line and left `<div class="center">\n\n</div>` standing invisibly
21328    /// in the file.
21329    #[test]
21330    fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
21331        let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
21332        d.caret = d.source.len() - 1;
21333        d.set_alignment(Some(Align::Center));
21334        assert_eq!(
21335            d.source,
21336            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
21337        );
21338        d.backspace();
21339        assert_eq!(d.source, "Try the toolbar.\n\n\n");
21340        assert_eq!(d.caret, 18, "on the blank line where the letter was");
21341        // The host rebuilds the map after every key; the spliced map and a
21342        // fresh one both give the line a caret home.
21343        d.build_visual_unwrapped();
21344        assert!(d.vmap.is_stop(18));
21345        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
21346        d.backspace();
21347        assert_eq!(
21348            d.source, "Try the toolbar.\n",
21349            "then the blank line collapses"
21350        );
21351        assert_eq!(d.caret, 16);
21352
21353        // With a block after the div the blank line keeps a gap each side.
21354        let mut d = wysiwyg_doc(
21355            "wys_div_empty_mid",
21356            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
21357        );
21358        d.caret = d.source.find("T\n").unwrap() + 1;
21359        d.backspace();
21360        assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
21361        assert_eq!(d.caret, 18);
21362        d.build_visual_unwrapped();
21363        assert!(d.vmap.is_stop(18));
21364        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
21365    }
21366
21367    /// djot spells the same paragraph as a `{.center}` line above it, and
21368    /// twig has no node at all for that line once the paragraph is gone —
21369    /// so the line goes with the letter too.
21370    #[test]
21371    fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
21372        let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
21373        d.build_visual(80);
21374        d.caret = d.source.len() - 1;
21375        d.set_alignment(Some(Align::Center));
21376        assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
21377        d.backspace();
21378        assert_eq!(d.source, "Try the toolbar.\n\n\n");
21379        assert_eq!(d.caret, 18);
21380        d.build_visual(80);
21381        assert!(d.vmap.is_stop(18));
21382    }
21383
21384    /// The rule is for a block that would be no block: a div holding more
21385    /// keeps its tags, and an emptied heading is still a heading.
21386    #[test]
21387    fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
21388        let mut d = wysiwyg_doc(
21389            "wys_div_more",
21390            "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
21391        );
21392        d.caret = d.source.find("T\n").unwrap() + 1;
21393        d.backspace();
21394        assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
21395        assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
21396
21397        let mut d = wysiwyg_doc(
21398            "wys_div_heading",
21399            "<div class=\"center\">\n\n# T\n\n</div>\n",
21400        );
21401        d.caret = d.source.find("T\n").unwrap() + 1;
21402        d.backspace();
21403        assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
21404        assert_eq!(d.caret, 24);
21405        d.build_visual(80);
21406        assert!(
21407            d.vmap.is_stop(24),
21408            "the empty heading is still a caret home"
21409        );
21410    }
21411
21412    /// The mirror: Delete in front of the letter takes the div with it.
21413    #[test]
21414    fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
21415        let mut d = wysiwyg_doc(
21416            "wys_div_empty_del",
21417            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
21418        );
21419        d.caret = d.source.find("T\n").unwrap();
21420        d.delete_forward();
21421        assert_eq!(d.source, "Try the toolbar.\n\n\n");
21422        assert_eq!(d.caret, 18);
21423        d.build_visual(80);
21424        assert!(d.vmap.is_stop(18));
21425
21426        let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
21427        d.build_visual(80);
21428        d.caret = d.source.find("T\n").unwrap();
21429        d.delete_forward();
21430        assert_eq!(d.source, "Try the toolbar.\n\n\n");
21431        assert_eq!(d.caret, 18);
21432    }
21433
21434    /// Backspace at a block's start is twig's join, spelled per format — so
21435    /// the cases the one-newline delete got wrong come out right: a
21436    /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
21437    /// and a quote's prefix is written on the joined line.
21438    #[test]
21439    fn backspace_at_a_block_start_joins_it_the_format_s_way() {
21440        let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
21441        d.caret = d.source.find("below").unwrap();
21442        d.backspace();
21443        assert_eq!(d.source, "# Title below\n", "onto the heading's line");
21444        assert_eq!(d.caret, d.source.find("below").unwrap());
21445        d.undo();
21446        assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
21447
21448        let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
21449        d.caret = d.source.find('b').unwrap();
21450        d.backspace();
21451        assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
21452        assert_eq!(d.caret, d.source.find('b').unwrap());
21453
21454        let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
21455        h.view = View::Wysiwyg;
21456        h.build_visual(80);
21457        h.caret = h.source.find("below").unwrap();
21458        h.backspace();
21459        assert_eq!(
21460            h.source, "<p>above\nbelow</p>\n",
21461            "one paragraph, the tag gone whole"
21462        );
21463        assert_eq!(h.caret, h.source.find("below").unwrap());
21464    }
21465
21466    /// Delete at the end of a block's content is the same join aimed at the
21467    /// block after it, and the caret stays where the joined text now begins.
21468    #[test]
21469    fn delete_at_a_block_end_joins_the_next_block_into_it() {
21470        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
21471        let mut d = wysiwyg_doc("wys_del_join", src);
21472        d.caret = d.source.find("hello").unwrap() + 5;
21473        d.delete_forward();
21474        assert_eq!(
21475            d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
21476            "below joins hello inside the div"
21477        );
21478        assert_eq!(
21479            d.caret,
21480            d.source.find("hello").unwrap() + 5,
21481            "the caret stays"
21482        );
21483
21484        let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
21485        d.caret = 5;
21486        d.delete_forward();
21487        assert_eq!(
21488            d.source, "above\nTitle\n",
21489            "the heading's marker goes with the join"
21490        );
21491        assert_eq!(d.caret, 5);
21492
21493        // A code block after the paragraph: nothing to join, the caret steps
21494        // forward onto the next stop and nothing is deleted.
21495        let src = "above\n\n```\ncode\n```\n";
21496        let mut d = wysiwyg_doc("wys_del_code", src);
21497        d.caret = 5;
21498        d.delete_forward();
21499        assert_eq!(d.source, src);
21500        assert!(d.caret > 5, "the caret stepped forward");
21501    }
21502
21503    // ── Text statistics ──────────────────────────────────────────────────────
21504
21505    /// The counts of `body`, from a document open in the WYSIWYG view — the
21506    /// shape every case below starts from.
21507    fn counts_of(name: &str, body: &str) -> TextCounts {
21508        doc_in(View::Wysiwyg, name, body).counts()
21509    }
21510
21511    #[test]
21512    fn counts_tally_plain_prose() {
21513        let c = counts_of(
21514            "counts_prose",
21515            "The quick brown fox jumps over the lazy dog.\n",
21516        );
21517        assert_eq!(
21518            c,
21519            TextCounts {
21520                words: 9,
21521                characters: 44,
21522                characters_without_spaces: 36,
21523                paragraphs: 1,
21524            }
21525        );
21526    }
21527
21528    #[test]
21529    fn counts_read_the_text_and_not_the_markup() {
21530        // The `**` are four bytes of source and no part of the word.
21531        assert_eq!(
21532            counts_of("counts_marks", "a **bold** word\n"),
21533            TextCounts {
21534                words: 3,
21535                characters: 11,
21536                characters_without_spaces: 9,
21537                paragraphs: 1,
21538            }
21539        );
21540        // A link is its label; the destination is plumbing, however long.
21541        assert_eq!(
21542            counts_of(
21543                "counts_link",
21544                "see [the label](https://example.com/a/b/c) here\n"
21545            ),
21546            TextCounts {
21547                words: 4,
21548                characters: 18,
21549                characters_without_spaces: 15,
21550                paragraphs: 1,
21551            }
21552        );
21553    }
21554
21555    #[test]
21556    fn counts_spend_nothing_on_a_picture() {
21557        // A block image renders as a `🖼 alt` placeholder — a picture, not a
21558        // sentence, and not a paragraph either.
21559        assert_eq!(
21560            counts_of("counts_image", "![a long caption](pic.png)\n"),
21561            TextCounts::default()
21562        );
21563        // And it adds nothing to the prose around it.
21564        assert_eq!(
21565            counts_of("counts_image_prose", "text\n\n![a long caption](pic.png)\n"),
21566            TextCounts {
21567                words: 1,
21568                characters: 4,
21569                characters_without_spaces: 4,
21570                paragraphs: 1,
21571            }
21572        );
21573    }
21574
21575    #[test]
21576    fn counts_spend_nothing_on_drawn_furniture() {
21577        // A thematic break is drawn, not written, and an empty paragraph has
21578        // nothing in it — neither is a paragraph of the document.
21579        assert_eq!(
21580            counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
21581            TextCounts {
21582                words: 2,
21583                characters: 6,
21584                characters_without_spaces: 6,
21585                paragraphs: 2,
21586            }
21587        );
21588    }
21589
21590    #[test]
21591    fn counts_measure_characters_as_a_reader_does() {
21592        // Four Han characters (each its own word under UAX#29), one ZWJ emoji
21593        // family that is a single grapheme cluster, and two letters.
21594        let c = counts_of(
21595            "counts_graphemes",
21596            "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
21597        );
21598        assert_eq!(
21599            c,
21600            TextCounts {
21601                words: 5,
21602                characters: 9,
21603                characters_without_spaces: 7,
21604                paragraphs: 1,
21605            }
21606        );
21607    }
21608
21609    #[test]
21610    fn counts_take_a_code_block_as_one_paragraph() {
21611        let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
21612        assert_eq!(
21613            c,
21614            TextCounts {
21615                words: 6,
21616                characters: 20,
21617                characters_without_spaces: 14,
21618                paragraphs: 1,
21619            }
21620        );
21621    }
21622
21623    #[test]
21624    fn counts_take_a_table_as_one_paragraph() {
21625        // The box-drawn borders and the column padding are the renderer's, not
21626        // the author's; the cells are what was written.
21627        let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
21628        assert_eq!(
21629            c,
21630            TextCounts {
21631                words: 5,
21632                characters: 6,
21633                characters_without_spaces: 5,
21634                paragraphs: 1,
21635            }
21636        );
21637    }
21638
21639    #[test]
21640    fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
21641        let c = counts_of(
21642            "counts_blocks",
21643            "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
21644        );
21645        assert_eq!(
21646            c,
21647            TextCounts {
21648                words: 7,
21649                characters: 36,
21650                characters_without_spaces: 34,
21651                paragraphs: 5,
21652            }
21653        );
21654    }
21655
21656    #[test]
21657    fn counts_leave_the_frontmatter_out() {
21658        // The WYSIWYG view doesn't render it and a writer didn't write it.
21659        let c = counts_of(
21660            "counts_frontmatter",
21661            "---\ntitle: Hidden\n---\n\nvisible words here\n",
21662        );
21663        assert_eq!(
21664            c,
21665            TextCounts {
21666                words: 3,
21667                characters: 18,
21668                characters_without_spaces: 16,
21669                paragraphs: 1,
21670            }
21671        );
21672    }
21673
21674    #[test]
21675    fn counts_of_an_empty_document_are_all_zero() {
21676        assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
21677    }
21678
21679    /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
21680    /// words. Recorded rather than corrected: it is what the algorithm says,
21681    /// and what every other UAX#29 counter reports.
21682    #[test]
21683    fn counts_split_a_hyphenated_compound_in_two() {
21684        let c = counts_of("counts_hyphen", "well-known example\n");
21685        assert_eq!(c.words, 3);
21686        assert_eq!(c.characters, 18);
21687        // Punctuation on its own is no word, and an apostrophe doesn't split one.
21688        assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
21689    }
21690
21691    #[test]
21692    fn selection_counts_measure_the_selection_and_nothing_without_one() {
21693        let src = "alpha beta\n\ngamma delta\n";
21694        let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
21695        assert_eq!(d.selection_counts(), None, "no selection, no counts");
21696
21697        // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
21698        d.select_range(6, 17);
21699        assert_eq!(
21700            d.selection_counts(),
21701            Some(TextCounts {
21702                words: 2,
21703                characters: 9,
21704                characters_without_spaces: 9,
21705                paragraphs: 2,
21706            })
21707        );
21708    }
21709
21710    /// The count is of the document, not of the window it is shown in — so
21711    /// ⌘E must not move it, and neither must a resize or an edit made with no
21712    /// map built at all.
21713    #[test]
21714    fn counts_agree_across_the_views() {
21715        let src =
21716            "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
21717        let mut d = doc_in(View::Wysiwyg, "counts_views", src);
21718        let wysiwyg = d.counts();
21719        assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
21720
21721        d.toggle_view();
21722        assert_eq!(d.view, View::Source);
21723        d.build_source();
21724        assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
21725
21726        // A narrower measure is a narrower window, not a shorter document.
21727        d.toggle_view();
21728        d.build_visual(24);
21729        assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
21730
21731        // And an edit made in the source view, with the visual map left stale,
21732        // still counts the document as it now stands.
21733        d.toggle_view();
21734        d.caret = d.source.len();
21735        d.insert("\n\ntail words\n");
21736        let after = d.counts();
21737        assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
21738        assert_eq!(after.words, wysiwyg.words + 2);
21739    }
21740
21741    // ── math ─────────────────────────────────────────────────────────────────
21742
21743    #[test]
21744    fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
21745        // The rule the proposal states: a formula's content is its TeX, not
21746        // its picture, so it reveals on the caret's line in *every* mode —
21747        // and nothing else on that line does outside `Full`.
21748        let mut d = doc_in(
21749            View::Wysiwyg,
21750            "math_reveal",
21751            "*one* $x+y$ here\n\ntwo there\n",
21752        );
21753        d.set_inline_pictures(true);
21754        assert_eq!(d.markup_mode(), MarkupMode::None);
21755
21756        // Away from the formula's line: the atom, and no reveal at all.
21757        caret_at(&mut d, "two");
21758        assert!(
21759            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
21760            "{:?}",
21761            drawn_rows(&d)
21762        );
21763        assert_eq!(d.vmap.math.len(), 1);
21764        assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
21765
21766        // On it: the formula is its source, the emphasis is still resolved.
21767        caret_at(&mut d, "here");
21768        assert!(
21769            drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
21770            "{:?}",
21771            drawn_rows(&d)
21772        );
21773        assert!(d.vmap.math.is_empty());
21774        assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
21775
21776        // Off again, and the picture is back.
21777        caret_at(&mut d, "two");
21778        assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
21779
21780        // The same in Shortcuts; and Full reveals the emphasis too.
21781        d.set_markup_mode(MarkupMode::Shortcuts);
21782        caret_at(&mut d, "here");
21783        assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
21784        d.set_markup_mode(MarkupMode::Full);
21785        caret_at(&mut d, "here");
21786        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21787    }
21788
21789    #[test]
21790    fn an_unrevealed_map_shows_the_carets_line_as_a_page_does() {
21791        let mut d = doc_in(
21792            View::Wysiwyg,
21793            "math_unrevealed",
21794            "*one* $x+y$ here\n\ntwo there\n",
21795        );
21796        d.set_inline_pictures(true);
21797        d.set_markup_mode(MarkupMode::Full);
21798        caret_at(&mut d, "here");
21799        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21800        let (screen, caret) = (d.visual_key(), d.caret);
21801
21802        // On paper: the delimiters hidden and the formula a picture, though
21803        // the caret has not moved off the line.
21804        d.set_unrevealed(true);
21805        d.build_visual(80);
21806        assert!(
21807            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
21808            "{:?}",
21809            drawn_rows(&d)
21810        );
21811        assert_eq!(d.vmap.math.len(), 1);
21812        assert_eq!(d.caret, caret);
21813
21814        // Off again: the screen's map is back as it was, and the next build
21815        // reuses it rather than building it over.
21816        d.set_unrevealed(false);
21817        assert_eq!(d.visual_key(), screen);
21818        d.build_visual(80);
21819        assert_eq!(d.visual_key(), screen);
21820        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21821        assert_eq!(d.caret, caret);
21822    }
21823
21824    #[test]
21825    fn a_formula_closed_by_typing_reveals_at_once() {
21826        // The reveal line is decided from the last build's layout, which
21827        // across an edit is stale: the keystroke that closes a `$…$` asks a
21828        // layout that knew no math. `build_map` asks again once the new
21829        // layout is in, so the formula does not snap to its picture under
21830        // the caret.
21831        let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
21832        d.set_inline_pictures(true);
21833        d.set_markup_mode(MarkupMode::Shortcuts);
21834        d.caret = 4;
21835        for ch in ["$", "x", "$"] {
21836            d.insert(ch);
21837            d.build_visual(80);
21838        }
21839        assert_eq!(d.source, "say $x$\n");
21840        assert_eq!(
21841            drawn_rows(&d)[0],
21842            "say $x$",
21843            "source, not a picture, under the caret"
21844        );
21845        assert!(d.vmap.math.is_empty());
21846        // Leaving the line folds it — there is only one line, so add one.
21847        d.newline();
21848        d.insert("more");
21849        d.build_visual(80);
21850        assert_eq!(drawn_rows(&d)[0], "say ∑");
21851        assert_eq!(d.vmap.math.len(), 1);
21852        // And deleting the formula while revealed drops the reveal with it.
21853        d.caret = 7;
21854        d.build_visual(80);
21855        assert_eq!(drawn_rows(&d)[0], "say $x$");
21856        for _ in 0..3 {
21857            d.backspace();
21858        }
21859        d.build_visual(80);
21860        assert_eq!(d.source, "say \n\nmore\n");
21861        assert_eq!(d.reveal_line(), None);
21862    }
21863
21864    #[test]
21865    fn a_display_block_is_edited_where_it_stands() {
21866        let mut d = doc_in(
21867            View::Wysiwyg,
21868            "math_block",
21869            "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
21870        );
21871        caret_at(&mut d, "end");
21872        assert_eq!(
21873            drawn_rows(&d),
21874            vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
21875        );
21876        // Up from `end` lands on the placeholder, whose glyphs all carry the
21877        // block's start — which is on its `$$` line, so the block reveals.
21878        d.move_up(false);
21879        d.build_visual(80);
21880        assert_eq!(d.caret, 7);
21881        assert_eq!(
21882            drawn_rows(&d),
21883            vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
21884        );
21885        // Down walks the source lines, still revealed; typing edits the TeX.
21886        d.move_down(false);
21887        d.build_visual(80);
21888        assert_eq!(d.caret, 10);
21889        d.move_end(false);
21890        d.insert("^2");
21891        d.build_visual(80);
21892        assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
21893        assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
21894        // Out below, and it folds to the placeholder with the new TeX.
21895        d.move_down(false);
21896        d.move_down(false);
21897        d.move_down(false);
21898        d.build_visual(80);
21899        assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
21900        assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
21901    }
21902
21903    #[test]
21904    fn set_math_rows_reserves_filler_rows_by_tex() {
21905        let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
21906        caret_at(&mut d, "end");
21907        assert_eq!(d.vmap.math[0].rows_span, 0..1);
21908        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
21909        d.build_visual(80);
21910        assert_eq!(d.vmap.math[0].rows_span, 0..3);
21911        assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
21912        // Cheap when nothing changed.
21913        let key = d.visual_key();
21914        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
21915        d.build_visual(80);
21916        assert_eq!(d.visual_key(), key);
21917    }
21918
21919    #[test]
21920    fn a_dollar_typed_in_shortcuts_authors_math() {
21921        let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
21922        d.set_markup_mode(MarkupMode::Shortcuts);
21923        d.caret = 0;
21924        d.insert("$x$");
21925        assert_eq!(d.source, "$x$\n");
21926        d.caret = 1;
21927        assert_eq!(d.breadcrumb(), "doc › para › inline_math");
21928
21929        // `None` keeps typed syntax literal, and under the math extension a
21930        // `$` is syntax: twig escapes it, and the paragraph stays text.
21931        let mut d = doc_in(View::Wysiwyg, "math_dollar_none", "\n");
21932        assert_eq!(d.markup_mode(), MarkupMode::None);
21933        d.caret = 0;
21934        d.insert("$x$");
21935        assert_eq!(d.source, "\\$x\\$\n");
21936        d.caret = 2;
21937        assert_eq!(d.breadcrumb(), "doc › para › str");
21938    }
21939
21940    #[test]
21941    fn counts_see_a_formula_as_a_picture_however_it_is_written() {
21942        let c = counts_of(
21943            "counts_math",
21944            "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
21945        );
21946        // `the sum … and` is three words; neither formula counts, and the
21947        // display block is not a paragraph of text.
21948        assert_eq!(c.words, 3);
21949        assert_eq!(c.paragraphs, 1);
21950    }
21951}