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`] and
742    /// [`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    /// Moving a block — [`Doc::move_block`] and the Alt+↑/↓ pair, twig's
961    /// `Gesture::MoveBlock`. Every format with blocks a caret can name; XML
962    /// has none.
963    pub move_block: bool,
964}
965
966impl Capabilities {
967    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
968    /// twig computes each from a static table — but a frontend that wants to
969    /// hold them can.
970    ///
971    /// The extensions are not a parameter because they are not a choice a
972    /// caller makes: every leaf document is parsed with [`parse_extensions`],
973    /// so the format is the whole of what varies.
974    pub fn of(format: Format) -> Self {
975        let exts = parse_extensions();
976        let supports = |g| format.supports_with(exts, g);
977        let inline = |k| supports(Gesture::ToggleInline(k));
978        let container = |k| supports(Gesture::ToggleBlockContainer(k));
979        Self {
980            bold: inline(InlineKind::Strong),
981            italic: inline(InlineKind::Emph),
982            code: inline(InlineKind::Verbatim),
983            mark: inline(InlineKind::Mark),
984            underline: inline(InlineKind::Insert),
985            strike: inline(InlineKind::Delete),
986            mark_color: supports(Gesture::SetMarkColor),
987            superscript: inline(InlineKind::Superscript),
988            subscript: inline(InlineKind::Subscript),
989            heading: supports(Gesture::SetBlock),
990            blockquote: container(BlockContainerKind::BlockQuote),
991            bullet_list: container(BlockContainerKind::BulletList),
992            ordered_list: container(BlockContainerKind::OrderedList),
993            // Both halves of the checkbox story, and leaf offers no control that
994            // needs only one: the item gesture mints the box, the checked one
995            // ticks it, and a format spelling a `task_marker` spells both.
996            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
997            link: supports(Gesture::InsertLink),
998            image: supports(Gesture::InsertImage),
999            thematic_break: supports(Gesture::InsertThematicBreak),
1000            footnote: supports(Gesture::InsertFootnote),
1001            code_block: supports(Gesture::ToggleCodeBlock),
1002            code_language: supports(Gesture::SetCodeLanguage),
1003            table: spells_pipe_tables(format),
1004            cell_line_break: supports(Gesture::InsertLineBreak),
1005            // The presentation vocabulary, one gesture per level: the two
1006            // line-level properties are a block's attributes and the three
1007            // run-level ones a span's. They are asked separately because the
1008            // formats answer differently — AsciiDoc spells the block and not
1009            // the span — and a toolbar that dimmed all five together would dim
1010            // three controls that work.
1011            alignment: supports(Gesture::SetBlockAttrs),
1012            line_spacing: supports(Gesture::SetBlockAttrs),
1013            font_size: supports(Gesture::WrapRangeAttrs),
1014            font_family: supports(Gesture::WrapRangeAttrs),
1015            text_color: supports(Gesture::WrapRangeAttrs),
1016            // Every format twig spells the directive in: the walker draws
1017            // HTML's `<page-break>` and AsciiDoc's `<<<` as the same
1018            // placeholder Markdown's `::page-break` and djot's fence get.
1019            page_break: supports(Gesture::InsertDirective),
1020            move_block: supports(Gesture::MoveBlock),
1021        }
1022    }
1023}
1024
1025/// The source of [`Doc::identity`], one per document ever built.
1026static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1027
1028impl Doc {
1029    #[cfg(feature = "fs")]
1030    pub fn open(path: PathBuf) -> Result<Self> {
1031        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1032        Self::from_disk_bytes(path, bytes)
1033    }
1034
1035    /// An empty document *named* `path`, for a file that isn't there yet — what
1036    /// every other terminal editor gives you when you name a file that doesn't
1037    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
1038    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
1039    /// the header shows the name the user asked for.
1040    ///
1041    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
1042    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
1043    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
1044    /// parse is still an error: a mistyped flag or a stray argument should say
1045    /// so, not open a buffer promising to save somewhere.
1046    ///
1047    /// The watermark is the hash of *no bytes*, not `None`, and that is the
1048    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
1049    /// answering [`DiskState::Untitled`] for a document that has a path and
1050    /// intends to write to it. Hashing `""` instead makes the answers the true
1051    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
1052    /// recreates it, which is exactly what this is for), and
1053    /// [`DiskState::Changed`] if somebody creates it underneath us between
1054    /// launch and save, so the frontend's overwrite prompt guards a new file as
1055    /// it guards an opened one.
1056    ///
1057    /// Nothing is written here. A buffer that is never typed into never touches
1058    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
1059    /// open — the write is where that fails, and it says so then.
1060    #[cfg(feature = "fs")]
1061    pub fn create(path: PathBuf) -> Result<Self> {
1062        Self::from_disk_bytes(path, Vec::new())
1063    }
1064
1065    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1066    /// the call a CLI frontend wants for its path argument.
1067    ///
1068    /// The decision is made from the failed read itself rather than a `exists()`
1069    /// check first, so there is no window between the two for the file to appear
1070    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1071    /// a directory in the way is still an error, because pretending those are
1072    /// "no file yet" would offer to save over something leaf couldn't read.
1073    #[cfg(feature = "fs")]
1074    pub fn open_or_create(path: PathBuf) -> Result<Self> {
1075        match std::fs::read(&path) {
1076            Ok(bytes) => Self::from_disk_bytes(path, bytes),
1077            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1078            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1079        }
1080    }
1081
1082    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1083    /// stand in for) the file at `path`, parsed as the format its extension
1084    /// names. Keeping the two on one path is what makes a new file's document
1085    /// identical in every respect to an opened one but its contents.
1086    #[cfg(feature = "fs")]
1087    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1088        let format = detect_format(&path)?;
1089        let editor = new_editor(&bytes, format)?;
1090        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1091        let disk_hash = Some(hash_bytes(source.as_bytes()));
1092        // Store the document's *absolute* path. A relative one (`leaf README.md`)
1093        // has an empty parent, so a frontend can't resolve a relative image
1094        // destination (`![](pic.png)`) against the document's directory and the
1095        // picture silently falls back to its text placeholder. `absolute` is
1096        // purely lexical — it prefixes the current directory and normalizes, but
1097        // reads nothing and resolves no symlinks — so `file_name` and save are
1098        // unchanged; it only gives `path.parent()` something to join against.
1099        let path = std::path::absolute(&path).unwrap_or(path);
1100        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1101    }
1102
1103    /// Build a document from an in-memory string, the format named explicitly —
1104    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1105    /// path and sniffs the format from its extension). A wasm or FFI host, which
1106    /// has no path to read, uses this: it hands over bytes it fetched however it
1107    /// could, and later persists [`Doc::source`] however it can (a browser
1108    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1109    ///
1110    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1111    /// true) exactly like a [`Doc::blank`] that has been given content.
1112    pub fn from_source(source: String, format: Format) -> Result<Self> {
1113        let editor = new_editor(source.as_bytes(), format)?;
1114        Ok(Doc::from_parts(
1115            editor,
1116            format,
1117            PathBuf::new(),
1118            source,
1119            None,
1120        ))
1121    }
1122
1123    /// An untitled, empty document — the `+` button and a `leaf` launched with
1124    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1125    ///
1126    /// It is Markdown, because a format has to be chosen before a name exists to
1127    /// read one from: `detect_format` reads the extension and an untitled
1128    /// document has neither. Markdown is what leaf's own files are, what its
1129    /// block markers are already written for (`insert_block_prefix`), and the
1130    /// extension a Save As will overwhelmingly pick — a wrong guess here would
1131    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1132    /// *doesn't* revisit this: see [`Doc::save_as`].
1133    pub fn blank() -> Result<Self> {
1134        let format = Format::Markdown;
1135        let editor = new_editor(b"", format)?;
1136        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1137        // field two frontends already read; making it an `Option` to say this
1138        // would break both). `is_untitled` is the question to ask, not the
1139        // representation to copy.
1140        Ok(Doc::from_parts(
1141            editor,
1142            format,
1143            PathBuf::new(),
1144            String::new(),
1145            None,
1146        ))
1147    }
1148
1149    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1150    /// apart in the ones neither of them has an opinion about.
1151    // `identity` is taken from a counter rather than from the `Doc`'s address,
1152    // which moves — a session that holds one is moved into and out of
1153    // containers freely, and an identity that changed with it would defeat the
1154    // one comparison it exists for.
1155    fn from_parts(
1156        editor: Editor,
1157        format: Format,
1158        path: PathBuf,
1159        source: String,
1160        disk_hash: Option<u64>,
1161    ) -> Self {
1162        Doc {
1163            editor,
1164            format,
1165            path,
1166            disk_hash,
1167            clean_source: source.clone(),
1168            source,
1169            caret: 0,
1170            anchor: None,
1171            dirty: false,
1172            status: None,
1173            read_only: false,
1174            highlights: Vec::new(),
1175            // leaf opens in the rich-text (WYSIWYG) view by default — the
1176            // markup-resolved surface is leaf's differentiator. Frontends can
1177            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1178            // toggles at runtime.
1179            view: View::Wysiwyg,
1180            // `None` by default — the clean surface Diaryx ships, with typed
1181            // syntax kept literal; a markup-fluent frontend can climb the
1182            // ladder to `Shortcuts` or `Full`.
1183            markup_mode: MarkupMode::default(),
1184            // Fold by default — flowing prose that reflows to the viewport, the
1185            // behaviour every frontend had before this preference existed.
1186            line_flow: LineFlow::default(),
1187            last_edit_kind: None,
1188            pending_marks: InlineMarks::empty(),
1189            pending_at: None,
1190            goal_col: None,
1191            vmap: VisualMap::default(),
1192            smap: SourceMap::default(),
1193            // No map yet — the first `build_source` always builds.
1194            smap_key: None,
1195            revision: 0,
1196            undo_steps: 0,
1197            redo_steps: 0,
1198            undo_group: None,
1199            #[cfg(test)]
1200            refuse_next_literal: false,
1201            // No map yet — the first `build_visual` always builds.
1202            vmap_key: None,
1203            screen: None,
1204            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1205            block_cache: wysiwyg::BlockCache::default(),
1206            surface: wysiwyg::Surface::default(),
1207            scroll: 0,
1208            body_origin: (0, 0),
1209            body_width: 0,
1210            body_height: 0,
1211            drawn_caret: None,
1212        }
1213    }
1214
1215    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1216    /// has never been saved. The question a ⌘S handler asks to know it should
1217    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1218    /// header asks to know the name it shows is a placeholder.
1219    pub fn is_untitled(&self) -> bool {
1220        self.path.as_os_str().is_empty()
1221    }
1222
1223    pub fn toggle_view(&mut self) {
1224        self.view = match self.view {
1225            View::Source => View::Wysiwyg,
1226            View::Wysiwyg => View::Source,
1227        };
1228        self.scroll = 0;
1229        self.status = None;
1230        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1231        // lift it to the first rendered offset.
1232        self.clamp_caret();
1233    }
1234
1235    /// The current markup-exposure preference (see [`MarkupMode`]).
1236    pub fn markup_mode(&self) -> MarkupMode {
1237        self.markup_mode
1238    }
1239
1240    /// Set the markup-exposure preference. Both of its axes take effect at
1241    /// once: the editing one on the next [`insert`](Self::insert), and the
1242    /// rendering one on the next build — which is why this drops the cached
1243    /// visual map and the per-block render cache, exactly as
1244    /// [`set_line_flow`](Self::set_line_flow) does.
1245    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1246        if self.markup_mode == mode {
1247            return;
1248        }
1249        self.markup_mode = mode;
1250        // Neither cache is keyed on the mode, and moving between `Full` and the
1251        // hidden modes changes every row the caret's line renders to — so
1252        // invalidate both explicitly.
1253        self.vmap_key = None;
1254        self.block_cache = wysiwyg::BlockCache::default();
1255    }
1256
1257    /// The line the caret sits on, when that line should render something
1258    /// raw — `None` when nothing on it would, which is what the builder reads
1259    /// as "reveal nothing" and what keeps caret motion from costing a build.
1260    ///
1261    /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1262    /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1263    /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1264    /// formula's content is not its picture and hiding the `$` alone would
1265    /// leave nothing to edit; there the line is threaded through only when it
1266    /// meets a block that holds one, which the last build's layout knows
1267    /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1268    /// keeps the `None` it always had, and one with math pays a rebuild only
1269    /// while the caret is in the formula's block.
1270    ///
1271    /// A *source* line (newline to newline), not a visual row: a wrapped
1272    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1273    /// line across several rows, and revealing half a delimiter pair because the
1274    /// other half wrapped would be worse than revealing neither. The range
1275    /// excludes the terminating newline and is empty-but-present on a blank
1276    /// line, which reveals nothing but still keys the caches correctly.
1277    ///
1278    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1279    /// there is nothing there to reveal.
1280    pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1281        // A page has no caret, so no line of it is the caret's.
1282        if self.view != View::Wysiwyg || self.screen.is_some() {
1283            return None;
1284        }
1285        let line = source_line_range(&self.source, self.caret);
1286        if self.markup_mode.reveals_caret_line() {
1287            return Some(Reveal::full(line));
1288        }
1289        self.block_cache
1290            .math_meets(&line)
1291            .then_some(Reveal::math(line))
1292    }
1293
1294    /// The current soft-break flow preference (see [`LineFlow`]).
1295    pub fn line_flow(&self) -> LineFlow {
1296        self.line_flow
1297    }
1298
1299    /// Set the soft-break flow preference. The mode changes how every block lays
1300    /// out, so a change drops the cached visual map and the per-block render
1301    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1302    ///
1303    /// [`build_visual`]: Self::build_visual
1304    pub fn set_line_flow(&mut self, mode: LineFlow) {
1305        if self.line_flow == mode {
1306            return;
1307        }
1308        self.line_flow = mode;
1309        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1310        // so invalidate them explicitly, or the next build would reuse rows laid
1311        // out under the old flow.
1312        self.vmap_key = None;
1313        self.block_cache = wysiwyg::BlockCache::default();
1314    }
1315
1316    pub fn view_name(&self) -> &'static str {
1317        match self.view {
1318            View::Source => "source",
1319            View::Wysiwyg => "wysiwyg",
1320        }
1321    }
1322
1323    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1324    /// (called by the renderer each frame it's in the WYSIWYG view).
1325    /// Build the WYSIWYG map, wrapped at `width` display columns.
1326    ///
1327    /// Cheap to call every frame, which is what both frontends do: the map is a
1328    /// pure function of the document and the wrap width, so a call that would
1329    /// rebuild the same map returns the one already built. Only an edit (or a
1330    /// resize) pays.
1331    ///
1332    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1333    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1334    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1335    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1336    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1337    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1338    pub fn build_visual(&mut self, width: usize) {
1339        self.build_map(Some(width));
1340    }
1341
1342    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1343    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1344    /// than a fixed character column.
1345    pub fn build_visual_unwrapped(&mut self) {
1346        self.build_map(None);
1347    }
1348
1349    /// Lay the document out as paper shows it, with no line revealed — or,
1350    /// with `false`, go back to the screen's map.
1351    ///
1352    /// The reveal is core's, folded into the rows before a frontend sees them:
1353    /// under [`MarkupMode::Full`] the caret's line shows its delimiters, and in
1354    /// every mode a formula on it is its TeX rather than its picture. A page
1355    /// has no caret, so a PDF or a printout laid out from the screen's map
1356    /// printed the one line the reader happened to be standing on as source.
1357    /// While this is on, [`build_visual`](Self::build_visual) and its twin
1358    /// build as though nothing were revealed.
1359    ///
1360    /// Kept apart from the screen's build rather than replacing it: turning
1361    /// this on sets the screen's map aside — and the caret, which a build
1362    /// clamps against the map it builds — and turning it off puts both back
1363    /// as they were, so the screen's next build costs nothing it would not
1364    /// have cost anyway. Meant to bracket one read of the map, on and then
1365    /// off; an edit in between is not the paper's to make.
1366    pub fn set_unrevealed(&mut self, on: bool) {
1367        match (on, self.screen.take()) {
1368            (true, None) => {
1369                self.screen = Some(Box::new(ScreenMap {
1370                    vmap: std::mem::take(&mut self.vmap),
1371                    key: self.vmap_key.take(),
1372                    caret: self.caret,
1373                    anchor: self.anchor,
1374                    goal_col: self.goal_col,
1375                }));
1376            }
1377            (false, Some(screen)) => {
1378                let ScreenMap {
1379                    vmap,
1380                    key,
1381                    caret,
1382                    anchor,
1383                    goal_col,
1384                } = *screen;
1385                self.vmap = vmap;
1386                self.vmap_key = key;
1387                self.caret = caret;
1388                self.anchor = anchor;
1389                self.goal_col = goal_col;
1390            }
1391            // Already in the state asked for.
1392            (_, screen) => self.screen = screen,
1393        }
1394    }
1395
1396    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1397    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1398    /// styling for [`View::Wysiwyg`].
1399    ///
1400    /// A frontend calls this before painting raw source. One that doesn't gets
1401    /// an empty map and paints unstyled text, so this is additive: nothing
1402    /// breaks by not calling it.
1403    ///
1404    /// Built at most once per revision, and the revision is the whole key — the
1405    /// map has no width and no caret in it, so it survives every resize, every
1406    /// motion, and every selection change.
1407    ///
1408    /// The builds it does do cost a whole-arena marshal, which is precisely what
1409    /// the WYSIWYG path works to avoid, so this has no incremental path where
1410    /// that one has two. From `cargo run --release -p leaf-core --example
1411    /// bench`, per keystroke, against the WYSIWYG build the source view is
1412    /// *not* doing:
1413    ///
1414    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1415    /// |------:|-------:|--------:|----------------:|-------------------:|
1416    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1417    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1418    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1419    ///
1420    /// Linear, two thirds of it the marshal, and the build itself five to seven
1421    /// times cheaper than the one it stands in for at every size. Comfortable
1422    /// well past any document a person edits in a terminal — a megabyte is where
1423    /// it would want [`Editor::dirty_range`] and the same splice treatment
1424    /// `build_spliced` gives the other map. The door is open; nothing has needed
1425    /// it yet.
1426    pub fn build_source(&mut self) {
1427        if self.smap_key == Some(self.revision) {
1428            return;
1429        }
1430        let nodes = self.nodes();
1431        self.smap = source::build(&nodes, &self.source);
1432        self.smap_key = Some(self.revision);
1433    }
1434
1435    /// Tell the model how many visual rows each block image should reserve, keyed
1436    /// by the image's destination. A terminal frontend calls this once it has
1437    /// decoded and measured its pictures — core does no image I/O, so this is the
1438    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1439    /// placeholder out that tall (the label row plus blank filler rows the
1440    /// frontend paints the raster over). A destination left out of the map falls
1441    /// back to the bare one-row placeholder, which is also what a frontend that
1442    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1443    /// by never calling this.
1444    ///
1445    /// Cheap to call every frame with the same map: only a *change* invalidates
1446    /// the built map (and the block-row cache, since a height isn't part of a
1447    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1448    /// no-op, so a frontend can just hand over its current measurements each frame.
1449    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1450        if self.surface.media_rows == rows {
1451            return;
1452        }
1453        self.surface.media_rows = rows;
1454        self.surface_changed();
1455    }
1456
1457    /// Tell the model how many visual rows each display formula should
1458    /// reserve, keyed by the formula's TeX exactly as the map's
1459    /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1460    /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1461    /// typesets the picture, measures it in cells, and reports back; a
1462    /// frontend that lays formulas out in pixels never calls this and gets
1463    /// the one-row placeholder to paint over.
1464    pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1465        if self.surface.math_rows == rows {
1466            return;
1467        }
1468        self.surface.math_rows = rows;
1469        self.surface_changed();
1470    }
1471
1472    /// Tell the model whether the frontend can paint a picture *inside* a line
1473    /// of text. When it can, an inline formula renders to one atom glyph the
1474    /// frontend draws its typeset picture over — see
1475    /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1476    /// the code-styled TeX it always showed. Off until a frontend says
1477    /// otherwise, so a host that has not caught up sees what it saw.
1478    pub fn set_inline_pictures(&mut self, on: bool) {
1479        if self.surface.inline_pictures == on {
1480            return;
1481        }
1482        self.surface.inline_pictures = on;
1483        self.surface_changed();
1484    }
1485
1486    /// A height or a capability lives outside a block's source bytes, so the
1487    /// content-keyed block cache would hand back the old rows on a hit. Drop
1488    /// it (and the splice layout it carries) so the next build re-renders
1489    /// every block against the new surface, and force that build by clearing
1490    /// the map key.
1491    fn surface_changed(&mut self) {
1492        self.block_cache = wysiwyg::BlockCache::default();
1493        self.vmap_key = None;
1494    }
1495
1496    /// The revision the document's text is at — bumped by every edit, undo,
1497    /// redo, and reload, and by nothing else. A frontend caches against this to
1498    /// tell a repaint that needs new work from one that doesn't.
1499    ///
1500    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1501    /// lands on the same text two revisions later. Work is only ever rebuilt
1502    /// needlessly, never wrongly reused.
1503    pub fn revision(&self) -> u64 {
1504        self.revision
1505    }
1506
1507    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1508    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1509    /// unbuilt map before the first one.
1510    ///
1511    /// This is *not* [`revision`](Self::revision). The revision says where the
1512    /// text is; this says where the map is, and the two part company the moment
1513    /// an edit lands, until something rebuilds. A frontend that keeps its own
1514    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1515    /// under an oversized heading — compares this against the value it held when
1516    /// it took the copy, and learns whether `vmap` is still the map it stashed
1517    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1518    /// map would paint a stale document; restoring nothing hands core's
1519    /// incremental rebuild a map it never built.
1520    ///
1521    /// "Somebody else" includes another document. The key names the `Doc`
1522    /// as well as the build, so a frontend that draws two documents through
1523    /// one stash — a host with several buffers, or one that opens the next
1524    /// document where the last one stood — never has the copy it took of one
1525    /// accepted by the other, however alike their builds are.
1526    pub fn visual_key(&self) -> VisualKey {
1527        VisualKey(self.identity, self.vmap_key.clone())
1528    }
1529
1530    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1531    /// runs on every call: the caret moves without the document changing, and
1532    /// keeping it on a legal stop is this function's job either way.
1533    fn build_map(&mut self, wrap: Option<usize>) {
1534        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1535        // as well as the text, so the line joins the key: moving within a line
1536        // still reuses the map, and crossing into another one rebuilds it. In
1537        // every other mode `reveal_line` is `None` and the key is what it was,
1538        // so caret motion goes on costing nothing.
1539        let reveal = self.reveal_line();
1540        let key = (self.revision, wrap, reveal.clone());
1541        if self.vmap_key.as_ref() != Some(&key) {
1542            self.build_map_with(wrap, reveal);
1543            self.vmap_key = Some(key);
1544            // In a hidden mode the reveal line was decided from the *previous*
1545            // build's layout, whose spans are stale across an edit: the
1546            // keystroke that closes a new `$…$` on the caret's line asked "is
1547            // there math here?" of a layout that had none, and the formula
1548            // would snap to its picture under the caret until the next
1549            // motion. Ask again of the layout just built, and go once more if
1550            // the answer moved. Between edits the first answer is exact and
1551            // this is one comparison.
1552            let again = self.reveal_line();
1553            if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1554                self.build_map_with(wrap, again.clone());
1555                self.vmap_key = Some((self.revision, wrap, again));
1556            }
1557        }
1558        self.clamp_caret();
1559    }
1560
1561    /// One build of the map at `wrap` under `reveal`, incremental where it can
1562    /// be — the body of [`build_map`](Self::build_map), which decides whether
1563    /// to call it.
1564    fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1565        {
1566            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1567            // A subtree is pulled only for the block(s) that actually changed, so
1568            // the FFI marshal shrinks from O(document) to O(edited block).
1569            let top = self.top_blocks();
1570
1571            // Fast path: when twig reports a dirty byte range, try to patch the
1572            // previous map in place — a single-block edit moves the prefix,
1573            // shifts the suffix, and re-renders only one block. `build_spliced`
1574            // returns `None` (and we fall back to the always-correct full rebuild)
1575            // whenever the edit reshaped the block structure, hit a table, or
1576            // there's no previous map to patch.
1577            // Preserve soft breaks as written when the flow preference asks for
1578            // it — the builder renders each as its own visual row instead of
1579            // folding it into the reflowed paragraph.
1580            let preserve_soft = self.line_flow == LineFlow::Preserve;
1581            let spliced = match self.editor.dirty_range() {
1582                Some(dirty) => {
1583                    let prev = std::mem::take(&mut self.vmap);
1584                    let source = &self.source;
1585                    let cache = &mut self.block_cache;
1586                    let surface = &self.surface;
1587                    let editor = &mut self.editor;
1588                    wysiwyg::build_spliced(
1589                        prev,
1590                        source,
1591                        wrap,
1592                        preserve_soft,
1593                        &top,
1594                        dirty,
1595                        surface,
1596                        reveal.clone(),
1597                        cache,
1598                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1599                    )
1600                }
1601                None => None,
1602            };
1603            self.vmap = spliced.unwrap_or_else(|| {
1604                let source = &self.source;
1605                let cache = &mut self.block_cache;
1606                let surface = &self.surface;
1607                let editor = &mut self.editor;
1608                wysiwyg::build_cached(
1609                    &top,
1610                    source,
1611                    wrap,
1612                    preserve_soft,
1613                    surface,
1614                    reveal,
1615                    cache,
1616                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1617                )
1618            });
1619            // Acknowledge the dirty range so the next edit's range starts fresh.
1620            self.editor.clear_dirty();
1621        }
1622    }
1623
1624    fn nodes(&mut self) -> Vec<FlatNode> {
1625        self.editor.nodes().unwrap_or_default()
1626    }
1627
1628    /// The document's top-level blocks for the incremental render. See
1629    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1630    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1631        wysiwyg::top_blocks(&mut self.editor)
1632    }
1633
1634    pub fn format_name(&self) -> &'static str {
1635        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1636        // required. It also covers `Asciidoc`, which twig parses but cannot
1637        // serialize — leaf never opens a document in it (see `Doc::open`).
1638        match self.format {
1639            Format::Djot => "djot",
1640            Format::Markdown => "markdown",
1641            Format::Xml => "xml",
1642            Format::Html => "html",
1643            _ => "unknown",
1644        }
1645    }
1646
1647    /// Whether this document's format offers *any* door in — `false` only for a
1648    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1649    /// a frontend may as well open the file read-only.
1650    ///
1651    /// This is a much weaker claim than the name suggests, and driving per-button
1652    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1653    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1654    /// while a heading, a quote, a list, a task box, a link and a code fence all
1655    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1656    /// [`supports`](Self::supports) — per control.
1657    pub fn authorable(&self) -> bool {
1658        self.format.is_authorable()
1659    }
1660
1661    /// Whether this document can spell `gesture`, which is twig's own answer
1662    /// rather than a copy of it: `Format::supports_with` reads the same
1663    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1664    /// the very [`parse_extensions`] this document's editor reparses with — so
1665    /// what the toolbar offers and what the splice will accept are one table.
1666    ///
1667    /// It is a fact about the *document*, not about the caret. `true` does not
1668    /// promise the gesture succeeds where it is standing — a link over a table
1669    /// border still fails — only that it will not fail with
1670    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1671    /// will work here".
1672    pub fn supports(&self, gesture: Gesture) -> bool {
1673        self.format.supports_with(parse_extensions(), gesture)
1674    }
1675
1676    /// Every control's enabled state in one read — what a toolbar builds itself
1677    /// from when a document opens or its format changes. See [`Capabilities`].
1678    pub fn capabilities(&self) -> Capabilities {
1679        Capabilities::of(self.format)
1680    }
1681
1682    /// Refuse a gesture this document's format cannot spell, saying so in the
1683    /// status line. `true` means the caller must return without calling twig.
1684    ///
1685    /// Most of these refusals duplicate one twig would make anyway, and they are
1686    /// made here regardless because a message naming the *document's* format
1687    /// reads better than one naming twig's internals. Two of them are not
1688    /// duplicates and are the reason this is a guard rather than an error
1689    /// translation:
1690    ///
1691    /// - The table family (see [`table_op`](Self::table_op)) consults no
1692    ///   `Syntax` table, so twig does not refuse it at all.
1693    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1694    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1695    ///   could not keep.
1696    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1697        self.refuse_unless(what, self.supports(gesture))
1698    }
1699
1700    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1701    /// answers itself — today only [`spells_pipe_tables`].
1702    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1703        if supported {
1704            return false;
1705        }
1706        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1707        true
1708    }
1709
1710    /// The name to show for this document. An untitled one has no file to name
1711    /// it, and both frontends put this straight on screen — an empty path
1712    /// renders as an empty header, so it says so instead.
1713    pub fn file_name(&self) -> String {
1714        if self.is_untitled() {
1715            return "untitled".into();
1716        }
1717        self.path
1718            .file_name()
1719            .map(|s| s.to_string_lossy().into_owned())
1720            .unwrap_or_else(|| self.path.display().to_string())
1721    }
1722
1723    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1724    /// caret and anchor coincide (an empty selection is no selection).
1725    pub fn selection(&self) -> Option<(usize, usize)> {
1726        self.anchor
1727            .map(|a| (a.min(self.caret), a.max(self.caret)))
1728            .filter(|(s, e)| s != e)
1729    }
1730
1731    /// The selected text, or `None` when there's no selection — the source
1732    /// slice a copy/cut hands to the system clipboard.
1733    pub fn selected_text(&self) -> Option<&str> {
1734        self.selection().map(|(s, e)| &self.source[s..e])
1735    }
1736
1737    /// The selection as a quote with a little of what surrounds it — the shape
1738    /// a host that cites, annotates, or searches for a passage wants, cut from
1739    /// the **source** rather than from anything rendered, so the quote is
1740    /// findable in the document again by plain string search.
1741    ///
1742    /// `context` is a count of characters (not bytes) on each side, clipped at
1743    /// the document's edges; the slices land on char boundaries by
1744    /// construction. `None` when nothing is selected.
1745    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1746        let (start, end) = self.selection()?;
1747        let mut before = start;
1748        for _ in 0..context {
1749            match self.source[..before].chars().next_back() {
1750                Some(c) => before -= c.len_utf8(),
1751                None => break,
1752            }
1753        }
1754        let mut after = end;
1755        for _ in 0..context {
1756            match self.source[after..].chars().next() {
1757                Some(c) => after += c.len_utf8(),
1758                None => break,
1759            }
1760        }
1761        Some(Quote {
1762            exact: self.source[start..end].to_string(),
1763            prefix: self.source[before..start].to_string(),
1764            suffix: self.source[end..after].to_string(),
1765            start,
1766            end,
1767        })
1768    }
1769
1770    /// Words, characters, and paragraphs over the whole document — the numbers
1771    /// a status bar or an inspector puts next to a piece of writing.
1772    ///
1773    /// Counted over the text a **reader** sees, not the markup that spells it:
1774    /// `**bold**` is one word and four characters, a link is its label and not
1775    /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1776    /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1777    /// not render at all — is not writing. [`crate::counts`] states the rules
1778    /// in full; [`TextCounts`] states them per field.
1779    ///
1780    /// The same numbers in both views. They have to be: a word count that fell
1781    /// when you pressed ⌘E would be telling you the view had changed, which
1782    /// you knew already. So this reads neither [`Doc::view`] nor the map the
1783    /// frontend last built — it renders the source afresh, unwrapped, with
1784    /// soft breaks folded and no line revealed, and counts that. A narrower
1785    /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1786    /// source view all give the identical answer, because none of them is an
1787    /// input.
1788    ///
1789    /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1790    /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1791    /// settle and wrong in a paint loop, so a frontend should ask when the
1792    /// typing stops rather than once a keystroke. Caching it against
1793    /// [`revision`](Self::revision) is the obvious next move if that is ever
1794    /// not enough; nothing has needed it yet.
1795    pub fn counts(&self) -> TextCounts {
1796        self.count_over(None)
1797    }
1798
1799    /// The same statistics over the selection alone — `None` when nothing is
1800    /// selected, since an empty selection is no selection.
1801    ///
1802    /// Same rules, over the same rendering, narrowed to the glyphs whose
1803    /// source byte falls inside [`selection`](Self::selection)'s range. A
1804    /// block the selection only clips still counts as one paragraph, and one
1805    /// it enters without catching a visible character counts as none — a
1806    /// selection that starts on a hidden `**` gains no paragraph from it.
1807    pub fn selection_counts(&self) -> Option<TextCounts> {
1808        let (start, end) = self.selection()?;
1809        Some(self.count_over(Some(start..end)))
1810    }
1811
1812    /// The rendering both counters tally, and the tally itself.
1813    ///
1814    /// A fresh parse rather than `self.editor`, because these take `&self` and
1815    /// twig's arena is reached through `&mut`. A document that will not
1816    /// reparse is a "cannot happen" — the source came out of an editor that
1817    /// had already accepted it — and answers zero rather than panicking in
1818    /// what is very likely a paint path.
1819    fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1820        let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1821            return TextCounts::default();
1822        };
1823        let Ok(nodes) = editor.nodes() else {
1824            return TextCounts::default();
1825        };
1826        // A surface that paints pictures in a line, so an inline formula is
1827        // an atom here and never its TeX: a formula is a picture to a reader
1828        // whichever way it is written, and the count says so consistently.
1829        let surface = wysiwyg::Surface {
1830            inline_pictures: true,
1831            ..Default::default()
1832        };
1833        let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1834        counts::tally(&map, range)
1835    }
1836
1837    /// Whether the document refuses to change — see the field.
1838    pub fn read_only(&self) -> bool {
1839        self.read_only
1840    }
1841
1842    /// Turn the read-only gate on or off. A frontend preference like
1843    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1844    /// itself changes, only what may be done to it from here on.
1845    pub fn set_read_only(&mut self, on: bool) {
1846        self.read_only = on;
1847    }
1848
1849    /// The host-painted ranges, sorted by start — see [`Highlight`].
1850    pub fn highlights(&self) -> &[Highlight] {
1851        &self.highlights
1852    }
1853
1854    /// Replace the host-painted ranges wholesale. The whole set each time,
1855    /// rather than add/remove verbs: the host owns the list (it derives it
1856    /// from its own state — annotations, search hits), and a replace can
1857    /// never leave the two disagreeing about what should be on screen.
1858    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1859        highlights.retain(|h| h.start < h.end);
1860        highlights.sort_by_key(|h| (h.start, h.end));
1861        self.highlights = highlights;
1862    }
1863
1864    /// The highlight covering source `offset`, if one does — first by start
1865    /// when several overlap, which makes overlapping washes resolvable rather
1866    /// than undefined. What a frontend asks when the reader activates a spot.
1867    ///
1868    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1869    /// asking the same question per glyph, against a slice they were handed
1870    /// rather than against a `Doc`, and one answer for both is what keeps a
1871    /// wash and an activation agreeing about which range a spot is in.
1872    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1873        Highlight::covering(&self.highlights, offset)
1874    }
1875
1876    /// The AST breadcrumb at the caret (root → deepest), e.g.
1877    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1878    pub fn breadcrumb(&mut self) -> String {
1879        match self.editor.ancestors_at(self.caret) {
1880            Ok(chain) => chain
1881                .iter()
1882                .map(|m| m.kind.as_str())
1883                .collect::<Vec<_>>()
1884                .join(" › "),
1885            Err(_) => String::new(),
1886        }
1887    }
1888
1889    // ── editing ──────────────────────────────────────────────────────────────
1890
1891    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1892    /// after it. The public form of the internal splice — a pixel frontend that
1893    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1894    /// edits through this, the same twig `edit_range` the caret ops use.
1895    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1896        self.splice(start, end, text, EditKind::Other);
1897    }
1898
1899    /// Replace `[start, end)` with `text` in place, behind the caret — an
1900    /// automatic substitution a host makes as the user types: a misspelling
1901    /// corrected, a text replacement expanded, a straight quote curled, a `--`
1902    /// made a dash. Whether it did: `false` for a read-only document, a range
1903    /// that is not one, or an edit twig refused.
1904    ///
1905    /// Unlike [`edit`](Self::edit), the caret and the selection stay where they
1906    /// were, moved along by the edit — a word corrected behind the caret does
1907    /// not pull the caret back to it — and so does a sticky mark armed at the
1908    /// caret. It is an undo step of its own, folded into neither the typing
1909    /// before it nor the typing after, so one undo puts back exactly what was
1910    /// typed, with the caret where it stood. The bytes are replaced exactly,
1911    /// snapping neither end, so a word at the edge of `**bold**` keeps its
1912    /// delimiters. `text` is written the way typing writes it: literally, with
1913    /// anything that would open markup escaped, where typing is literal
1914    /// ([`MarkupMode::None`] in the rendered view).
1915    pub fn substitute(&mut self, start: usize, end: usize, text: &str) -> bool {
1916        if self.read_only
1917            || start > end
1918            || end > self.source.len()
1919            || !self.source.is_char_boundary(start)
1920            || !self.source.is_char_boundary(end)
1921        {
1922            return false;
1923        }
1924        let before = self.source.len();
1925        let (caret, anchor) = (self.caret, self.anchor);
1926        let (pending_marks, pending_at) = (self.pending_marks, self.pending_at);
1927        let group_had_step = self.undo_group.map(|g| g.has_step);
1928        self.last_edit_kind = None;
1929        let literal = !self.markup_mode.authors()
1930            && self.view == View::Wysiwyg
1931            && !text.is_empty()
1932            && self.supports(Gesture::InsertLiteral);
1933        let landed = if literal {
1934            let deleted = self.source[start..end].to_owned();
1935            if start != end && !self.splice_exact(start, end, "", EditKind::Other) {
1936                false
1937            } else if self.insert_literal_at(start, text, EditKind::Other, start != end) {
1938                true
1939            } else {
1940                // The deletion landed and the text did not: take the deletion
1941                // back rather than leave half a substitution.
1942                if start != end {
1943                    self.restore_deleted(start, &deleted, group_had_step);
1944                }
1945                false
1946            }
1947        } else {
1948            self.splice_exact(start, end, text, EditKind::Other)
1949        };
1950        if !landed {
1951            self.caret = caret;
1952            self.anchor = anchor;
1953            self.record_caret();
1954            return false;
1955        }
1956        let delta = self.source.len() as isize - before as isize;
1957        let new_end = (end as isize + delta).max(start as isize) as usize;
1958        // A place after the range moves with it, one inside it goes to the end
1959        // of what replaced it, and one before it stays.
1960        fn shift(p: usize, start: usize, end: usize, new_end: usize, delta: isize) -> usize {
1961            if p >= end {
1962                (p as isize + delta).max(0) as usize
1963            } else if p > start {
1964                new_end
1965            } else {
1966                p
1967            }
1968        }
1969        self.caret = shift(caret, start, end, new_end, delta);
1970        self.anchor = anchor
1971            .map(|a| shift(a, start, end, new_end, delta))
1972            .filter(|&a| a != self.caret);
1973        if pending_at == Some(caret) {
1974            self.pending_marks = pending_marks;
1975            self.pending_at = Some(self.caret);
1976        }
1977        self.goal_col = None;
1978        self.last_edit_kind = None;
1979        self.clamp_caret();
1980        // The caret the step leaves, so a redo puts it back here too.
1981        self.record_caret();
1982        true
1983    }
1984
1985    /// Put back `deleted`, the bytes a half-made [`substitute`](Self::substitute)
1986    /// took out at `at`, and leave nothing of the pair on the history.
1987    ///
1988    /// Not [`undo`](Self::undo): that closes an open group and takes the whole
1989    /// of it back — every substitution of the keystroke made before this one
1990    /// — and leaves a redo step that would delete the bytes again. Instead the
1991    /// bytes go back as an edit of their own, and the two edits, which change
1992    /// nothing together, are folded away: inside a group that already had a
1993    /// step they fold into it as they land; as a group's first step, or
1994    /// outside a group, into the step before. With no step before it there is
1995    /// nothing to fold into, and one step that changes nothing is left.
1996    /// `group_had_step` is what the group said before the deletion — `None`
1997    /// outside one.
1998    fn restore_deleted(&mut self, at: usize, deleted: &str, group_had_step: Option<bool>) {
1999        if !self.splice_exact(at, at, deleted, EditKind::Other) {
2000            // twig will not take its own bytes back: the history's way, which
2001            // at least leaves the document as it was.
2002            self.undo();
2003            return;
2004        }
2005        match group_had_step {
2006            Some(true) => {}
2007            Some(false) => {
2008                let steps = self.undo_steps;
2009                self.fold_last_undo();
2010                if self.undo_steps < steps
2011                    && let Some(g) = &mut self.undo_group
2012                {
2013                    g.has_step = false;
2014                }
2015            }
2016            None => {
2017                self.fold_last_undo();
2018                self.fold_last_undo();
2019            }
2020        }
2021        self.last_edit_kind = None;
2022    }
2023
2024    /// Insert typed `text` at the caret, replacing the selection if there is one.
2025    /// A single typed character coalesces with the run of typing before it; a
2026    /// newline or a multi-character insert is its own undo step.
2027    ///
2028    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
2029    pub fn insert(&mut self, text: &str) {
2030        // The read-only gate, up front: the paths below reach twig by several
2031        // verbs, not all of them through the splice — see the field.
2032        if self.read_only {
2033            return;
2034        }
2035        // Typing against a block picture would dissolve it, and typing past a
2036        // table would grow it a row — see `open_paragraph_at_block_edge`. Give
2037        // the text a paragraph first, so what the caret was standing beside
2038        // stays what it was.
2039        self.open_paragraph_at_block_edge(text);
2040        // Armed sticky marks (⌘b with no selection) turn the next typed text
2041        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
2042        // the exception: it takes no mark of its own and keeps the delta armed
2043        // for the character behind it — see `insert_space_with_marks`.
2044        let pending = self.pending_here();
2045        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
2046            if text.trim().is_empty() {
2047                self.insert_space_with_marks(self.caret, text, pending);
2048            } else {
2049                self.insert_with_marks(self.caret, text, pending);
2050            }
2051            return;
2052        }
2053        // `MarkupMode::None`: typed syntax stays literal — twig escapes
2054        // anything that would open markup, so a Diaryx user never mints
2055        // formatting by keyboard (it comes from commands instead). The other two
2056        // rungs of the ladder author markup from what you type, which is the
2057        // whole difference between them and this one. Only in the rendered view
2058        // (source view is for typing raw markup) and only where the format has a
2059        // literal spelling at all: escaping is a backslash before a byte from the
2060        // format's own alphabet, and a format with no such alphabet (HTML escapes
2061        // with entities, XML spells nothing) would have `\&` written into it,
2062        // which is two literal characters and not an escape. Marks (⌘b) still
2063        // format — that path returned above; and leaf's own structural inserts go
2064        // through `insert_raw`, never here, so a list marker or quote gutter is
2065        // written as the markup it is.
2066        if !self.markup_mode.authors()
2067            && self.view == View::Wysiwyg
2068            && !text.is_empty()
2069            && self.supports(Gesture::InsertLiteral)
2070        {
2071            self.insert_literal_typed(text);
2072            return;
2073        }
2074        self.insert_raw(text);
2075    }
2076
2077    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
2078    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
2079    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
2080    /// markup by design and must not be escaped.
2081    fn insert_raw(&mut self, text: &str) {
2082        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2083        self.splice(s, e, text, typed_edit_kind(text));
2084    }
2085
2086    /// Open a paragraph for text about to be inserted at one of a block media's
2087    /// two caret stops, or at a table's trailing stop, and leave the caret
2088    /// standing in it.
2089    ///
2090    /// A block image is a paragraph whose entire content is the picture, and the
2091    /// caret's only homes on it are in front of it and just past it (see
2092    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
2093    /// *that* paragraph — and a paragraph holding anything besides the image is
2094    /// no longer a block image but a line of text with an inline one in it. The
2095    /// frontend that was painting a photo there paints a text run instead; the
2096    /// picture is still in the file, and nothing said a word. Those two offsets
2097    /// are also exactly where a click on the picture lands, so the whole accident
2098    /// is one tap and one keystroke.
2099    ///
2100    /// So the break goes in first and the text lands in the new empty paragraph —
2101    /// what pressing Return before typing would have done, which is a habit no
2102    /// one should have to learn from losing a photo. A no-op everywhere else, and
2103    /// over a selection (which is replaced, not joined into).
2104    ///
2105    /// A picture inside a quote or a list leaves its container, because `\n\n`
2106    /// ends the block. The alternative is worse: the `\n> ` / next-item
2107    /// continuation [`newline`](Self::newline) writes stays in the same
2108    /// *paragraph*, which is the thing being prevented.
2109    ///
2110    /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
2111    /// accident from the other side of a different block: the stop sits at the
2112    /// end of the table's last source line, and a line glued under a table is
2113    /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
2114    /// break goes in there too, and the text lands under the table.
2115    ///
2116    /// Only in the rendered view. Source view is for typing raw markup, where
2117    /// putting a character against an image is exactly what it looks like.
2118    fn open_paragraph_at_block_edge(&mut self, text: &str) {
2119        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
2120            return;
2121        }
2122        if self.selection().is_some() {
2123            return;
2124        }
2125        // The map may be a revision behind (nothing has drawn since the last
2126        // edit), and this asks it about offsets — a stale answer would splice a
2127        // break into the wrong place. Free when it is already current, which it
2128        // is whenever a frontend drew a frame between keystrokes.
2129        self.rebuild_map();
2130        let at = self.caret;
2131        let side = match self.vmap.block_media_stop(at) {
2132            Some((side, _)) => side,
2133            None if self.vmap.table_end_stop(at) => MediaStop::After,
2134            None => return,
2135        };
2136        if !self.splice(at, at, "\n\n", EditKind::Other) {
2137            return;
2138        }
2139        // The break is part of the keystroke, not an edit of its own: leave the
2140        // run marked as typing so the character about to arrive folds into it and
2141        // one undo puts the document back the way it was found. (A paste, or a
2142        // multi-character insert, is `EditKind::Other` and stays its own step —
2143        // as it would have been anywhere else in the document.)
2144        self.last_edit_kind = Some(EditKind::Insert);
2145        if side == MediaStop::Before {
2146            // The break went in above the picture and the caret rode to the end
2147            // of it — which is still hard against the picture. Step back onto the
2148            // blank line it opened, so the text lands above rather than in front.
2149            self.caret = at;
2150        }
2151    }
2152
2153    /// A delete key pressed at one of a block picture's two caret stops, handled
2154    /// as the picture being an *atom* rather than a run of bytes. Returns whether
2155    /// the key was consumed.
2156    ///
2157    /// The caret rests in front of a block image and just past it, never inside
2158    /// its markup — which the rendered view doesn't show. So the byte a delete
2159    /// key nominally takes there is one the writer cannot see, and taking it
2160    /// leaves the picture as broken markup rather than as anything anyone asked
2161    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
2162    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
2163    /// missing from a document with nobody having touched it — the same
2164    /// dissolution [`open_paragraph_at_block_edge`](Self::open_paragraph_at_block_edge)
2165    /// prevents from the typing side, and it cost this repository's own test vault
2166    /// a photo before it was found.
2167    ///
2168    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
2169    /// when it is behind the caret, Delete when it is in front — which is what
2170    /// every editor does with an embed, and one undo away. The key aimed *away*
2171    /// from it would otherwise delete the paragraph break and merge a neighbour
2172    /// into the picture's own paragraph, which dissolves it just as surely; it
2173    /// steps the caret over the boundary instead and leaves the
2174    /// next press to delete in the block it has reached — the same "first press
2175    /// steps out of the atom, second press deletes" every delete key here gets,
2176    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
2177    /// above, and reaches it on the second press rather than taking the break and
2178    /// the picture with it on the first).
2179    fn delete_around_block_media(&mut self, forward: bool) -> bool {
2180        // The map answers about offsets, so it has to be this revision's — see
2181        // the same call in `open_paragraph_at_block_edge`.
2182        self.rebuild_map();
2183        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
2184            return false;
2185        };
2186        let aimed_at_it = side
2187            == if forward {
2188                MediaStop::Before
2189            } else {
2190                MediaStop::After
2191            };
2192        if !aimed_at_it {
2193            let over = if forward {
2194                self.vmap.stop_after(self.caret)
2195            } else {
2196                self.vmap.stop_before(self.caret)
2197            };
2198            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
2199                self.caret = off;
2200                self.anchor = None;
2201                self.goal_col = None;
2202            }
2203            return true;
2204        }
2205        // Take the break that held the picture apart from its neighbour with it,
2206        // so the delete doesn't leave a blank paragraph standing where the
2207        // picture was. The last arm is a picture that is the whole document.
2208        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
2209            (span.start - 2, span.end)
2210        } else if self.source[span.end..].starts_with("\n\n") {
2211            (span.start, span.end + 2)
2212        } else {
2213            (span.start, span.end)
2214        };
2215        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
2216        true
2217    }
2218
2219    /// The Hidden-mode typing path: replace any selection, then insert `text`
2220    /// escaped so it stays literal. When it replaces a selection the two edits
2221    /// fold into one undo step, so an overwrite undoes atomically (and restores
2222    /// the selection) exactly as a plain one does.
2223    fn insert_literal_typed(&mut self, text: &str) {
2224        let kind = typed_edit_kind(text);
2225        match self.selection() {
2226            Some((s, e)) => {
2227                if !self.splice(s, e, "", EditKind::Other) {
2228                    return;
2229                }
2230                // Typing over a whole marked run takes its delimiters with it
2231                // (the empty content couldn't hold them — see
2232                // `repair_mark_edges`) and leaves its marks armed at the caret.
2233                // The text taking the run's place inherits them, exactly as it
2234                // would have by landing inside a run that survived.
2235                let pending = self.pending_here();
2236                if !pending.is_empty() && !text.trim().is_empty() {
2237                    self.insert_with_marks(self.caret, text, pending);
2238                    return;
2239                }
2240                self.insert_literal_at(self.caret, text, kind, true);
2241            }
2242            None => {
2243                self.insert_literal_at(self.caret, text, kind, false);
2244            }
2245        }
2246    }
2247
2248    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2249    /// at a collapsed caret, but only while the caret still stands where they
2250    /// were armed and nothing is selected. Empty otherwise, so a stale delta
2251    /// never styles text it wasn't meant for.
2252    fn pending_here(&self) -> InlineMarks {
2253        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2254            self.pending_marks
2255        } else {
2256            InlineMarks::empty()
2257        }
2258    }
2259
2260    /// Drop the armed sticky marks — any caret motion, selection, or edit does
2261    /// this, so "start bold here" only ever applies at the exact spot it was
2262    /// asked for.
2263    fn clear_pending(&mut self) {
2264        self.pending_marks = InlineMarks::empty();
2265        self.pending_at = None;
2266    }
2267
2268    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2269    /// force is wrapped around the freshly typed text; a mark the caret already
2270    /// stands inside is *shed* — the text is inserted past the run's end so it
2271    /// lands unmarked ("type normally again"). The caret comes to rest inside any
2272    /// added runs, so continued typing inherits the marks with no re-wrapping,
2273    /// and the delta is cleared: the marks now live in the document, not here.
2274    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2275        let base = self.mark_spans_at(at);
2276        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2277        // Nothing to shed, and a run of exactly these marks standing just behind
2278        // the caret: carry on writing *that* run rather than opening a second
2279        // one beside it.
2280        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2281            return;
2282        }
2283        // Shed the marks we're turning off: step the insertion point past the
2284        // end of each run the caret sits in, so the new text falls outside it.
2285        let mut ins_at = at;
2286        for (kind, span) in &base {
2287            if marks.contains(*kind) {
2288                ins_at = ins_at.max(span.end);
2289            }
2290        }
2291        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2292            return;
2293        }
2294        // The plain splice inserted exactly `text` at `ins_at`; that byte range
2295        // is the content every added mark wraps.
2296        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2297        for kind in marks.iter() {
2298            if !base_set.contains(kind) {
2299                let (ncs, nce) = self.wrap_span(cs, ce, kind);
2300                cs = ncs;
2301                ce = nce;
2302            }
2303        }
2304        self.caret = ce.min(self.source.len());
2305        self.anchor = None;
2306        self.last_edit_kind = None;
2307        // Realised: the marks are in the document now, and the caret sits inside
2308        // them, so there is no delta left to carry. Arm nothing, but remember the
2309        // spot so a *further* toggle before typing starts a clean delta here.
2310        self.pending_marks = InlineMarks::empty();
2311        self.pending_at = Some(self.caret);
2312        self.clamp_caret();
2313        self.record_caret();
2314    }
2315
2316    /// Carry on the marked run just behind `at` — moving its closing delimiters
2317    /// out past the new text — instead of opening a second run of the same marks
2318    /// beside it. Returns whether it did.
2319    ///
2320    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2321    /// A space typed after a bold word steps the caret out of the run, because
2322    /// `**bold **` is not bold; the next character has to step back *in*, or the
2323    /// writer who typed one bold phrase is left with `**bold** **and**` — two
2324    /// runs that read the same to a reader but spell the file in a way nobody
2325    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2326    /// words it isn't marking), and the marks behind it must be exactly the ones
2327    /// armed — a run of *some* other kind is a neighbour, not this phrase.
2328    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2329        if text.is_empty() || text.trim() != text {
2330            return false;
2331        }
2332        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2333        // Walk in through the delimiters stacked at that point, innermost last:
2334        // `***both*** ` closes two runs with one `***`, and rejoining means
2335        // getting behind all of them.
2336        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2337        while let Some((kind, content_end)) = self
2338            .editor
2339            .ancestors_at(prev_boundary(&self.source, cut))
2340            .unwrap_or_default()
2341            .into_iter()
2342            .filter(|m| m.span.end == cut)
2343            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2344        {
2345            if content_end >= cut {
2346                break; // a mark with no closing delimiter to step behind
2347            }
2348            kinds.insert(kind);
2349            cut = content_end;
2350        }
2351        if cut == gap_at || kinds != marks {
2352            return false;
2353        }
2354        // Re-spell the tail: the gap, then the new text, then the delimiters that
2355        // used to close in front of them — read out of the document rather than
2356        // written from a table, so whatever twig spells them with is what moves.
2357        let tail = format!(
2358            "{}{text}{}",
2359            &self.source[gap_at..at],
2360            &self.source[cut..gap_at]
2361        );
2362        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2363            return false;
2364        }
2365        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2366        self.anchor = None;
2367        self.last_edit_kind = None;
2368        self.pending_marks = InlineMarks::empty();
2369        self.pending_at = Some(self.caret);
2370        self.clamp_caret();
2371        self.record_caret();
2372        true
2373    }
2374
2375    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2376    /// never itself wrapped: a mark around a space draws nothing a reader can
2377    /// see, and in Markdown and Djot it draws its own delimiters instead
2378    /// (`** **`). So the space goes in unmarked — outside any run the armed
2379    /// marks are shedding — and the marks stay armed for the character after it,
2380    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2381    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2382        let base = self.mark_spans_at(at);
2383        // What the *next* character carries: the armed delta resolved against the
2384        // marks in force here, which the space must not quietly drop.
2385        let want = base
2386            .iter()
2387            .map(|(k, _)| *k)
2388            .collect::<InlineMarks>()
2389            .xor(marks);
2390        let mut ins_at = at;
2391        for (kind, span) in &base {
2392            if marks.contains(*kind) {
2393                ins_at = ins_at.max(span.end);
2394            }
2395        }
2396        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2397            return;
2398        }
2399        self.rearm(want);
2400        self.record_caret();
2401    }
2402
2403    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2404    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2405    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2406    /// added split evenly around the content — half the growth on each side.
2407    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2408        // The read-only gate — this door reaches twig without the splice.
2409        if self.read_only {
2410            return (s, e);
2411        }
2412        match self.editor.toggle_inline(s, e, kind) {
2413            Ok(change) => {
2414                self.last_edit_kind = None;
2415                self.refresh();
2416                self.dirty = self.source != self.clean_source;
2417                let added = (change.new.end - change.new.start).saturating_sub(e - s);
2418                let half = added / 2;
2419                (change.new.start + half, change.new.end - half)
2420            }
2421            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2422            // rather than lose the keystroke.
2423            Err(e2) => {
2424                self.status = Some(format!("{kind:?}: {e2}"));
2425                (s, e)
2426            }
2427        }
2428    }
2429
2430    /// The safe offset to splice a block-level break at, given a caret that may
2431    /// sit exactly between an inline mark's content and its own closing
2432    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2433    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2434    /// with nothing following it on the line: the closing `**` renders no
2435    /// glyph of its own, so the caret's "end of line" offset lands right
2436    /// before it). Splicing a paragraph/list/quote break at `off` itself would
2437    /// sever the delimiter from its content, stranding it alone on the new
2438    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2439    /// nested marks closing at the same point (`**_x_**`) all clear together.
2440    /// A no-op everywhere else — mid-run, or past real trailing content, no
2441    /// mark's `content_span` ends exactly at `off`.
2442    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2443        let off = off.min(self.source.len());
2444        let runs = self.run_span_ids();
2445        self.editor
2446            .ancestors_at(off)
2447            .unwrap_or_default()
2448            .into_iter()
2449            .filter(|m| hides_delims(m, &runs))
2450            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2451            .map(|m| m.span.end)
2452            .max()
2453            .unwrap_or(off)
2454    }
2455
2456    /// The offset a *delete* aimed at the character before `off` should stop at,
2457    /// when `off` is the start of a run's text and the bytes behind it are that
2458    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2459    /// byte behind the caret at the start of a bold word is not a character the
2460    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2461    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2462    /// delete steps over the whole delimiter to the visible character in front of
2463    /// it instead. Walks out to the *outermost* mark opening there, so
2464    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2465    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2466        let off = off.min(self.source.len());
2467        let runs = self.run_span_ids();
2468        self.editor
2469            .ancestors_at(off)
2470            .unwrap_or_default()
2471            .into_iter()
2472            .filter(|m| hides_delims(m, &runs))
2473            .filter(|m| {
2474                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2475            })
2476            .map(|m| m.span.start)
2477            .min()
2478            .unwrap_or(off)
2479    }
2480
2481    /// `off` moved *inside* the run whose closing delimiters end there — the
2482    /// other offset the rich view draws in the same place, since a `**` renders
2483    /// no glyph of its own. `**bold**` has a caret home on each side of its
2484    /// closing delimiter, one column apart on screen and eight bytes and a whole
2485    /// run apart in the file, and a plain ← lands on the outer one whenever a
2486    /// space follows the phrase. The inner one is what the writer is pointing at
2487    /// there: the end of their bold word. Walks in through every mark closing at
2488    /// that point, innermost last, so `***both***` lands inside both. A no-op
2489    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2490    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2491        let mut off = off.min(self.source.len());
2492        let runs = self.run_span_ids();
2493        loop {
2494            let inner = self
2495                .editor
2496                .ancestors_at(prev_boundary(&self.source, off))
2497                .unwrap_or_default()
2498                .into_iter()
2499                .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2500                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2501                .filter(|&end| end < off)
2502                .max();
2503            match inner {
2504                Some(end) => off = end,
2505                None => return off,
2506            }
2507        }
2508    }
2509
2510    /// The mirror at the opening edge: `off` moved inside the run whose
2511    /// delimiters *start* there, onto the first character of its text. See
2512    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2513    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2514        let mut off = off.min(self.source.len());
2515        let runs = self.run_span_ids();
2516        loop {
2517            let inner = self
2518                .editor
2519                .ancestors_at(off)
2520                .unwrap_or_default()
2521                .into_iter()
2522                .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2523                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2524                .filter(|&start| start > off)
2525                .min();
2526            match inner {
2527                Some(start) => off = start,
2528                None => return off,
2529            }
2530        }
2531    }
2532
2533    /// The ids of the document's attributed run spans — the inline
2534    /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2535    /// which sees an ancestor chain and so only a kind. Read once per gesture,
2536    /// not once per step of a walk.
2537    fn run_span_ids(&mut self) -> Vec<NodeId> {
2538        self.nodes()
2539            .iter()
2540            .filter(|n| wysiwyg::is_run_span(n))
2541            .map(|n| n.id)
2542            .collect()
2543    }
2544
2545    /// The attributed span whose text is exactly `content` — the whole of
2546    /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2547    /// and sits between the tags of `<span …></span>` — as the whole range
2548    /// spelling the span: the node's span, widened to its attribute block
2549    /// where the format writes that outside the node, as djot's
2550    /// `[i]{data-size="large"}` does. `None` for any other range, including
2551    /// part of a span's text.
2552    ///
2553    /// An empty span has an interior of no bytes, or no known interior at
2554    /// all: twig gives Markdown's `<span …></span>` the first and djot's
2555    /// `[]{…}` the second, and the chain already says the offset is inside.
2556    fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2557        let runs = self.run_span_ids();
2558        let m = self
2559            .editor
2560            .ancestors_at(content.start)
2561            .unwrap_or_default()
2562            .into_iter()
2563            .filter(|m| runs.contains(&NodeId(m.node_id)))
2564            .find(|m| match &m.content_span {
2565                Some(c) => *c == content,
2566                None => content.is_empty(),
2567            })?;
2568        let mut range = m.span;
2569        if let Some(attrs) = self
2570            .editor
2571            .document()
2572            .ok()
2573            .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2574        {
2575            range.start = range.start.min(attrs.start);
2576            range.end = range.end.max(attrs.end);
2577        }
2578        Some(range)
2579    }
2580
2581    /// The attributed block whose whole text is exactly `content` — the `T`
2582    /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2583    /// `{.center}\nT` — as the range a delete that takes that text takes with
2584    /// it: the whole `<div>` when the block is all the div holds, or the
2585    /// `{…}` line down to the end of the text. The block version of
2586    /// [`run_span_of_content`](Self::run_span_of_content), for the same
2587    /// reason: a paragraph with no text is no block, so the div would stand
2588    /// around nothing and the `{…}` line above nothing, and a from-scratch
2589    /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2590    /// `None` for a block with more text, a div holding more, a heading (an
2591    /// empty `# ` is still a heading), and a format whose attributes are the
2592    /// block's own tag (HTML's `<p class="center"></p>` is still a
2593    /// paragraph).
2594    fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2595        if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2596            return None;
2597        }
2598        let nodes = self.nodes();
2599        let block = nodes
2600            .iter()
2601            .filter(|n| n.kind == Kind::Para)
2602            .find(|n| n.content_span.as_ref() == Some(&content))?;
2603        match self.format {
2604            Format::Djot => {
2605                let attrs = self
2606                    .editor
2607                    .document()
2608                    .ok()
2609                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2610                (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2611            }
2612            _ => {
2613                let div = block
2614                    .parent
2615                    .and_then(|p| nodes.iter().find(|n| n.id == p))
2616                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2617                let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2618                alone.then(|| div.span.clone())
2619            }
2620        }
2621    }
2622
2623    /// The inline mark kinds whose span covers `off`, each with that span — the
2624    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2625    /// ids instead. Used to shed a mark by stepping past the end of its run.
2626    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2627        let off = off.min(self.source.len());
2628        self.editor
2629            .ancestors_at(off)
2630            .unwrap_or_default()
2631            .into_iter()
2632            .filter(|m| off < m.span.end)
2633            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2634            .collect()
2635    }
2636
2637    /// Insert clipboard `text` at the caret, replacing the selection if there is
2638    /// one — always its own undo step, whatever its length.
2639    ///
2640    /// Provenance is the whole point, and only the caller has it. `insert` reads
2641    /// a lone character as a keystroke and folds it into the run around it,
2642    /// which is right for typing and wrong for a one-character paste: that paste
2643    /// would vanish mid-run on an undo it was never part of, and the characters
2644    /// the user actually typed would go with it. Length can't tell the two
2645    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2646    /// caller comes through is what says which happened.
2647    pub fn paste(&mut self, text: &str) {
2648        // Pasting against a block picture or a table's end joins the block
2649        // exactly as typing does, and for the same reason — see
2650        // `open_paragraph_at_block_edge`.
2651        self.open_paragraph_at_block_edge(text);
2652        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2653        self.splice(s, e, text, EditKind::Other);
2654    }
2655
2656    /// Replace `[start, end)` with `text` as one step of an IME composition —
2657    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2658    /// folds into a single undo.
2659    ///
2660    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2661    /// dozen calls here, each replacing the last one's provisional bytes, and an
2662    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2663    /// unspools backwards through kana — the intermediate states were never text
2664    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2665    /// look like any other edit), so the door the caller comes through is what
2666    /// says so, exactly as it is for [`paste`](Self::paste) versus
2667    /// [`insert`](Self::insert).
2668    ///
2669    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2670    /// composition folds into this one.
2671    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2672        self.splice(start, end, text, EditKind::Compose);
2673    }
2674
2675    /// Close the open composition run, so the next one is its own undo step.
2676    /// Call when the IME commits or withdraws a composition.
2677    ///
2678    /// Only clears a *composition* run: a frontend that reports an end it never
2679    /// began (some IMEs unmark unprompted) would otherwise split the run of
2680    /// typing around it into two undo steps for no reason the user can see.
2681    pub fn end_composition(&mut self) {
2682        if self.last_edit_kind == Some(EditKind::Compose) {
2683            self.last_edit_kind = None;
2684        }
2685    }
2686
2687    // ── the clipboard's rich flavor ──────────────────────────────────────────
2688
2689    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2690    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2691    /// nothing is selected, or when the selection doesn't render (the caller
2692    /// still has [`selected_text`](Self::selected_text), which is what to publish
2693    /// as `text/plain` either way).
2694    ///
2695    /// **The fragment is a source substring, and that is the honest limit here.**
2696    /// It's parsed standalone, so a selection whose meaning depends on its
2697    /// surroundings converts as what it literally says rather than what it looks
2698    /// like on screen: half a list item is a paragraph, a row torn out of a table
2699    /// is the text of a row, the `**` of a bold run selected without its closing
2700    /// `**` is two asterisks. Every one of those still *renders* — there's no
2701    /// error to report — it just renders as the fragment and not as the document.
2702    /// Widening the range to whole blocks would publish text the user didn't
2703    /// select, which is a worse lie than a fragment being a fragment; the plain
2704    /// flavor has the same substring, so the two flavors at least agree.
2705    pub fn selection_html(&mut self) -> Option<String> {
2706        let (start, end) = self.selection()?;
2707        let inline = self.selection_is_inline(start, end);
2708        let html = html::render_fragment(&self.source[start..end], self.format)?;
2709        Some(match inline {
2710            true => html::strip_sole_paragraph(html),
2711            false => html,
2712        })
2713    }
2714
2715    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2716    /// format first. Its own undo step, like any [`paste`](Self::paste).
2717    ///
2718    /// Returns whether it landed. `false` means the HTML didn't convert to
2719    /// anything worth pasting — the caller should fall back to the plain flavor
2720    /// rather than treat it as an error. The `html` module has the full list of
2721    /// what that covers: a table twig won't build, markup it doesn't recognise,
2722    /// an empty result.
2723    pub fn paste_html(&mut self, html: &str) -> bool {
2724        match html::parse_fragment(html, self.format) {
2725            Some(source) => {
2726                self.paste(&source);
2727                true
2728            }
2729            None => false,
2730        }
2731    }
2732
2733    /// Does the selection live *inside* a single top-level block?
2734    ///
2735    /// The question [`selection_html`](Self::selection_html) needs and the
2736    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2737    /// whether the user selected one word of a sentence or a whole paragraph, and
2738    /// only the document knows which. Selecting a word and pasting into Docs
2739    /// should extend the line you paste into; selecting the paragraph should make
2740    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2741    /// is an artifact of standalone parsing), and one that covers a whole block —
2742    /// or spans two — keeps its structure.
2743    ///
2744    /// Reads the block from twig rather than guessing from the bytes:
2745    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2746    /// block containing an offset, and two ends inside the same one cannot have
2747    /// crossed a block boundary.
2748    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2749        // The last *character*, not `end - 1`: the selection's end is exclusive
2750        // and may sit mid-codepoint's-worth of bytes past the last char.
2751        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2752            return false;
2753        };
2754        let (Some(head), Some(tail)) =
2755            (self.top_block_span(start), self.top_block_span(start + off))
2756        else {
2757            return false;
2758        };
2759        head == tail && !(start <= head.start && end >= head.end)
2760    }
2761
2762    /// The byte span of the top-level block containing `offset`, or `None` at an
2763    /// offset that belongs to no block (the blank line between two of them).
2764    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2765        self.editor
2766            .ancestors_at(offset)
2767            .ok()?
2768            .get(1)
2769            .map(|m| m.span.clone())
2770    }
2771
2772    // ── indentation ──────────────────────────────────────────────────────────
2773
2774    /// One indent level.
2775    ///
2776    /// Two spaces, not the four both frontends type for Tab today, because in a
2777    /// markdown document four columns isn't a width — it's a *meaning*. Four
2778    /// spaces at the head of a line is markdown's indented-code-block marker, so
2779    /// one Tab on a paragraph would reparse it into code and style it as such;
2780    /// two cannot, and the line stays the prose it was. Two is also exactly
2781    /// where a `- ` bullet's content starts, so an indented line lands under its
2782    /// parent item's text instead of beside it — the column a list-aware indent
2783    /// has to hit anyway, which keeps this width from being relitigated later.
2784    const INDENT: &'static str = "  ";
2785
2786    /// Indent the selected lines — or the caret's line, with no selection — by
2787    /// one level (Tab).
2788    pub fn indent(&mut self) {
2789        self.reindent(true);
2790        // Nesting changes an ordered list's numbering (the nested item restarts,
2791        // its old siblings resume) — keep the source markers in step.
2792        self.renumber_here();
2793        // Nesting an empty `-` item under a text line reparses that text as a
2794        // setext heading; swap the dash for a `*` before it can (a no-op unless
2795        // the collapse actually happened).
2796        self.avoid_setext_collapse();
2797    }
2798
2799    /// Take one indent level back off the selected lines, or the caret's line
2800    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2801    ///
2802    /// A line with *less* than a full level gives back what it has rather than
2803    /// refusing: outdent's job is to walk a line left, and real documents — hand
2804    /// written, or reflowed by some other editor — are full of indentation that
2805    /// was never a clean multiple of anything. Refusing there would strand the
2806    /// line at a depth Shift+Tab couldn't undo.
2807    pub fn outdent(&mut self) {
2808        self.reindent(false);
2809        self.renumber_here();
2810    }
2811
2812    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2813    ///
2814    /// One splice across the whole line range, never one per line: a Tab is one
2815    /// thing the user did, so it has to be one undo step and one reparse. Per
2816    /// line, twig would reparse the document once per line and leave a stack of
2817    /// steps that Shift+⌘Z walks back one line at a time.
2818    fn reindent(&mut self, add: bool) {
2819        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2820        let start = source_line_range(&self.source, sel_start).start;
2821        let end = source_line_range(&self.source, sel_end).end;
2822        let region = self.source[start..end].to_string();
2823        let lines: Vec<&str> = region.split('\n').collect();
2824        // A blank line has no text to move, and padding it would leave nothing
2825        // but trailing whitespace — but Tab on a blank line *is* a request for
2826        // indentation to type into, so the skip only applies where the op has
2827        // other lines to do real work on.
2828        let skip_blank = add && lines.len() > 1;
2829
2830        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2831        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2832        let mut line_off = start;
2833        for (i, full) in lines.iter().enumerate() {
2834            if i > 0 {
2835                out.push('\n');
2836            }
2837            // A list item moves by having its whole leading prefix *replaced*,
2838            // never by having spaces pushed in front of the line. twig spells
2839            // both prefixes, so the quote markers, the parent's indent and an
2840            // ordered marker's extra column all come out right without leaf
2841            // measuring any of them — and a line that only looks like an item
2842            // (a Djot continuation) reports no marker and is left to the plain
2843            // path, where a Tab is just a Tab.
2844            let marker = self.list_marker_on_line(line_off);
2845            let own = marker
2846                .as_ref()
2847                .map(|m| m.marker_start - m.line_start)
2848                .unwrap_or(0);
2849            let delta = if add {
2850                if skip_blank && full.trim().is_empty() {
2851                    out.push_str(full);
2852                    0
2853                } else if marker.is_some() && self.first_item_of_list(line_off) {
2854                    // The first item of a list has no preceding sibling to nest
2855                    // under, so a Tab here can't spell a sub-list — twig would
2856                    // reparse the shoved-over marker as the same list, only
2857                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2858                    // item where it is, the way every list editor refuses to
2859                    // over-indent a list's first line.
2860                    out.push_str(full);
2861                    0
2862                } else if marker.is_some() {
2863                    // Nesting means standing where a *continuation* of this line
2864                    // would stand: past the parent's marker, inside its content
2865                    // column. That is `continuation_prefix`, less a checkbox.
2866                    let new = self.nesting_prefix_at(line_off);
2867                    let delta = new.len() as isize - own as isize;
2868                    out.push_str(&new);
2869                    out.push_str(&full[own..]);
2870                    delta
2871                } else {
2872                    out.push_str(Self::INDENT);
2873                    out.push_str(full);
2874                    Self::INDENT.len() as isize
2875                }
2876            } else if marker.is_some() {
2877                // Unnesting is the mirror: stand where the parent item's own
2878                // line starts, which drops exactly the level it contributed.
2879                let new = self.outdent_prefix_at(line_off);
2880                let delta = new.len() as isize - own as isize;
2881                out.push_str(&new);
2882                out.push_str(&full[own..]);
2883                delta
2884            } else {
2885                // A plain line gives back the ordinary step.
2886                let strip = outdent_width(full, Self::INDENT.len());
2887                out.push_str(&full[strip..]);
2888                -(strip as isize)
2889            };
2890            deltas.push(delta);
2891            line_off += full.len() + 1;
2892        }
2893        // Nothing to give back. Returning before the splice keeps an outdent at
2894        // column zero from spending an undo step on a document it never changed.
2895        if deltas.iter().all(|d| *d == 0) {
2896            return;
2897        }
2898
2899        // Every line's text keeps its offset *within the line*, so the caret is
2900        // remapped by its column, not by its byte offset — which the prefixes on
2901        // the lines above it have already invalidated.
2902        let remap = |off: usize| -> usize {
2903            let (mut old_ls, mut new_ls) = (start, start);
2904            for (line, delta) in lines.iter().zip(&deltas) {
2905                let old_le = old_ls + line.len();
2906                let new_len = (line.len() as isize + delta) as usize;
2907                if off <= old_le {
2908                    let col = (off - old_ls) as isize;
2909                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2910                }
2911                old_ls = old_le + 1;
2912                new_ls += new_len + 1;
2913            }
2914            start + out.len()
2915        };
2916        let placed = match self.selection() {
2917            // Keep the rewritten region selected, the way a container toggle
2918            // keeps its own: it leaves a second Tab aimed at the same lines
2919            // rather than at whatever the shifted offsets now happen to cover.
2920            Some(_) => (start + out.len(), Some(start)),
2921            None => (remap(self.caret), None),
2922        };
2923
2924        // A rolled-back splice leaves the old source in place, where every offset
2925        // computed above addresses text that was never written.
2926        if !self.splice(start, end, &out, EditKind::Other) {
2927            return;
2928        }
2929        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2930        // rewrite is the last line's end — nowhere the caret was. Place it, then
2931        // re-record the caret so this is the state redo restores, not the one
2932        // `splice` left behind from the `Change`.
2933        self.caret = placed.0.min(self.source.len());
2934        self.anchor = placed.1;
2935        self.clamp_caret();
2936        self.record_caret();
2937    }
2938
2939    /// The Enter key.
2940    ///
2941    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2942    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2943    /// caret is in decides what actually gets written.
2944    ///
2945    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2946    ///                            block at the caret and reopens its container
2947    ///   - list item            → likewise: the next item, its indent, quote
2948    ///                            prefix and `[ ]` box all reproduced by twig —
2949    ///                            except an *empty* item, which exits the list
2950    ///   - block quote          → likewise: a new paragraph inside the quote
2951    ///   - heading              → a new *paragraph*, not another heading
2952    ///   - code block           → a literal newline (stay in the block)
2953    ///   - blank line           → a literal newline (one Backspace undoes it)
2954    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2955    ///                            visible line
2956    ///
2957    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2958    /// and it is better at it: it drops the whitespace the caret was sitting in
2959    /// front of instead of stranding it at the head of the second half, and it
2960    /// knows continuations leaf's marker scan never covered — a checklist item
2961    /// continues as an *unchecked* checklist item rather than a plain bullet.
2962    ///
2963    /// The exceptions above are exceptions because `split_block` is either wrong
2964    /// there or refuses: parting a fence yields two fences with the code split
2965    /// between them, parting a heading yields a second heading where every editor
2966    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2967    /// table all report an error rather than a split.
2968    pub fn newline(&mut self) {
2969        if self.view == View::Source {
2970            self.insert_raw("\n");
2971            return;
2972        }
2973        // Enter over a selection replaces it with a paragraph break.
2974        if let Some((s, e)) = self.selection() {
2975            self.splice(s, e, "\n\n", EditKind::Other);
2976            return;
2977        }
2978        // A caret resting exactly between an inline mark's content and its own
2979        // closing delimiter (`**bold**` with nothing after it on the line —
2980        // the WYSIWYG caret's natural end-of-line position) must not splice a
2981        // block break there: every path below eventually does via
2982        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2983        // delimiter would strand it alone on the new line.
2984        self.caret = self.skip_trailing_close_delims(self.caret);
2985        // The block the caret is in. `block_offset_for_caret` nudges off a line
2986        // end (where the caret sits at the doc level); on a bare line (e.g. an
2987        // empty list item) fall back to the caret so the enclosing list/quote is
2988        // still visible in the ancestors.
2989        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2990        let kinds: Vec<Kind> = self
2991            .editor
2992            .ancestors_at(off)
2993            .map(|c| c.into_iter().map(|m| m.kind).collect())
2994            .unwrap_or_default();
2995        let has = |k: Kind| kinds.contains(&k);
2996
2997        if has(Kind::CodeBlock) {
2998            self.insert_raw("\n");
2999            return;
3000        }
3001        // An *empty* list item exits the list — the standard double-Enter — which
3002        // `split_block` reports as an error rather than a split (there is no
3003        // content to part), so it stays leaf's. `list_marker_on_line` is itself
3004        // the AST gate — it answers from the tree, so a `- ` that reads as a
3005        // marker byte-for-byte but opens no item (a setext underline, a Djot
3006        // continuation line) never reaches here.
3007        if let Some(marker) = self.list_marker_on_line(self.caret)
3008            && self.item_is_empty(&marker)
3009        {
3010            self.exit_list(&marker);
3011            return;
3012        }
3013        // On an *empty* paragraph line, a lone Enter should add a single blank line,
3014        // not another full paragraph break — so it moves down one line and one
3015        // Backspace undoes it, not two. (`split_block` errors here too.)
3016        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3017        let line_end = self.source[self.caret..]
3018            .find('\n')
3019            .map_or(self.source.len(), |i| self.caret + i);
3020        if self.source[line_start..line_end].trim().is_empty() {
3021            self.insert_raw("\n");
3022            return;
3023        }
3024        // In `Preserve` flow a soft break is a *visible* line the author means to
3025        // make, so Enter writes a single `\n` and typing continues the same
3026        // paragraph on the next line — the behaviour of an ordinary text editor.
3027        // A second Enter then lands on the blank line above and takes the
3028        // empty-line branch, so double-Enter still promotes to a full paragraph
3029        // break; and Backspace, which deletes a lone `\n` over a soft break,
3030        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
3031        // render as an invisible space, so Enter keeps making the paragraph break
3032        // that actually shows.
3033        //
3034        // Only in running prose. A list or a quote has a continuation of its own
3035        // to write, and a `\n` there is not a soft line but a lost container.
3036        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
3037        if self.line_flow == LineFlow::Preserve && !in_container {
3038            self.insert_raw("\n");
3039            return;
3040        }
3041        // A heading gets a *paragraph*, never a second heading: Enter at the end
3042        // of a title is how every editor is asked for the body under it, and
3043        // `split_block` would repeat the `#` instead. Whitespace at the split
3044        // point goes with the break rather than opening the new paragraph, which
3045        // is what `split_block` does everywhere else.
3046        if has(Kind::Heading) {
3047            let mut end = self.caret;
3048            while self.source.as_bytes().get(end) == Some(&b' ') {
3049                end += 1;
3050            }
3051            self.splice(self.caret, end, "\n\n", EditKind::Other);
3052            return;
3053        }
3054        self.split_block_here();
3055    }
3056
3057    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
3058    /// the caret in the second half.
3059    ///
3060    /// twig reopens whatever the first half was inside of — the bullet with its
3061    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
3062    /// reason this replaced the markup leaf used to spell from the line's bytes.
3063    /// It renumbers nothing, though: a new item mid-list is written with its
3064    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
3065    /// behind it, folded into the same undo step.
3066    ///
3067    /// Falls back to a plain paragraph break if twig declines, so an unhandled
3068    /// shape still moves the caret down rather than swallowing the keystroke.
3069    fn split_block_here(&mut self) {
3070        // The read-only gate — this door reaches twig without the splice.
3071        if self.read_only {
3072            return;
3073        }
3074        match self.editor.split_block(self.caret) {
3075            Ok(change) => {
3076                self.last_edit_kind = None;
3077                self.refresh();
3078                self.anchor = None;
3079                self.caret = change.new.end;
3080                self.dirty = self.source != self.clean_source;
3081                self.status = None;
3082                self.clamp_caret();
3083                self.record_caret();
3084                // Aimed at the new block's *start*: the caret twig leaves is one
3085                // past the marker it wrote, where there is no list in reach.
3086                self.renumber_at(change.new.start);
3087            }
3088            Err(_) => self.insert_raw("\n\n"),
3089        }
3090    }
3091
3092    /// Whether the item on the marker's line carries no content — the shape
3093    /// double-Enter reads as "I'm done with this list."
3094    fn item_is_empty(&self, line: &ListMarker) -> bool {
3095        let content_start = line.content_start().min(self.source.len());
3096        let line_end = self.source[self.caret..]
3097            .find('\n')
3098            .map(|i| self.caret + i)
3099            .unwrap_or(self.source.len());
3100        self.source[content_start..line_end.max(content_start)]
3101            .trim()
3102            .is_empty()
3103    }
3104
3105    /// Leave the list: replace the empty item's marker with a blank line, so the
3106    /// caret lands in a fresh paragraph below it.
3107    ///
3108    /// Inside a quote the blank line has to stay quoted (a bare one would end the
3109    /// quote), and the caret's new line keeps the `> ` it was already behind —
3110    /// leaving the list without also leaving the quote.
3111    fn exit_list(&mut self, line: &ListMarker) {
3112        let prefix = self.quote_prefix_at(line.marker_start);
3113        let blank = prefix.trim_end();
3114        self.splice(
3115            line.line_start,
3116            self.caret,
3117            &format!("{blank}\n{prefix}"),
3118            EditKind::Other,
3119        );
3120    }
3121
3122    /// What a line continuing the containers at `off` has to open with — the
3123    /// quote markers reproduced, each enclosing item's marker as its width in
3124    /// spaces. Also the column a nested item's marker stands in, which is what
3125    /// makes it Tab's answer.
3126    fn continuation_prefix_at(&mut self, off: usize) -> String {
3127        self.editor
3128            .document()
3129            .and_then(|mut d| d.continuation_prefix(off))
3130            .map(|p| p.text)
3131            .unwrap_or_default()
3132    }
3133
3134    /// The column a *nested list* may open at inside the item at `off` — which
3135    /// is not always where the item's own text continues.
3136    ///
3137    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
3138    /// it is markup a rich view hides, and the item's own wrapped text does
3139    /// stand past it. But a nested list may only open at the *list* marker's
3140    /// column, and four columns further in is an indented continuation of the
3141    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
3142    /// So the box's own width goes back.
3143    ///
3144    /// The one place leaf still reads a checkbox's spelling. It goes when twig
3145    /// reports the list marker's column apart from the box; `checked` is what
3146    /// says a box is there at all, so only its width is being measured here.
3147    fn nesting_prefix_at(&mut self, off: usize) -> String {
3148        let cont = self.continuation_prefix_at(off);
3149        let Some(item) = self.innermost_list_item(off) else {
3150            return cont;
3151        };
3152        if item.checked.is_none() {
3153            return cont;
3154        }
3155        let box_width = item
3156            .marker_span
3157            .and_then(|m| self.source.get(m))
3158            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
3159            .unwrap_or(0);
3160        // The trailing columns are the ones the item's own marker contributed,
3161        // so trimming from the end leaves any quote prefix standing.
3162        cont[..cont.len().saturating_sub(box_width)].to_string()
3163    }
3164
3165    /// Where the line of the item *containing* the item at `off` begins — the
3166    /// prefix Shift+Tab moves back to, which gives up exactly the level the
3167    /// parent contributed. The quote prefix alone for a top-level item, which
3168    /// has no level left to give.
3169    fn outdent_prefix_at(&mut self, off: usize) -> String {
3170        let items: Vec<usize> = self
3171            .editor
3172            .document()
3173            .and_then(|mut d| d.ancestors_at_caret(off))
3174            .map(|c| {
3175                c.into_iter()
3176                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3177                    .map(|m| m.span.start)
3178                    .collect()
3179            })
3180            .unwrap_or_default();
3181        // The second-innermost item is the parent; its own line's indent is the
3182        // target. `list_marker_on_line` gives that line's prefix directly.
3183        let parent = items.len().checked_sub(2).map(|i| items[i]);
3184        match parent.and_then(|p| self.list_marker_on_line(p)) {
3185            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
3186            None => self.quote_prefix_at(off),
3187        }
3188    }
3189
3190    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
3191    /// inside one, `"> > "` inside two.
3192    ///
3193    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
3194    /// the `>` and the space after it are twig's spelling rather than leaf's.
3195    /// The whole line prefix can't answer this: it also carries the indent of
3196    /// whatever the quote holds, which a blank separator line must *not* repeat.
3197    fn quote_prefix_at(&mut self, off: usize) -> String {
3198        let Ok(chain) = self
3199            .editor
3200            .document()
3201            .and_then(|mut d| d.ancestors_at_caret(off))
3202        else {
3203            return String::new();
3204        };
3205        let quotes: Vec<usize> = chain
3206            .iter()
3207            .filter(|m| m.kind == Kind::BlockQuote)
3208            .map(|m| m.node_id as usize)
3209            .collect();
3210        let Ok(nodes) = self.editor.nodes() else {
3211            return String::new();
3212        };
3213        quotes
3214            .iter()
3215            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
3216            .filter_map(|s| self.source.get(s))
3217            .collect()
3218    }
3219
3220    /// Whether the item at `off` sits inside another one — the test Backspace
3221    /// uses to choose between outdenting and dropping the marker.
3222    ///
3223    /// Counted from the AST rather than from the line's leading whitespace,
3224    /// which is indentation in Markdown and, in Djot, may be nothing at all.
3225    fn item_is_nested(&mut self, off: usize) -> bool {
3226        self.editor
3227            .document()
3228            .and_then(|mut d| d.ancestors_at_caret(off))
3229            .map(|c| {
3230                c.into_iter()
3231                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3232                    .count()
3233                    > 1
3234            })
3235            .unwrap_or(false)
3236    }
3237
3238    /// The innermost list item containing `probe`, under twig's **caret**
3239    /// containment rule — a block's end is inside it.
3240    ///
3241    /// Half-open containment can't answer this. An empty item's span is exactly
3242    /// its marker, so the caret sitting after `- ` is one past the end and the
3243    /// item it is plainly in tests as out of reach; that is the shape
3244    /// double-Enter has to recognise to leave the list.
3245    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3246        let chain = self
3247            .editor
3248            .document()
3249            .and_then(|mut d| d.ancestors_at_caret(probe))
3250            .ok()?;
3251        let id = chain
3252            .iter()
3253            .rev()
3254            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3255            .node_id as usize;
3256        self.editor.nodes().ok()?.get(id).cloned()
3257    }
3258
3259    /// The list marker opening `off`'s line, per twig — `None` when that line
3260    /// opens no list item.
3261    ///
3262    /// [`Document::line_prefix`] is the whole hidden run from the line start:
3263    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
3264    /// and it is `None` on a *continuation* line, which opens nothing. That last
3265    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
3266    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3267    /// a paragraph and `  - b` is literal text — identical bytes, and only the
3268    /// parser knows which document it is looking at.
3269    ///
3270    /// The item's own marker is separated out via its
3271    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3272    /// the containers around it contribute and what the item does.
3273    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3274        let off = off.min(self.source.len());
3275        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3276        // The prefix belongs to a list only when an item's marker closes it —
3277        // a heading's `# ` or a bare quote's `> ` is a prefix too.
3278        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3279        let marker = item.marker_span.clone()?;
3280        if marker.end != prefix.end {
3281            return None;
3282        }
3283        Some(ListMarker {
3284            line_start: prefix.start,
3285            marker_start: marker.start,
3286            text: self.source.get(prefix)?.to_string(),
3287        })
3288    }
3289
3290    /// Whether the list item on `line_start`'s line is the **first item** of its
3291    /// list — the one Tab must not nest, because nesting needs a preceding
3292    /// sibling to become the new parent and a first item has none. `false` for a
3293    /// line that isn't a list item, and for an item with a sibling above it (the
3294    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3295    /// the same in a setext underline that opens no list at all.
3296    fn first_item_of_list(&mut self, line_start: usize) -> bool {
3297        let Some(marker) = self.list_marker_on_line(line_start) else {
3298            return false;
3299        };
3300        // Probe just inside the marker, where the item's own node is in reach —
3301        // the marker offset itself can resolve to the enclosing list, not the
3302        // `list_item`, whose span starts at the marker.
3303        let probe = marker.content_start().min(self.source.len());
3304        let Some(item) = self.innermost_list_item(probe) else {
3305            return false;
3306        };
3307        let Ok(nodes) = self.editor.nodes() else {
3308            return false;
3309        };
3310        match item.parent {
3311            // First when the parent list opens with this very item.
3312            Some(pid) => nodes
3313                .get(pid.0 as usize)
3314                .is_some_and(|p| p.first_child == Some(item.id)),
3315            // A parentless item is trivially the first (and only) one.
3316            None => true,
3317        }
3318    }
3319
3320    pub fn backspace(&mut self) {
3321        if let Some((s, e)) = self.selection() {
3322            self.splice(s, e, "", EditKind::Other);
3323            return;
3324        }
3325        // WYSIWYG: Backspace at the very start of a list item's content is a
3326        // structural key, not a character delete — it walks the "un-indent, then
3327        // un-list" ladder every list editor gives that keystroke (outdent a
3328        // nested item, strip a top-level one's marker to a paragraph). In source
3329        // view the `- ` is visible text the user is deleting a byte of, so it
3330        // keeps its literal meaning there, like Enter does.
3331        if self.view != View::Source && self.backspace_list_start() {
3332            return;
3333        }
3334        // WYSIWYG: and the same at the start of a heading's content — the `# `
3335        // there is markup the rich view hides, not text the user typed.
3336        if self.view != View::Source && self.backspace_heading_start() {
3337            return;
3338        }
3339        // WYSIWYG: and at the start of a block whose presentation is spelled
3340        // as hidden markup before it — djot's `{.center}` line, Markdown's
3341        // `<div class="center">` — Backspace takes that markup, the way it
3342        // takes a heading's `#`, rather than a byte out of it.
3343        if self.view != View::Source && self.backspace_attributed_block_start() {
3344            return;
3345        }
3346        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3347        // the markup apart under a caret that cannot see it — see
3348        // `delete_around_block_media`.
3349        if self.view != View::Source && self.delete_around_block_media(false) {
3350            return;
3351        }
3352        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3353        // cell rather than taking the byte behind the caret — the row's closing
3354        // `|`, which the rich view never drew, so the key would have looked like
3355        // it did nothing. The stop before is the last cell's end.
3356        if self.view != View::Source && self.backspace_at_table_end() {
3357            return;
3358        }
3359        // WYSIWYG: a table cell's start is a wall. The bytes behind it are the
3360        // padding and the `|` that make the grid, and taking them merges two
3361        // cells — a structural edit nobody asked for, and one no table editor
3362        // gives the key. Tab and Shift+Tab are how the caret leaves a cell.
3363        if self.view != View::Source && self.at_cell_wall(BreakEdge::Backward) {
3364            return;
3365        }
3366        // WYSIWYG: at the start of a block's content, the byte behind the caret
3367        // is a block boundary, and Backspace over one is a join — twig's, so
3368        // that what a join is in each format is not this file's to know. After
3369        // the picture and table cases, which are block starts with their own
3370        // answers.
3371        if self.view != View::Source && self.backspace_joins_block() {
3372            return;
3373        }
3374        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3375        // stop, not a single newline. On a line with no text of its own, the byte
3376        // before the caret is a `\n` that spells part of a block boundary — the gap
3377        // between two blocks, drawn but never a caret home. Removing just it strands
3378        // the caret in that gap and leaves an odd blank line the eye reads as one
3379        // separator but the caret can't land on: the "extra newline" left behind
3380        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3381        // Deleting to the previous stop instead collapses the whole break at once,
3382        // landing the caret at the end of the block above. Two blank lines in a row
3383        // are one stop apart, so this still removes exactly one — the lone-Enter /
3384        // lone-Backspace symmetry the empty-line case is built on is untouched.
3385        if self.view != View::Source
3386            && self.caret > self.caret_floor()
3387            && self.caret_on_blank_line()
3388            && let Some(stop) = self.vmap.stop_before(self.caret)
3389        {
3390            let stop = stop.max(self.caret_floor());
3391            if stop < self.caret {
3392                if self.source[stop..self.caret].trim().is_empty() {
3393                    self.splice(stop, self.caret, "", EditKind::Delete);
3394                } else {
3395                    // Hidden markup stands between the stop and the caret — a
3396                    // `</div>`, a comment, a link reference definition — and
3397                    // collapsing to the stop would delete it. Take the blank
3398                    // line alone, with the newline that opened it, and land
3399                    // the caret where the collapse would have.
3400                    self.delete_blank_line_to(stop);
3401                }
3402                return;
3403            }
3404        }
3405        if self.caret > self.caret_floor() {
3406            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3407            // takes the whole tag — a single-byte step would leave a broken `<br`
3408            // showing in the cell. Rich view only (source view edits the literal).
3409            if self.view != View::Source
3410                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3411            {
3412                let start = start.max(self.caret_floor());
3413                if start < end {
3414                    self.splice(start, end, "", EditKind::Delete);
3415                    return;
3416                }
3417            }
3418            // Aim the delete at the character the writer can *see* behind the
3419            // caret, never at a delimiter the rich view drew nothing for. Two
3420            // steps, and either can apply: from the far side of a run's closing
3421            // `**` step back into the run (the caret is drawn at the end of its
3422            // word), and at the start of a run's text step out past its opening
3423            // `**` to the character in front of it, leaving the run standing.
3424            // Without them a plain Backspace unspells the phrase it is editing
3425            // and leaves a literal asterisk on screen.
3426            let end = if self.view == View::Source {
3427                self.caret
3428            } else {
3429                let inside = self.step_inside_close_delims(self.caret);
3430                // An attributed span with no text — `<span …></span>` as the
3431                // file was written — is hidden markup around nothing, and a
3432                // byte-step here would take its `>`. Backspace takes the span
3433                // whole, with the character before it: the character the key
3434                // looks aimed at, since the span draws nothing.
3435                if let Some(span) = self.run_span_of_content(inside..inside) {
3436                    let from = if self.source[..span.start].ends_with('\n') {
3437                        span.start
3438                    } else {
3439                        prev_boundary(&self.source, span.start)
3440                    };
3441                    let from = from.max(self.caret_floor());
3442                    self.splice(from, span.end, "", EditKind::Delete);
3443                    return;
3444                }
3445                self.skip_leading_open_delims(inside)
3446                    .max(self.caret_floor())
3447            };
3448            // Never delete back across the floor — that would eat hidden
3449            // frontmatter the WYSIWYG caret can't even see.
3450            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3451            // Take a hidden escape backslash with the char it escapes: the rich
3452            // view draws `\*` as a single `*`, so Backspace over it must delete
3453            // both bytes, never strand the `\` as a lone visible backslash (the
3454            // mirror of the Hidden-mode typing that wrote the escape). Source view
3455            // shows the `\`, so there it is an ordinary character.
3456            if self.view != View::Source
3457                && prev > self.caret_floor()
3458                && self.is_hidden_escape(prev - 1)
3459            {
3460                prev -= 1;
3461            }
3462            // The delete that takes the last of a span's text takes the span
3463            // with it, in the same edit: `<span …>i</span>` losing its `i`
3464            // would leave an empty span the map has no stop inside, so the
3465            // caret would draw at the next stop — a line away — until a
3466            // further key removed the span. Landing on the span's start is
3467            // where the letter was.
3468            if self.view != View::Source
3469                && let Some(span) = self.run_span_of_content(prev..end)
3470            {
3471                self.splice(span.start, span.end, "", EditKind::Delete);
3472                return;
3473            }
3474            // And the same for a block: the letter that was all of a centred
3475            // paragraph's text goes with the `<div>` around it, or the `{…}`
3476            // line above it, leaving a plain blank line where the letter was.
3477            // A paragraph with no text is no block, so the markup would stand
3478            // around nothing, the map would give it no caret home, and the
3479            // next key would take the tag apart.
3480            if self.view != View::Source
3481                && let Some(block) = self.attributed_block_of_content(prev..end)
3482            {
3483                self.splice(block.start, block.end, "", EditKind::Delete);
3484                return;
3485            }
3486            if prev < end {
3487                self.splice(prev, end, "", EditKind::Delete);
3488            }
3489        }
3490    }
3491
3492    /// Remove the blank line the caret is on — its own newline and the one
3493    /// that ended the line before it — and put the caret on `stop`, the caret
3494    /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3495    /// blank line that hidden markup separates from the block above: the
3496    /// navigable blank row after a `</div>` is always one of at least three
3497    /// newlines under the tag (the drawn separators either side of it), so
3498    /// taking two leaves the blank line the tag needs under it.
3499    fn delete_blank_line_to(&mut self, stop: usize) {
3500        let caret = self.caret;
3501        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3502        let line_end = self.source[caret..]
3503            .find('\n')
3504            .map_or(self.source.len(), |i| caret + i);
3505        let from = line_start.saturating_sub(1).max(stop);
3506        let to = (line_end + 1).min(self.source.len());
3507        self.splice(from, to, "", EditKind::Delete);
3508        self.caret = stop;
3509        self.anchor = None;
3510        self.goal_col = None;
3511        self.record_caret();
3512    }
3513
3514    /// Move the caret to the stop before it and consume the key — what
3515    /// Backspace does where the byte behind the caret is hidden markup it
3516    /// has no structural answer for, rather than take that markup apart.
3517    fn step_back_to_stop(&mut self) {
3518        // The map answers about offsets, so it has to be this revision's — see
3519        // `open_paragraph_at_block_edge`.
3520        self.rebuild_map();
3521        if let Some(off) = self
3522            .vmap
3523            .stop_before(self.caret)
3524            .filter(|&o| o >= self.caret_floor())
3525        {
3526            self.caret = off;
3527            self.anchor = None;
3528            self.goal_col = None;
3529        }
3530    }
3531
3532    /// Backspace's presentation behaviour: with the caret exactly at the start
3533    /// of a block's content, and that block's attributes spelled as hidden
3534    /// markup before it, strip the attributes. The peer of
3535    /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3536    /// reasoning: the `{.center}` line above a djot block and the
3537    /// `<div class="center">` around a Markdown one are what the byte behind
3538    /// the caret belongs to, and the rich view draws neither. The ordinary
3539    /// delete took the newline out of `{.center}\nhello` and left
3540    /// `{.center}hello` — the attribute line fused onto the text as prose —
3541    /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3542    ///
3543    /// The whole attribute set goes, the way the whole `#` marker does — the
3544    /// press is over the line that spells it, not over one key of it — and
3545    /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3546    /// djot line and unwraps the Markdown div. Where the block is the first of
3547    /// several in a div, twig has no sole child to unwrap and answers with a
3548    /// no-op, so the caret steps back to the stop before instead, as it does
3549    /// at a table's end. A later child of the div has an ordinary paragraph
3550    /// above it and is not this rule's.
3551    ///
3552    /// Returns whether it acted; `false` leaves Backspace its character delete.
3553    fn backspace_attributed_block_start(&mut self) -> bool {
3554        if !matches!(self.format, Format::Markdown | Format::Djot) {
3555            return false;
3556        }
3557        let caret = self.caret;
3558        let nodes = self.nodes();
3559        let Some(block) = nodes
3560            .iter()
3561            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3562            .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3563        else {
3564            return false;
3565        };
3566        match self.format {
3567            Format::Djot => {
3568                // twig records where the `{…}` block was written, so this is
3569                // the parser's own answer and not a scan for a `{` above the
3570                // block; `None` (a synthesized or merged set) is not a line
3571                // the caret is standing after.
3572                let spelled = self
3573                    .editor
3574                    .document()
3575                    .ok()
3576                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3577                    .is_some_and(|s| s.end <= caret);
3578                if !spelled {
3579                    return false;
3580                }
3581            }
3582            _ => {
3583                let Some(div) = block
3584                    .parent
3585                    .and_then(|p| nodes.iter().find(|n| n.id == p))
3586                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3587                else {
3588                    return false;
3589                };
3590                let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3591                if kids.clone().any(|k| k.span.start < block.span.start) {
3592                    return false;
3593                }
3594                if kids.nth(1).is_some() {
3595                    self.step_back_to_stop();
3596                    return true;
3597                }
3598            }
3599        }
3600        self.write_block_attrs("block attributes", Vec::new());
3601        true
3602    }
3603
3604    /// Backspace at the start of a block's content: join the block into the
3605    /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3606    /// the split Enter makes, spelled the format's way. Two paragraphs join
3607    /// on a soft break; a paragraph under a marker heading joins onto the
3608    /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3609    /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3610    /// taken as one; a quote's or an item's continuation prefix is written.
3611    /// The joined text takes the block above's presentation and containers,
3612    /// which is the rule every editor with a centred paragraph follows.
3613    ///
3614    /// Leaf used to join by deleting the one newline behind the caret, which
3615    /// is the right bytes for two Markdown paragraphs and nothing else: under
3616    /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3617    /// after a div it took the newline under the hidden `</div>`, which drew
3618    /// nothing different and took the tag apart on the next press. What a
3619    /// join is in each format is twig's to know, and now it does.
3620    ///
3621    /// Where twig refuses — the block above is a code block, a table or a
3622    /// rule with no text to join into, or the caret's block would have to
3623    /// leave a div that holds more after it — the caret steps back to the
3624    /// stop before instead, as it does at a table's end: the key moves the
3625    /// caret and takes no markup apart. Where nothing precedes the block, or
3626    /// the format cannot join at all, Backspace keeps its character delete.
3627    ///
3628    /// Returns whether it acted.
3629    fn backspace_joins_block(&mut self) -> bool {
3630        let caret = self.caret;
3631        if caret <= self.caret_floor() {
3632            return false;
3633        }
3634        let Some(text) = self.text_block_opening_at(caret) else {
3635            return false;
3636        };
3637        match self.join_blocks(caret) {
3638            Ok(change) => {
3639                // The caret keeps its place at the start of the text it stood
3640                // on, wherever the join put that text — after a soft break,
3641                // a space, or a quote's prefix. Found by the bytes, as
3642                // `block_content_in` finds a re-spelled block.
3643                let region = &self.source[change.new.clone()];
3644                let at = region
3645                    .find(&text)
3646                    .map_or(change.new.start, |i| change.new.start + i);
3647                self.land_after_join(at);
3648                true
3649            }
3650            Err(twig::Error::NotEditable) => {
3651                self.step_back_to_stop();
3652                true
3653            }
3654            Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3655            Err(e) => {
3656                self.status = Some(format!("join: {e}"));
3657                true
3658            }
3659        }
3660    }
3661
3662    /// Delete at the end of a block's content: join the block after it into
3663    /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3664    /// mirror, and the same twig gesture aimed at the next block. The caret
3665    /// stays where it was, which is where the joined text now begins after
3666    /// the separator. Where twig refuses, the caret steps forward to the next
3667    /// stop instead; where no block follows, Delete keeps its character
3668    /// delete.
3669    fn delete_forward_joins_block(&mut self) -> bool {
3670        let caret = self.caret;
3671        let at_end = self
3672            .nodes()
3673            .iter()
3674            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3675            .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3676        if !at_end {
3677            return false;
3678        }
3679        // The next stop, across a line end, in a block: what Delete at a
3680        // block's end points at. On the same line it is a hidden delimiter's
3681        // far side, which the ordinary delete handles; on a blank line it is
3682        // the empty paragraph the byte delete has always closed.
3683        self.rebuild_map();
3684        let Some(stop) = self.vmap.stop_after(caret) else {
3685            return false;
3686        };
3687        if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3688            return false;
3689        }
3690        match self.join_blocks(stop) {
3691            Ok(change) => {
3692                self.land_after_join(change.old.start);
3693                true
3694            }
3695            Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3696                self.caret = stop;
3697                self.anchor = None;
3698                self.goal_col = None;
3699                true
3700            }
3701            Err(twig::Error::UnsupportedFormat) => false,
3702            Err(e) => {
3703                self.status = Some(format!("join: {e}"));
3704                true
3705            }
3706        }
3707    }
3708
3709    /// The content bytes of the paragraph or heading whose content opens
3710    /// exactly at `off` — the block a Backspace there is at the start of.
3711    fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3712        self.nodes()
3713            .into_iter()
3714            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3715            .find_map(|n| {
3716                let c = n.content_span?;
3717                (c.start == off).then(|| self.source[c].to_string())
3718            })
3719    }
3720
3721    /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3722    /// plumbing every structural gesture has; the caret is the caller's to
3723    /// place from the change, via [`land_after_join`](Self::land_after_join).
3724    fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3725        if self.read_only {
3726            return Err(twig::Error::NotEditable);
3727        }
3728        self.record_caret();
3729        let change = self.editor.join_blocks(offset)?;
3730        self.last_edit_kind = None; // structural edit is its own undo step
3731        self.refresh();
3732        Ok(change)
3733    }
3734
3735    /// Finish a join: the caret at `at`, no selection, the map this
3736    /// revision's before the clamp — see `write_block_attrs` for why.
3737    fn land_after_join(&mut self, at: usize) {
3738        self.caret = at;
3739        self.anchor = None;
3740        self.goal_col = None;
3741        self.dirty = self.source != self.clean_source;
3742        self.status = None;
3743        self.rebuild_map();
3744        self.clamp_caret();
3745        self.record_caret();
3746    }
3747
3748    // ── moving a block ─────────────────────────────────────────────────────────
3749
3750    /// The block a move picks up at `offset` — the same one
3751    /// [`select_block_at`](Self::select_block_at) selects (the deepest node
3752    /// that is neither inline nor a multi-block container), widened to the
3753    /// list item when it is the item's first block, because that is what
3754    /// twig's `move_block` moves: a bullet's text is the bullet, and dragging
3755    /// it takes the item and everything under it. A block later in an item's
3756    /// tail moves alone. `None` on a blank line, and past the source.
3757    fn movable_block_at(&mut self, offset: usize) -> Option<FlatNode> {
3758        let off = offset.min(self.source.len());
3759        let chain = self.editor.ancestors_at(off).ok()?;
3760        let block = chain
3761            .iter()
3762            .rev()
3763            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
3764        let nodes = self.nodes();
3765        let block = nodes.get(block.node_id as usize)?.clone();
3766        let item = block
3767            .parent
3768            .and_then(|p| nodes.get(p.0 as usize))
3769            .filter(|p| matches!(p.kind, Kind::ListItem | Kind::TaskListItem))
3770            .filter(|p| p.first_child == Some(block.id));
3771        Some(item.cloned().unwrap_or(block))
3772    }
3773
3774    /// The source range of the block a move would pick up at `offset` — for
3775    /// the outline of the block being carried, without moving the caret. The
3776    /// range [`select_block_at`](Self::select_block_at) would select, except
3777    /// that it is the whole item for a bullet's text, as
3778    /// [`move_block`](Self::move_block) is.
3779    pub fn block_range_at(&mut self, offset: usize) -> Option<Range<usize>> {
3780        self.movable_block_at(offset).map(|b| b.span)
3781    }
3782
3783    /// Move the block at `from` to the boundary `to` — twig's `move_block`,
3784    /// with the undo plumbing every structural gesture has and the caret
3785    /// riding the block to its new place. `from` is any offset inside the
3786    /// block (the one [`block_range_at`](Self::block_range_at) finds); `to`
3787    /// is a position *between* blocks — a block's first byte lands before it,
3788    /// its last after it, a blank line is itself a boundary, and the source's
3789    /// length is the document's end. The block takes the line prefixes of
3790    /// the container the boundary is inside — a `> ` on the way into a quote,
3791    /// none on the way out — and the blank lines a person would have typed
3792    /// are written and removed; twig spells all of that.
3793    ///
3794    /// A move that would move nothing — `to` inside the block, or on the
3795    /// boundary it already sits on — is a quiet no-op rather than an error,
3796    /// since it is what a block dropped back where it was asks for. Any other
3797    /// refusal reaches the status line: a `to` interior to a fence or a table,
3798    /// or a format with no blocks a caret could name.
3799    ///
3800    /// In WYSIWYG the frontmatter is hidden, and a boundary above it is not
3801    /// one a person can see: `to` is raised to the first rendered offset.
3802    ///
3803    /// `true` when the document changed. A move twig accepts that rewrites
3804    /// nothing — a one-item list sent past a paragraph is still a one-item
3805    /// list past that paragraph — is taken back rather than left as an undo
3806    /// step with no difference in it, and is `false` too.
3807    pub fn move_block(&mut self, from: usize, to: usize) -> bool {
3808        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3809            return false;
3810        }
3811        let len = self.source.len();
3812        let from = from.min(len);
3813        let to = to.clamp(self.caret_floor().min(len), len);
3814        let Some(block) = self.movable_block_at(from) else {
3815            self.status = Some("move block: no block here".into());
3816            return false;
3817        };
3818        // Where the caret stands in the block, as (line within the block,
3819        // bytes back from that line's end) — the shape that survives a
3820        // prefix being added or stripped on the way through a container.
3821        let caret = self.caret.clamp(block.span.start, block.span.end);
3822        let block_line = self.source[block.span.start..caret].matches('\n').count();
3823        let line_end = self.source[caret..block.span.end]
3824            .find('\n')
3825            .map_or(block.span.end, |i| caret + i);
3826        let tail = line_end - caret;
3827        let block_lines = self.source[block.span.start..block.span.end]
3828            .matches('\n')
3829            .count()
3830            + 1;
3831        let upward = to <= block.span.start;
3832        self.record_caret();
3833        match self.editor.move_block(from, to) {
3834            Ok(_) if self.editor.source_str().ok().as_deref() == Some(self.source.as_str()) => {
3835                let _ = self.editor.undo();
3836                self.status = None;
3837                false
3838            }
3839            Ok(change) => {
3840                self.last_edit_kind = None; // structural edit is its own undo step
3841                self.refresh();
3842                let at = self.moved_block_caret(&change.new, upward, block_lines, block_line, tail);
3843                self.caret = at;
3844                self.anchor = None;
3845                self.goal_col = None;
3846                self.dirty = self.source != self.clean_source;
3847                self.status = None;
3848                self.rebuild_map();
3849                self.clamp_caret();
3850                self.record_caret();
3851                true
3852            }
3853            // Nothing to move: the block dropped back onto its own boundary.
3854            Err(twig::Error::InvalidArgument) => {
3855                self.status = None;
3856                false
3857            }
3858            Err(e) => {
3859                self.status = Some(format!("move block: {e}"));
3860                false
3861            }
3862        }
3863    }
3864
3865    /// The caret's place in the rewritten region after a move: the moved
3866    /// block's lines open the region when it went up (below the blank line it
3867    /// was dropped on, when it was) and close it (above the separator twig
3868    /// wrote after it) when it went down, and within them the
3869    /// caret keeps its line and its distance from that line's end. Clamped
3870    /// into the region rather than trusted, since a block can lose a line on
3871    /// the way — a quote it was the only content of goes with it.
3872    fn moved_block_caret(
3873        &self,
3874        new: &Range<usize>,
3875        upward: bool,
3876        block_lines: usize,
3877        block_line: usize,
3878        tail: usize,
3879    ) -> usize {
3880        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3881        let mut lines: Vec<(usize, usize)> = Vec::new();
3882        let mut start = 0;
3883        loop {
3884            match region[start..].find('\n') {
3885                Some(i) => {
3886                    lines.push((start, start + i));
3887                    start += i + 1;
3888                }
3889                None => {
3890                    lines.push((start, region.len()));
3891                    break;
3892                }
3893            }
3894        }
3895        // A separator line is blank, or a quote's bare `>`; the region can
3896        // open with one (the blank line the block was dropped on) or close
3897        // with one (the one twig wrote after it).
3898        let separator = |&(s, e): &(usize, usize)| {
3899            region[s..e]
3900                .trim()
3901                .trim_start_matches('>')
3902                .trim()
3903                .is_empty()
3904        };
3905        let first = if upward {
3906            lines.iter().position(|l| !separator(l)).unwrap_or(0)
3907        } else {
3908            let filled = lines
3909                .iter()
3910                .rposition(|l| !separator(l))
3911                .map_or(0, |i| i + 1);
3912            filled.saturating_sub(block_lines)
3913        };
3914        let (s, e) = lines[(first + block_line).min(lines.len() - 1)];
3915        new.start + e.saturating_sub(tail).max(s)
3916    }
3917
3918    /// Move the caret's block one place up — Alt+↑: above the block before it,
3919    /// and out of its container, to just above it, when it is the first block
3920    /// there. Nothing above the document's first block, and nothing to do for
3921    /// a caret on a blank line; both say so in the status line.
3922    pub fn move_block_up(&mut self) {
3923        self.move_block_by(true);
3924    }
3925
3926    /// Move the caret's block one place down — Alt+↓, the mirror of
3927    /// [`move_block_up`](Self::move_block_up): below the block after it, and
3928    /// out of its container when it is the last block there.
3929    pub fn move_block_down(&mut self) {
3930        self.move_block_by(false);
3931    }
3932
3933    fn move_block_by(&mut self, up: bool) {
3934        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3935            return;
3936        }
3937        let Some(from) = self.block_offset_for_caret() else {
3938            self.status = Some("move block: no block here".into());
3939            return;
3940        };
3941        let Some(block) = self.movable_block_at(from) else {
3942            self.status = Some("move block: no block here".into());
3943            return;
3944        };
3945        let nodes = self.nodes();
3946        let to = if up {
3947            self.boundary_above(&nodes, &block)
3948        } else {
3949            self.boundary_below(&nodes, &block)
3950        };
3951        if to.is_none_or(|to| !self.move_block(from, to)) && self.status.is_none() {
3952            self.status = Some(if up {
3953                "move block: nothing above".into()
3954            } else {
3955                "move block: nothing below".into()
3956            });
3957        }
3958    }
3959
3960    /// The boundary one step above `block`: before its previous sibling, or
3961    /// — for the first block in a container — before the container itself.
3962    /// `None` for the document's first block.
3963    fn boundary_above(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3964        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3965        let previous = nodes
3966            .iter()
3967            .filter(|n| n.parent == Some(parent.id))
3968            .take_while(|n| n.id != block.id)
3969            .last();
3970        match previous {
3971            Some(p) => self.before(p),
3972            None if parent.kind == Kind::Doc => None,
3973            // An item leaving a nested list upward goes before the item
3974            // holding that list, as an item of the outer one; the list's own
3975            // first byte is inside the holding item's tail.
3976            None if matches!(
3977                parent.kind,
3978                Kind::BulletList | Kind::OrderedList | Kind::TaskList
3979            ) =>
3980            {
3981                match parent.parent.and_then(|p| nodes.get(p.0 as usize)) {
3982                    Some(item) if matches!(item.kind, Kind::ListItem | Kind::TaskListItem) => {
3983                        self.before(item)
3984                    }
3985                    _ => self.before(parent),
3986                }
3987            }
3988            None => self.before(parent),
3989        }
3990    }
3991
3992    /// The boundary one step below `block`: after its next sibling, or — for
3993    /// the last block in a container — just past the container, at its
3994    /// parent's level ([`exit_below`](Self::exit_below)). `None` for the
3995    /// document's last block.
3996    fn boundary_below(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3997        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3998        if let Some(n) = block.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
3999            return Some(self.after(n));
4000        }
4001        match parent.kind {
4002            Kind::Doc => None,
4003            _ => self.exit_below(nodes, parent),
4004        }
4005    }
4006
4007    /// The boundary just past `container` at its parent's level: before its
4008    /// next sibling, or past its parent when it is the last thing there —
4009    /// the document's end at the top. Leaving a list item is the exception
4010    /// that keeps a block in the list: the next item's tail, which is what
4011    /// [`after`](Self::after) an item names.
4012    fn exit_below(&self, nodes: &[FlatNode], container: &FlatNode) -> Option<usize> {
4013        let item = matches!(container.kind, Kind::ListItem | Kind::TaskListItem);
4014        match container.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
4015            Some(n) if item => Some(self.after(n)),
4016            Some(n) => self.before(n),
4017            None => {
4018                let parent = container.parent.and_then(|p| nodes.get(p.0 as usize))?;
4019                match parent.kind {
4020                    Kind::Doc => Some(self.source.len()),
4021                    _ => self.exit_below(nodes, parent),
4022                }
4023            }
4024        }
4025    }
4026
4027    /// The boundary before `node`: its first byte. twig reads a container's
4028    /// opening as before it — a quote's marker, a fence's first byte — so the
4029    /// span's start is the boundary at the parent's level for every kind.
4030    fn before(&self, node: &FlatNode) -> Option<usize> {
4031        Some(node.span.start)
4032    }
4033
4034    /// The boundary after `node`: its own end, which for a container proper
4035    /// (a quote, a list, a fenced or tagged container) is the end of its last
4036    /// line — after its last block, still inside it, where a block arriving
4037    /// from outside joins it. Past the container is the next line's start: a
4038    /// blank line, the next block, or the document's end.
4039    fn after(&self, node: &FlatNode) -> usize {
4040        let container = is_block_container(&node.kind)
4041            && !matches!(node.kind, Kind::ListItem | Kind::TaskListItem);
4042        let end = node.span.end.min(self.source.len());
4043        if container && self.source.as_bytes().get(end) == Some(&b'\n') {
4044            end + 1
4045        } else {
4046            end
4047        }
4048    }
4049
4050    /// Where a block dragged over rendered row `row` would land — the
4051    /// boundary before the row's block when the row is in its upper half, the
4052    /// boundary after it otherwise, and the document's end for a row below
4053    /// everything. `None` for a row that holds no block (a decoration row is
4054    /// resolved to the block under it, so this is a table's bottom rule with
4055    /// nothing after it, or a map with no rows). The frontend draws its
4056    /// indicator above [`DropTarget::row`] and hands
4057    /// [`DropTarget::offset`] to [`move_block`](Self::move_block).
4058    ///
4059    /// The block is the deepest one, not the widened item: a drop on a
4060    /// bullet's text lands before or after that bullet, and its nested
4061    /// children — rows of their own — answer for themselves.
4062    pub fn drop_target_at(&mut self, row: usize) -> Option<DropTarget> {
4063        let rows = self.vmap.rows.len();
4064        if row >= rows {
4065            return Some(DropTarget {
4066                offset: self.source.len(),
4067                row: rows,
4068            });
4069        }
4070        // A gap or a rule is not a block; the block under it is the one meant.
4071        let row = (row..rows).find(|&r| self.vmap.row_is_navigable(r))?;
4072        let start = self.vmap.row_start(row)?;
4073        let chain = self.editor.ancestors_at(start).ok()?;
4074        let block = chain
4075            .iter()
4076            .rev()
4077            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
4078        let span = block.span.clone();
4079        let (first, last) = self.vmap.row_range_for(span.clone());
4080        if row <= usize::midpoint(first, last) {
4081            Some(DropTarget {
4082                offset: span.start,
4083                row: first,
4084            })
4085        } else {
4086            Some(DropTarget {
4087                offset: span.end.min(self.source.len()),
4088                row: last + 1,
4089            })
4090        }
4091    }
4092
4093    /// Backspace at a table's trailing stop: move onto the stop before it (the
4094    /// last cell's end) and consume the key. `false` anywhere else. See
4095    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
4096    /// not one to delete.
4097    fn backspace_at_table_end(&mut self) -> bool {
4098        // The map answers about offsets, so it has to be this revision's — see
4099        // `open_paragraph_at_block_edge`.
4100        self.rebuild_map();
4101        if !self.vmap.table_end_stop(self.caret) {
4102            return false;
4103        }
4104        if let Some(off) = self
4105            .vmap
4106            .stop_before(self.caret)
4107            .filter(|&o| o >= self.caret_floor())
4108        {
4109            self.caret = off;
4110            self.anchor = None;
4111            self.goal_col = None;
4112        }
4113        true
4114    }
4115
4116    /// Whether the caret stands at a table cell's wall on the `edge` side — at
4117    /// or before its first caret stop for Backspace, at or past its last for
4118    /// Delete — where the only bytes between it and the neighbouring cell are
4119    /// the padding and the `|` the rich view draws as the grid. The padding
4120    /// counts as the cell's: a caret placed between `| ` and the text is at the
4121    /// same wall, so the first press cannot eat the space either. An in-cell
4122    /// `<br>` at the edge is not a wall; the delete takes it whole, as ever.
4123    fn at_cell_wall(&mut self, edge: BreakEdge) -> bool {
4124        // The map answers about offsets, so it has to be this revision's.
4125        self.rebuild_map();
4126        if self.cell_break_at(edge).is_some() {
4127            return false;
4128        }
4129        let caret = self.caret;
4130        let src = self.source.as_bytes();
4131        let pad = |b: &u8| *b == b' ' || *b == b'\t';
4132        self.vmap
4133            .tables
4134            .iter()
4135            .flat_map(|t| &t.grid)
4136            .flat_map(|row| &row.cells)
4137            .any(|cell| {
4138                let lo = cell.start
4139                    - src[..cell.start]
4140                        .iter()
4141                        .rev()
4142                        .take_while(|b| pad(b))
4143                        .count();
4144                let hi = cell.end + src[cell.end..].iter().take_while(|b| pad(b)).count();
4145                (lo..=hi).contains(&caret)
4146                    && match edge {
4147                        BreakEdge::Backward => {
4148                            self.vmap.stop_before(caret).is_none_or(|s| s < cell.start)
4149                        }
4150                        BreakEdge::Forward => {
4151                            self.vmap.stop_after(caret).is_none_or(|s| s > cell.end)
4152                        }
4153                    }
4154            })
4155    }
4156
4157    /// Whether the caret's own source line holds nothing but whitespace — an
4158    /// empty paragraph, or the blank line a block boundary is spelled with. The
4159    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
4160    /// no text of its own, so the newline before the caret belongs to the gap
4161    /// between blocks rather than to any word the caret is editing.
4162    fn caret_on_blank_line(&self) -> bool {
4163        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
4164        let line_end = self.source[self.caret..]
4165            .find('\n')
4166            .map_or(self.source.len(), |i| self.caret + i);
4167        self.source[line_start..line_end].trim().is_empty()
4168    }
4169
4170    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
4171    /// `edge` side — the byte range to delete whole. A table row is one source
4172    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
4173    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
4174    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
4175    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
4176    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
4177    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
4178    /// apart — no ancestor walk needed. Rich view only; source view shows the
4179    /// literal tag and deletes it a byte at a time.
4180    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
4181        let caret = self.caret;
4182        let nodes = self.nodes();
4183        let src = self.source.as_bytes();
4184        nodes
4185            .iter()
4186            .find(|n| {
4187                n.kind == Kind::HardBreak
4188                    && n.span.start < n.span.end
4189                    && src.get(n.span.start) == Some(&b'<')
4190                    && match edge {
4191                        BreakEdge::Backward => n.span.end == caret,
4192                        BreakEdge::Forward => n.span.start == caret,
4193                    }
4194            })
4195            .map(|n| (n.span.start, n.span.end))
4196    }
4197
4198    /// Whether the source byte at `off` is a backslash twig consumed as an escape
4199    /// (hidden in the rich view), as against a literal backslash (drawn). A
4200    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
4201    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
4202    /// round-trip needed.
4203    fn is_hidden_escape(&self, off: usize) -> bool {
4204        let b = self.source.as_bytes();
4205        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
4206    }
4207
4208    /// Backspace's list behaviour: when the caret sits exactly at the start of a
4209    /// list item's content (right after its marker), outdent the item if it's
4210    /// nested, else strip the marker so it becomes a paragraph. Returns whether
4211    /// it acted — `false` leaves Backspace its ordinary character delete.
4212    fn backspace_list_start(&mut self) -> bool {
4213        let Some(marker) = self.list_marker_on_line(self.caret) else {
4214            return false;
4215        };
4216        // Only right after the marker. That the line opens a real item is
4217        // already settled: `list_marker_on_line` answers from the tree.
4218        if self.caret != marker.content_start() {
4219            return false;
4220        }
4221        if self.item_is_nested(marker.marker_start) {
4222            // Nested: give back one level, keeping the marker and carrying the
4223            // caret with it.
4224            self.outdent();
4225        } else {
4226            // Top level: drop the marker, leaving a paragraph, then renumber the
4227            // siblings the removed item was counted among. Only the marker goes —
4228            // a quote prefix in front of it still has a quote to hold up.
4229            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
4230            self.renumber_here();
4231        }
4232        true
4233    }
4234
4235    /// Backspace's heading behaviour: with the caret exactly at the start of an
4236    /// ATX heading's content — right after the `#` marker the rich view hides —
4237    /// strip the marker so the line becomes a paragraph. The peer of
4238    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
4239    /// reasoning: hidden block markup is structure, so the keystroke over it is
4240    /// structural.
4241    ///
4242    /// Without this the ordinary delete takes the space out of `# Title` and
4243    /// leaves `#Title`, which is no longer a heading at all — the hash the view
4244    /// had been hiding surfaces as literal text the user has to delete a second
4245    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
4246    /// other end) goes with the marker for the same reason.
4247    ///
4248    /// Returns whether it acted; `false` leaves Backspace its character delete.
4249    fn backspace_heading_start(&mut self) -> bool {
4250        let caret = self.caret;
4251        // The heading whose content opens exactly at the caret. A bare `#` has no
4252        // content span at all — its content starts (and ends) where the line does.
4253        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
4254            let (start, end) = match &n.content_span {
4255                Some(c) => (c.start, c.end),
4256                None => (n.span.end, n.span.end),
4257            };
4258            (n.kind == Kind::Heading && start == caret)
4259                .then(|| (n.span.clone(), end, n.marker_span.clone()))
4260        }) else {
4261            return false;
4262        };
4263        // twig reports the marker's own extent, so there is nothing to walk back
4264        // over and no `#` in this file. A setext heading has no marker — its
4265        // content opens the line — so it falls through to the ordinary delete,
4266        // as does anything else sitting at a content start.
4267        // `m.end == caret` is what excludes a setext heading, whose marker is the
4268        // underline *after* the content rather than a prefix before it.
4269        let Some(marker) = marker.filter(|m| m.end == caret) else {
4270            return false;
4271        };
4272        let start = marker.start;
4273        // A closing `#` sequence is hidden too, so it can't be left behind. Only
4274        // when the tail really is one: trailing spaces alone are nothing to strip.
4275        let tail = &self.source[content_end..span.end];
4276        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
4277            let kept = self.source[caret..content_end].to_string();
4278            self.splice(start, span.end, &kept, EditKind::Other);
4279            // The splice leaves the caret past the text it re-wrote; the caret
4280            // belongs where the content now starts, which is where it already was.
4281            self.caret = start;
4282            self.record_caret();
4283        } else {
4284            self.splice(start, caret, "", EditKind::Other);
4285        }
4286        true
4287    }
4288
4289    pub fn delete_forward(&mut self) {
4290        if let Some((s, e)) = self.selection() {
4291            self.splice(s, e, "", EditKind::Other);
4292        } else if self.caret < self.source.len() {
4293            // The mirror of Backspace's: forward-delete in front of a picture
4294            // would eat the `!` off its markup and leave a link where a photo was.
4295            if self.view != View::Source && self.delete_around_block_media(true) {
4296                return;
4297            }
4298            // And of Backspace's cell wall: Delete at a cell's end would take
4299            // the padding and the `|` after it.
4300            if self.view != View::Source && self.at_cell_wall(BreakEdge::Forward) {
4301                return;
4302            }
4303            // And of Backspace's join: at the end of a block's content, Delete
4304            // joins the next block into this one.
4305            if self.view != View::Source && self.delete_forward_joins_block() {
4306                return;
4307            }
4308            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
4309            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
4310            // strands a broken `<br` in the cell.
4311            if self.view != View::Source
4312                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
4313            {
4314                self.splice(start, end, "", EditKind::Delete);
4315                return;
4316            }
4317            // The mirror of Backspace's two steps: from in front of a run's
4318            // opening `**` step into it, onto the first letter of its text, and
4319            // at the end of a run's text step out past its closing `**` to the
4320            // character beyond. Either way Delete takes the character it looks
4321            // like it is pointing at, and never a delimiter drawn as nothing.
4322            // The caret then settles back inside the run it was standing in —
4323            // see `settle_inside_close_delims`.
4324            let from = if self.view == View::Source {
4325                self.caret
4326            } else {
4327                let inside = self.step_inside_open_delims(self.caret);
4328                // The mirror of Backspace's empty-span rule: an attributed
4329                // span with no text goes whole, with the character after it.
4330                if let Some(span) = self.run_span_of_content(inside..inside) {
4331                    let to = if self.source[span.end..].starts_with('\n') {
4332                        span.end
4333                    } else {
4334                        next_boundary(&self.source, span.end)
4335                    };
4336                    self.splice(span.start, to, "", EditKind::Delete);
4337                    return;
4338                }
4339                self.skip_trailing_close_delims(inside)
4340            };
4341            let next = next_boundary(&self.source, from);
4342            // And of its emptying rules: the span goes with its last letter,
4343            // and so does the block's div or `{…}` line.
4344            if self.view != View::Source
4345                && let Some(span) = self.run_span_of_content(from..next)
4346            {
4347                self.splice(span.start, span.end, "", EditKind::Delete);
4348                return;
4349            }
4350            if self.view != View::Source
4351                && let Some(block) = self.attributed_block_of_content(from..next)
4352            {
4353                self.splice(block.start, block.end, "", EditKind::Delete);
4354                return;
4355            }
4356            if from < next {
4357                self.splice(from, next, "", EditKind::Delete);
4358            }
4359        }
4360    }
4361
4362    /// Delete from the caret back to the start of the previous word (⌥⌫ /
4363    /// Ctrl+⌫). Deletes the selection instead when one is active.
4364    pub fn delete_word_back(&mut self) {
4365        if let Some((s, e)) = self.selection() {
4366            self.splice(s, e, "", EditKind::Other);
4367        } else {
4368            // A word back from just past a picture is a word *of its markup*, and
4369            // a word back from in front of one runs through the paragraph break
4370            // into the prose above — dissolving the picture either way. See
4371            // `delete_around_block_media`.
4372            if self.view != View::Source && self.delete_around_block_media(false) {
4373                return;
4374            }
4375            let start = self.word_left_from(self.caret).max(self.caret_floor());
4376            if start < self.caret {
4377                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4378                self.splice(s, e, "", EditKind::Delete);
4379            }
4380        }
4381    }
4382
4383    /// Delete from the caret forward to the end of the next word (⌥⌦ /
4384    /// Ctrl+Del). Deletes the selection instead when one is active.
4385    pub fn delete_word_forward(&mut self) {
4386        if let Some((s, e)) = self.selection() {
4387            self.splice(s, e, "", EditKind::Other);
4388        } else {
4389            // The mirror: a word forward from in front of a picture is its markup.
4390            if self.view != View::Source && self.delete_around_block_media(true) {
4391                return;
4392            }
4393            let end = self.word_right_from(self.caret);
4394            if end > self.caret {
4395                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4396                self.splice(s, e, "", EditKind::Delete);
4397            }
4398        }
4399    }
4400
4401    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
4402    /// selection instead when one is active, as every other delete here does.
4403    ///
4404    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
4405    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
4406    /// stops at the first character and this takes the indentation with it, the
4407    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
4408    /// leave an indent behind that nothing can then ask to delete, where a caret
4409    /// left at column 0 is one press of Home away from either.
4410    pub fn delete_to_line_start(&mut self) {
4411        if let Some((s, e)) = self.selection() {
4412            self.splice(s, e, "", EditKind::Other);
4413            return;
4414        }
4415        // Never back across the floor: hidden frontmatter isn't on this line, or
4416        // on any line the WYSIWYG caret can see.
4417        let (start, _) = self.line_span();
4418        let start = start.max(self.caret_floor());
4419        if start < self.caret {
4420            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4421            self.splice(s, e, "", EditKind::Delete);
4422        }
4423    }
4424
4425    /// Kill from the caret to the end of its line (^K). Deletes the selection
4426    /// instead when one is active.
4427    ///
4428    /// At the end of the line it does nothing, rather than pulling the line
4429    /// below up into this one. Joining has no meaning to give it in both views
4430    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
4431    /// there is nothing there to delete, while the newline a *source* line ends
4432    /// with is only half of the blank line that separates two paragraphs —
4433    /// deleting one leaves a soft break, which is not the join it looks like.
4434    /// The views agreeing is worth more than emacs' second press, and Delete is
4435    /// already the key that joins.
4436    pub fn delete_to_line_end(&mut self) {
4437        if let Some((s, e)) = self.selection() {
4438            self.splice(s, e, "", EditKind::Other);
4439            return;
4440        }
4441        let (_, end) = self.line_span();
4442        if end > self.caret {
4443            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4444            self.splice(s, e, "", EditKind::Delete);
4445        }
4446    }
4447
4448    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
4449    ///
4450    /// A glyph-space range covers what the user can see, which for `**bold**` is
4451    /// the word and never the delimiters around it — so deleting the word on its
4452    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
4453    /// word, and the styling was the word's; the two go together. Only the
4454    /// node's delimiters are taken, and those are hidden here anyway, so nothing
4455    /// visible outside the range is lost.
4456    ///
4457    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
4458    /// the strong, and only then is the strong empty too.
4459    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
4460        if self.view == View::Source {
4461            return (start, end);
4462        }
4463        let nodes = self.nodes();
4464        let (mut s, mut e) = (start, end);
4465        loop {
4466            let mut grew = false;
4467            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
4468                let Some(text) = inline_content_span(n, &self.source) else {
4469                    continue;
4470                };
4471                // Some of its text survives, so the node still has a job.
4472                if text.start < s || text.end > e {
4473                    continue;
4474                }
4475                if n.span.start < s || n.span.end > e {
4476                    s = s.min(n.span.start);
4477                    e = e.max(n.span.end);
4478                    grew = true;
4479                }
4480            }
4481            if !grew {
4482                return (s, e);
4483            }
4484        }
4485    }
4486
4487    /// One splice of document text, keeping the **mark-edge rule**: an inline
4488    /// mark's content never begins or ends with whitespace. In Markdown and Djot
4489    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
4490    /// is four literal asterisks around a word, and a rich view drawing the
4491    /// document faithfully has no choice but to show them. That is correct
4492    /// rendering of what the file says, and nobody typing a space after a bold
4493    /// word meant to say it.
4494    ///
4495    /// So the space goes *outside* the run instead — `**bold** ` — which is the
4496    /// same document to a reader and a live one to a parser. The caret follows it
4497    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
4498    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
4499    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
4500    ///
4501    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
4502    /// through here, so the rule holds however the whitespace arrives at the
4503    /// edge. The repair is decided *after* the plain edit, by asking whether the
4504    /// mark actually died: a code span's backticks aren't whitespace-sensitive
4505    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
4506    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4507        let fix = self.mark_edge_fix(start, end, text);
4508        if !self.splice_exact(start, end, text, kind) {
4509            return false;
4510        }
4511        if let Some(fix) = fix {
4512            self.repair_mark_edges(fix);
4513        }
4514        if text.is_empty() && end > start {
4515            self.settle_inside_close_delims();
4516        }
4517        true
4518    }
4519
4520    /// After a delete, take a caret left standing past a run's closing delimiters
4521    /// back inside the run.
4522    ///
4523    /// A delete leaves the caret where the deleted bytes began, and when those
4524    /// bytes were the last thing after a marked phrase — the space the mark-edge
4525    /// rule pushed out of `**bold** `, say — that spot is the far side of the
4526    /// closing `**`. The rich view has nothing to draw there: the delimiters are
4527    /// hidden, so the caret shows at the end of the word either way, and the two
4528    /// offsets are one place on screen with two different meanings. Typing at the
4529    /// outer one lands past the run, so the writer who backspaced a space out of
4530    /// their bold phrase watches the next character come out plain, and the
4531    /// toolbar button go dark, with the caret never appearing to move.
4532    ///
4533    /// The end of the run's text is the caret's home there — a delete that took
4534    /// away everything after a phrase leaves the caret at the end of that phrase,
4535    /// which is inside it — so it settles onto that
4536    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
4537    /// walk, through every mark closing at the point): the word stays bold, the
4538    /// button stays lit, and the next character carries on the phrase.
4539    ///
4540    /// Rich view only, and only where a mark really closes at the caret — mid-run
4541    /// or in plain prose no span ends there and the caret stays put. The opening
4542    /// edge is left alone on purpose: a caret in front of a run inherits from the
4543    /// text on its left, which is the plain text outside.
4544    fn settle_inside_close_delims(&mut self) {
4545        if self.view != View::Wysiwyg {
4546            return;
4547        }
4548        let at = self.step_inside_close_delims(self.caret);
4549        if at != self.caret {
4550            self.caret = at;
4551            self.clear_pending();
4552            self.record_caret();
4553        }
4554    }
4555
4556    /// The splice exactly as asked, with no mark-edge repair — for the callers
4557    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
4558    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
4559    /// the bytes they inserted.
4560    ///
4561    /// One `edit_range` through twig, then re-anchor the caret from the returned
4562    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
4563    /// Markdown/Djot) leaves the document untouched and reports.
4564    ///
4565    /// Returns whether the edit landed — for a caller that has offsets of its
4566    /// own to place afterwards, which a rolled-back splice would leave pointing
4567    /// into text that never came to exist.
4568    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4569        // The read-only gate, for every edit at once — see the field.
4570        if self.read_only {
4571            return false;
4572        }
4573        // twig records an undo step for every edit; when this one continues a
4574        // run of the same kind (typing, deleting), tell twig to fold it into the
4575        // step before it so the whole run undoes at once.
4576        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
4577        // Hand twig the pre-edit caret before the splice, so the undo step it
4578        // retires carries where the caret was standing.
4579        self.record_caret();
4580        match self.editor.edit_range(start, end, text) {
4581            Ok(change) => {
4582                self.last_edit_kind = Some(kind);
4583                // Counted first, so the fold has the step it folds.
4584                self.refresh();
4585                if coalesce {
4586                    self.coalesce_last_undo();
4587                }
4588                self.caret = change.new.end;
4589                self.anchor = None;
4590                self.goal_col = None;
4591                self.clear_pending();
4592                self.dirty = self.source != self.clean_source;
4593                self.status = None;
4594                // And the post-edit caret, so a later redo restores it.
4595                self.record_caret();
4596                true
4597            }
4598            // The edit was rolled back, so twig's history did not move and
4599            // neither may ours: pushing here would leave a step with no edit
4600            // under it and shift every later undo onto the wrong caret.
4601            Err(e) => {
4602                self.status = Some(format!("edit: {e}"));
4603                false
4604            }
4605        }
4606    }
4607
4608    /// The re-spelling that would keep the mark-edge rule for the edit
4609    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
4610    /// against a delimiter and the plain splice is already right. Computed
4611    /// *before* the edit, while the run's spans and delimiters can still be read
4612    /// off the document; applied afterwards, and only if the mark really died —
4613    /// see [`repair_mark_edges`](Self::repair_mark_edges).
4614    ///
4615    /// Rich view only. Source view is for typing raw markup, where a space put
4616    /// against a `**` is exactly the character it looks like.
4617    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
4618        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
4619            return None;
4620        }
4621        // Every inline mark standing over the edit, outermost first, with the
4622        // content span that says where its delimiters are.
4623        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
4624            .editor
4625            .ancestors_at(start)
4626            .unwrap_or_default()
4627            .into_iter()
4628            .filter_map(|m| {
4629                let kind = inline_kind(&m.kind)?;
4630                let content = m.content_span.clone()?;
4631                Some((kind, m.span.clone(), content))
4632            })
4633            .collect();
4634        // The innermost run whose *content* holds the whole edit: the one whose
4635        // text is being changed, rather than one the edit merely sits under.
4636        let (kind, span, content) = chain
4637            .iter()
4638            .rev()
4639            .find(|(_, _, c)| c.start <= start && end <= c.end)?
4640            .clone();
4641        // What that content becomes. Whitespace at either end of it is what
4642        // would put out the mark.
4643        let body = format!(
4644            "{}{text}{}",
4645            &self.source[content.start..start],
4646            &self.source[end..content.end]
4647        );
4648        let (lead, trail) = if body.trim().is_empty() {
4649            // Nothing but whitespace left: there is no content to mark at all,
4650            // and the delimiters go with it rather than closing on a space.
4651            (body.len(), 0)
4652        } else {
4653            (
4654                body.len() - body.trim_start().len(),
4655                body.len() - body.trim_end().len(),
4656            )
4657        };
4658        // Nothing against a delimiter, and something still between them: the
4659        // plain edit stands. An emptied run is broken just as surely (`**b**`
4660        // with the `b` deleted is the literal `****`) and is re-spelt as the
4661        // nothing it now says.
4662        if lead == 0 && trail == 0 && !body.is_empty() {
4663            return None;
4664        }
4665        // Marks that open or close exactly where this one does — `***both***` is
4666        // two runs sharing an edge — spell their delimiters as one run of bytes,
4667        // so the whitespace has to clear all of them together.
4668        let (mut open_at, mut close_at) = (span.start, span.end);
4669        for _ in 0..chain.len() {
4670            match chain.iter().find(|(_, _, c)| c.start == open_at) {
4671                Some((_, s, _)) => open_at = s.start,
4672                None => break,
4673            }
4674        }
4675        for _ in 0..chain.len() {
4676            match chain.iter().find(|(_, _, c)| c.end == close_at) {
4677                Some((_, s, _)) => close_at = s.end,
4678                None => break,
4679            }
4680        }
4681        let open = &self.source[open_at..content.start];
4682        let close = &self.source[content.end..close_at];
4683        let core = &body[lead..body.len() - trail];
4684        let respelt = if core.is_empty() {
4685            body.clone()
4686        } else {
4687            format!(
4688                "{}{open}{core}{close}{}",
4689                &body[..lead],
4690                &body[body.len() - trail..]
4691            )
4692        };
4693        // The caret sits just past the inserted text within the new content —
4694        // which, when that lands in the whitespace, is now outside the delimiters.
4695        let pos = (start - content.start) + text.len();
4696        let caret = if core.is_empty() || pos <= lead {
4697            open_at + pos
4698        } else if pos >= lead + core.len() {
4699            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4700        } else {
4701            open_at + lead + open.len() + (pos - lead)
4702        };
4703        Some(MarkEdgeFix {
4704            kind,
4705            probe: content.start,
4706            start: open_at,
4707            end: close_at + text.len() - (end - start),
4708            text: respelt,
4709            caret,
4710            // The marks in force here, resolved against any armed sticky delta —
4711            // what the writer is typing in, and so what has to still be true on
4712            // the far side of the delimiter the caret just stepped over.
4713            want: chain
4714                .iter()
4715                .filter(|(_, s, _)| start < s.end)
4716                .map(|(k, _, _)| *k)
4717                .collect::<InlineMarks>()
4718                .xor(self.pending_here()),
4719        })
4720    }
4721
4722    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4723    /// did break the mark. Whether whitespace at a delimiter is fatal is the
4724    /// format's business, not leaf's: `**bold **` is no longer strong, while
4725    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4726    /// don't care either. Asking the parser afterwards settles it for every kind
4727    /// and format at once, and costs a re-spelling only where one is due.
4728    ///
4729    /// The repair rides along with the edit that caused it — one undo step puts
4730    /// back what the writer typed, not a delimiter shuffle they never saw.
4731    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4732        if fix.end > self.source.len() {
4733            return;
4734        }
4735        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4736            return; // still a mark: these delimiters don't mind the whitespace
4737        }
4738        let resumed = self.last_edit_kind;
4739        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4740            return;
4741        }
4742        self.coalesce_last_undo();
4743        // The keystroke owns the undo step, so the run of typing it belongs to
4744        // keeps coalescing over the repair rather than breaking in two here.
4745        self.last_edit_kind = resumed;
4746        self.caret = fix.caret.min(self.source.len());
4747        self.anchor = None;
4748        self.goal_col = None;
4749        self.rearm(fix.want);
4750        self.clamp_caret();
4751        self.record_caret();
4752    }
4753
4754    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4755    /// writer is typing in, carried across an edit that moved the caret out of
4756    /// the run holding them. Arms nothing when the caret already stands in
4757    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4758    /// a clean delta here (see [`toggle`](Self::toggle)).
4759    fn rearm(&mut self, want: InlineMarks) {
4760        let here: InlineMarks = self
4761            .marks_at(self.caret)
4762            .into_iter()
4763            .map(|(k, _)| k)
4764            .collect();
4765        self.pending_marks = want.xor(here);
4766        self.pending_at = Some(self.caret);
4767    }
4768
4769    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4770    /// which backslash-escapes any character that would otherwise open markup in
4771    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4772    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4773    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4774    /// a collapsed point — a selection is deleted by the caller first, since
4775    /// `insert_literal` inserts rather than replaces.
4776    fn insert_literal_at(
4777        &mut self,
4778        at: usize,
4779        text: &str,
4780        kind: EditKind,
4781        force_coalesce: bool,
4782    ) -> bool {
4783        // The read-only gate: this door goes to twig directly, not through
4784        // `splice_exact`, so it guards itself — see the field.
4785        if self.read_only {
4786            return false;
4787        }
4788        // `force_coalesce` folds this into the immediately preceding edit (the
4789        // selection-delete of an overwrite) so the pair is one undo step; else it
4790        // coalesces only when it continues a run of the same-kind typing.
4791        let coalesce =
4792            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4793        // The mark-edge rule holds for typed text however it is spelled — see
4794        // `splice`. Only an insert twig passed through unchanged can use it,
4795        // since a fix is measured in the bytes that actually land, and an escape
4796        // adds bytes this couldn't have counted.
4797        let fix = self.mark_edge_fix(at, at, text);
4798        #[cfg(test)]
4799        if std::mem::take(&mut self.refuse_next_literal) {
4800            self.status = Some("edit: refused".into());
4801            return false;
4802        }
4803        self.record_caret();
4804        match self.editor.insert_literal(at, text) {
4805            Ok(change) => {
4806                self.last_edit_kind = Some(kind);
4807                // Counted first, so the fold has the step it folds.
4808                self.refresh();
4809                if coalesce {
4810                    self.coalesce_last_undo();
4811                }
4812                self.caret = change.new.end;
4813                self.anchor = None;
4814                self.goal_col = None;
4815                self.clear_pending();
4816                self.dirty = self.source != self.clean_source;
4817                self.status = None;
4818                self.record_caret();
4819                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4820                    self.repair_mark_edges(fix);
4821                }
4822                true
4823            }
4824            Err(e) => {
4825                self.status = Some(format!("edit: {e}"));
4826                false
4827            }
4828        }
4829    }
4830
4831    /// After a structural list edit (a new item, a nest/unnest), renumber the
4832    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4833    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4834    /// renumber as its own edit; fold it into the edit that triggered it so the
4835    /// two undo as one, and only when it actually changed the source (a no-op or
4836    /// a caret outside any ordered list must not coalesce the real edit into the
4837    /// step before it).
4838    fn renumber_here(&mut self) {
4839        self.renumber_at(self.caret);
4840    }
4841
4842    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4843    /// caret — for an edit that leaves the caret one past the item it just wrote,
4844    /// where twig resolves no list to renumber.
4845    fn renumber_at(&mut self, off: usize) {
4846        // The read-only gate — this door reaches twig without the splice.
4847        if self.read_only {
4848            return;
4849        }
4850        let before = self.source.clone();
4851        if self.editor.renumber_ordered_lists(off).is_err() {
4852            return; // not inside an ordered list — nothing to renumber
4853        }
4854        self.refresh();
4855        if self.source != before {
4856            self.coalesce_last_undo();
4857            self.dirty = self.source != self.clean_source;
4858            self.clamp_caret();
4859            self.record_caret();
4860        }
4861    }
4862
4863    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4864    /// sub-item written directly beneath a text line reparses that text as a
4865    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
4866    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4867    /// bullets can't underline anything, so swap the dash for a `*`: the item
4868    /// stays an empty nested bullet, the parent stays prose, and the source
4869    /// round-trips instead of hiding a heading the user never asked for. Folded
4870    /// into the triggering edit's undo step, the way renumbering is.
4871    ///
4872    /// Gated on the collapse having actually happened (the swapped dash was
4873    /// swallowed into a `heading`), so a real setext heading the author wrote —
4874    /// or a `- x` with content, which can't underline anything — is never
4875    /// touched. This has to live in the *edit*, not the renderer: leaving the
4876    /// hazardous bytes on disk and only painting over them would ship a file
4877    /// every other CommonMark tool reads as a heading.
4878    ///
4879    /// This one keeps its own byte scan, and has to: the hazard is precisely
4880    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4881    /// which asks twig which lines open an item — reports nothing here. There is
4882    /// no node to ask about. It is also the last Markdown spelling leaf writes on
4883    /// purpose rather than for want of an answer; once twig spells continuations
4884    /// itself, avoiding the trap becomes twig's, and this goes.
4885    ///
4886    /// [`list_marker_on_line`]: Self::list_marker_on_line
4887    fn avoid_setext_collapse(&mut self) {
4888        let caret = self.caret.min(self.source.len());
4889        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4890        let bytes = self.source.as_bytes();
4891        let mut dash = line_start;
4892        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4893            dash += 1;
4894        }
4895        // A dash bullet is the only marker that doubles as a setext underline.
4896        if bytes.get(dash) != Some(&b'-') {
4897            return;
4898        }
4899        // Only an *empty* item is a bare underline; `- x` carries content and
4900        // can't fold the line above into a heading.
4901        let line_end = self.source[dash..]
4902            .find('\n')
4903            .map_or(self.source.len(), |i| dash + i);
4904        if !self.source[dash + 1..line_end].trim().is_empty() {
4905            return;
4906        }
4907        // The tell: that dash was swallowed into a `heading`. A properly nested
4908        // empty item sits under a `list_item`, with no heading in reach. Probe
4909        // the dash byte itself (well inside the heading), not the caret, whose
4910        // end-of-line offset can fall on the half-open span boundary.
4911        let collapsed = self
4912            .editor
4913            .ancestors_at(dash)
4914            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4915            .unwrap_or(false);
4916        if !collapsed {
4917            return;
4918        }
4919        let caret = self.caret;
4920        if self.splice(dash, dash + 1, "*", EditKind::Other) {
4921            // Same width, so the caret keeps its column; fold into the edit that
4922            // triggered this so Tab stays one undo step.
4923            self.coalesce_last_undo();
4924            self.caret = caret.min(self.source.len());
4925            self.clamp_caret();
4926            self.record_caret();
4927        }
4928    }
4929
4930    fn snapshot(&self) -> CaretState {
4931        CaretState {
4932            caret: self.caret,
4933            anchor: self.anchor,
4934        }
4935    }
4936
4937    /// Hand twig the current caret and selection as the blob for the live
4938    /// document state. Called before an edit — so the step twig retires records
4939    /// where the caret was, and undo can restore it — and again once the op has
4940    /// placed the caret, so redo restores where the edit left it.
4941    ///
4942    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4943    /// caret through its own history, so coalescing falls out for free (folding
4944    /// two twig steps into one drops the intermediate blob, keeping the run's
4945    /// first) and the parallel stacks that had to march in lockstep — and could
4946    /// silently drift out of it — are gone.
4947    fn record_caret(&mut self) {
4948        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4949    }
4950
4951    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4952    /// the toggled region selected so a second press cleanly reverses it.
4953    pub fn toggle(&mut self, kind: InlineKind) {
4954        // The read-only gate — this door reaches twig without the splice.
4955        if self.read_only {
4956            return;
4957        }
4958        // Ahead of the no-selection branch below: arming a mark for text not yet
4959        // typed is a promise `insert` cannot keep in a format with no delimiters
4960        // to spell it with. Per *kind*, not per format — Markdown spells five
4961        // of the eight marks (highlight among them, under the `highlight`
4962        // extension leaf parses with), djot all eight, HTML seven.
4963        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4964            return;
4965        }
4966        let Some((s, e)) = self.selection() else {
4967            // No selection: arm the mark for the next text typed here, the way a
4968            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4969            // in the flow of typing without ever selecting anything — the delta
4970            // is realised onto the freshly typed text by `insert`. A fresh caret
4971            // position starts the delta over from the marks actually in force.
4972            if self.pending_at != Some(self.caret) {
4973                self.pending_marks = InlineMarks::empty();
4974                self.pending_at = Some(self.caret);
4975            }
4976            self.pending_marks.flip(kind);
4977            self.status = None;
4978            return;
4979        };
4980        // Whitespace at the edge of a selection is not part of what was chosen —
4981        // a double-click takes the space after the word with it — and a mark
4982        // cannot close against one anyway: `**word **` is four literal asterisks
4983        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4984        let picked = &self.source[s..e];
4985        let (s, e) = (
4986            s + (picked.len() - picked.trim_start().len()),
4987            e - (picked.len() - picked.trim_end().len()),
4988        );
4989        if s >= e {
4990            self.status = Some(format!("{kind:?}: nothing selected to mark"));
4991            return;
4992        }
4993        // Styling a selection is a one-shot act, not a sticky mode.
4994        self.clear_pending();
4995        self.record_caret();
4996        match self.editor.toggle_inline(s, e, kind) {
4997            Ok(change) => {
4998                self.last_edit_kind = None; // structural edit is its own undo step
4999                self.refresh();
5000                self.anchor = Some(change.new.start);
5001                self.caret = change.new.end;
5002                self.dirty = self.source != self.clean_source;
5003                self.status = None;
5004                self.record_caret();
5005            }
5006            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5007        }
5008    }
5009
5010    /// Whether the caret stands in a highlight — what a frontend asks to enable
5011    /// or disable its highlight-colour controls, the way
5012    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
5013    ///
5014    /// A fact about the *caret*, and the other half of
5015    /// [`Capabilities::mark_color`], which is the fact about the format. A
5016    /// frontend needs both: djot spells a highlight and no colour for it, so a
5017    /// caret standing in `{=word=}` answers `true` here and still has no palette
5018    /// to offer.
5019    ///
5020    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
5021    /// the palette appears exactly where the Highlight button is lit — with one
5022    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
5023    /// into lights the button and answers `false` here, because there is no node
5024    /// to colour until the text exists.
5025    pub fn caret_in_mark(&mut self) -> bool {
5026        self.mark_offset().is_some()
5027    }
5028
5029    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
5030    /// standing in the highlight the gesture means — or `None` when neither end
5031    /// of what is selected is in one.
5032    ///
5033    /// The caret first, and the selection's *start* after it, because of what
5034    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
5035    /// whole, with the caret at its far edge, one past the closing `==` and so
5036    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
5037    /// and colour it — the two presses a coloured highlight is made of — would
5038    /// otherwise refuse on the second, having just written the highlight the
5039    /// author is pointing at.
5040    fn mark_offset(&mut self) -> Option<usize> {
5041        let in_mark = |d: &mut Self, off: usize| {
5042            d.marks_at(off)
5043                .into_iter()
5044                .any(|(k, _)| k == InlineKind::Mark)
5045                .then_some(off)
5046        };
5047        let caret = self.caret.min(self.source.len());
5048        in_mark(self, caret).or_else(|| {
5049            let start = self.selection()?.0;
5050            in_mark(self, start)
5051        })
5052    }
5053
5054    /// The colour of the highlight at the caret — `None` both when the caret is
5055    /// in no highlight and when the highlight it is in names no colour, which
5056    /// are the same answer to "which swatch is lit".
5057    ///
5058    /// The innermost mark, by span, for the same reason
5059    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
5060    /// what the caret is *in* is the deepest node containing it. A `data-color`
5061    /// naming a colour this build has no variant for reads as `None` — the
5062    /// renderer already draws that as a plain highlight rather than guessing,
5063    /// and the toolbar agrees with the renderer.
5064    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
5065        let at = self.mark_offset()?;
5066        self.mark_color_at(at)
5067    }
5068
5069    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
5070    /// the innermost `mark` covering it, and the colour it names.
5071    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
5072        self.nodes()
5073            .into_iter()
5074            .filter(|n| n.kind == Kind::Mark)
5075            .filter(|n| n.span.start <= off && off < n.span.end)
5076            .min_by_key(|n| n.span.end - n.span.start)
5077            .and_then(|n| MarkColor::from_attrs(&n.attrs))
5078    }
5079
5080    /// Colour the highlight at the caret, or clear its colour with `None` — the
5081    /// palette behind a toolbar's Highlight button.
5082    ///
5083    /// Markdown only, and the one gesture whose availability is a fact about the
5084    /// *parse extensions* rather than about the format alone: the colour is
5085    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
5086    /// records as the mark's `data-color`, and only an editor parsing with
5087    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
5088    /// document) reads it back that way. Djot spells the highlight and no colour
5089    /// for it, so this refuses there — see [`Capabilities::mark_color`].
5090    ///
5091    /// **A colour is a property of a highlight that already exists.** There is
5092    /// no "highlight this in red" here, because that is two splices and would be
5093    /// two undo steps under one press; a frontend that wants it calls
5094    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
5095    /// order the two buttons already sit in. With no highlight at the caret this
5096    /// says so in the status line and writes nothing.
5097    ///
5098    /// The caret keeps its place in the *text*: the splice is entirely in the
5099    /// prefix between the opening `==` and the first word, so an offset past it
5100    /// rides the emoji's width, and one standing on the prefix itself lands
5101    /// where the prefix now ends.
5102    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
5103        // The read-only gate — this door reaches twig without the splice.
5104        if self.read_only {
5105            return;
5106        }
5107        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
5108            return;
5109        }
5110        let Some(at) = self.mark_offset() else {
5111            self.status = Some("highlight colour: no highlight at the caret".into());
5112            return;
5113        };
5114        // Clearing a colour a highlight hasn't got is twig's one *successful*
5115        // no-op, and the `Change` it hands back then describes whatever edit came
5116        // before it — a stale span that would drag the caret somewhere it never
5117        // was. Answer it here, where the question is cheap, rather than trusting
5118        // a change that isn't one.
5119        if color.is_none() && self.mark_color_at(at).is_none() {
5120            self.status = None;
5121            return;
5122        }
5123        self.record_caret();
5124        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
5125            Ok(change) => {
5126                // Re-anchored from the offsets as they were, *before* `refresh`
5127                // sees the new bytes: the caret it clamps is one standing inside
5128                // a prefix that didn't exist a moment ago, and walking it back to
5129                // a char boundary of the emoji loses the place this is restoring.
5130                let caret = reanchor(self.caret, &change);
5131                let anchor = self.anchor.map(|a| reanchor(a, &change));
5132                self.last_edit_kind = None; // structural edit is its own undo step
5133                self.refresh();
5134                self.caret = caret;
5135                self.anchor = anchor;
5136                self.dirty = self.source != self.clean_source;
5137                self.status = None;
5138                self.clamp_caret();
5139                self.record_caret();
5140            }
5141            Err(e) => self.status = Some(format!("highlight colour: {e}")),
5142        }
5143    }
5144
5145    /// One press of a colour swatch: colour the highlight at the caret, or —
5146    /// over a selection that isn't highlighted yet — highlight it and colour it,
5147    /// as **one** undo step.
5148    ///
5149    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
5150    /// one splice; this is the compound every toolbar actually presses, and it
5151    /// lives here rather than in each frontend because the rule it encodes —
5152    /// what a swatch means when there is no highlight under it yet — is one
5153    /// answer, not one per frontend. The two splices are folded into a single
5154    /// history step, so the press that made a red highlight is taken back by a
5155    /// single undo rather than leaving an uncoloured one behind.
5156    ///
5157    /// `None` clears the colour, and over an unhighlighted selection means
5158    /// simply "highlight this" — the same thing the Highlight button does.
5159    /// A bare caret in no highlight is left alone with a status line, because
5160    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
5161    /// colour cannot be armed with it.
5162    pub fn highlight(&mut self, color: Option<MarkColor>) {
5163        if self.caret_in_mark() || self.selection().is_none() {
5164            self.set_mark_color(color);
5165            return;
5166        }
5167        self.toggle(InlineKind::Mark);
5168        // The format may not spell a highlight at all (`toggle` said so), and
5169        // there is nothing to colour if it doesn't.
5170        if self.status.is_some() {
5171            return;
5172        }
5173        let before = self.revision;
5174        self.set_mark_color(color);
5175        // Only fold when the colour really spliced. `highlight(None)` over a
5176        // fresh highlight is a no-op by design, and coalescing there would eat
5177        // the *previous* edit into the toggle instead.
5178        if self.revision != before {
5179            self.coalesce_last_undo();
5180        }
5181    }
5182
5183    // ── the presentation vocabulary ─────────────────────────────────────────
5184    //
5185    // Six gestures and five queries over twig's two attribute ops. Each gesture
5186    // edits **one key and keeps the rest**: it reads the node's attributes,
5187    // removes its own key (and, for alignment, its own tokens out of `class`),
5188    // adds the new value or nothing, and passes the list back whole — twig's
5189    // contract is replace-not-merge, so the read is the caller's job. A
5190    // paragraph that came in as `class="lead center" id="intro"
5191    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
5192    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
5193    // touched, which is what lets a document from elsewhere pass through the
5194    // editor unharmed.
5195    //
5196    // Clearing is the same gesture with `None`: the key goes, and an empty list
5197    // at the end unwraps the span or the Markdown div, which twig does.
5198
5199    /// Set — or with `None` clear — the alignment of the block the caret is in.
5200    ///
5201    /// A block property, so the gesture is `set_block_attrs` on the caret's
5202    /// block **whatever is selected**: a line is a block's, and "centre this"
5203    /// with three words selected means the paragraph, not the words. The
5204    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
5205    /// same `class` are kept.
5206    ///
5207    /// In Markdown the attributes live on a `<div>` around the block — twig has
5208    /// no paragraph attribute syntax to write — and this reads them back off
5209    /// that div when the block is its sole child, so a second press rewrites
5210    /// the div rather than nesting a second one.
5211    pub fn set_alignment(&mut self, align: Option<Align>) {
5212        let attrs = self.block_attrs_at_caret();
5213        if align.is_none()
5214            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
5215        {
5216            return;
5217        }
5218        let attrs = with_class_token(
5219            &attrs,
5220            |t| Align::from_token(t).is_some(),
5221            align.map(Align::name),
5222        );
5223        self.write_block_attrs("alignment", attrs);
5224    }
5225
5226    /// Set — or with `None` clear — the line spacing of the block the caret is
5227    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
5228    /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
5229    /// three names or an exact ratio, written in its canonical spelling.
5230    pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
5231        let attrs = self.block_attrs_at_caret();
5232        if spacing.is_none()
5233            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
5234                LineHeight::from_attrs(a).is_some()
5235            })
5236        {
5237            return;
5238        }
5239        let spelling = spacing.map(LineHeight::name);
5240        let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
5241        self.write_block_attrs("line spacing", attrs);
5242    }
5243
5244    /// Set — or with `None` clear — the size of the selected run, or of the
5245    /// caret's whole block when nothing is selected.
5246    ///
5247    /// Size, face and colour are the *run's*, and the block's when no run is
5248    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
5249    /// the range in an attributed span or re-styles the span it already lies in
5250    /// (never nesting a second, and unwrapping it when the last key goes). With
5251    /// no selection it is `set_block_attrs` on the caret's block, so that "make
5252    /// this paragraph larger" is a click with the caret in it rather than a
5253    /// select-all first.
5254    ///
5255    /// The walker reads the key at both levels with the nearer winning, so a
5256    /// span's `data-size` inside a block carrying its own applies to the span.
5257    ///
5258    /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
5259    /// what a menu offers first because a step reads as a step up under every
5260    /// theme, or the point size an author asked for, which is exact and is all
5261    /// it is. Either is written in its canonical spelling, so a size set twice
5262    /// from the same field writes the same bytes both times.
5263    pub fn set_font_size(&mut self, size: Option<FontSize>) {
5264        let spelling = size.map(FontSize::name);
5265        self.set_run_attr("size", "data-size", spelling.as_deref());
5266    }
5267
5268    /// Set — or with `None` clear — the face of the selected run, or of the
5269    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
5270    /// [`FontFace`] under `data-font` — one of the four generics, or the family
5271    /// the author named, which the frontends resolve through the platform's
5272    /// font registry and fall back to the body face without.
5273    pub fn set_font_family(&mut self, font: Option<FontFace>) {
5274        let spelling = font.as_ref().map(FontFace::name);
5275        self.set_run_attr("font", "data-font", spelling.as_deref());
5276    }
5277
5278    /// Set — or with `None` clear — the *text* colour of the selected run, or of
5279    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
5280    /// with [`TextColor`] under `data-color` — one of the seven names, whose
5281    /// two inks the theme owns, or the triple the author picked, which is
5282    /// painted as written in both appearances.
5283    ///
5284    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
5285    /// writes, and a different thing: that one colours a highlight's
5286    /// *background* and rides the `mark` node twig owns the spelling of, this
5287    /// one colours the letters and rides an attributed span. The two never
5288    /// collide, because a `mark` is a `mark` and a span is a span — and they
5289    /// share a vocabulary on purpose, so that a frontend with a red for a
5290    /// highlight has a red for text and both are *that* red.
5291    pub fn set_text_color(&mut self, color: Option<TextColor>) {
5292        let spelling = color.map(TextColor::name);
5293        self.set_run_attr("text colour", "data-color", spelling.as_deref());
5294    }
5295
5296    /// Insert a page break at the caret — `::page-break`, a leaf directive with
5297    /// no label and no attributes, which twig spells in every format that names
5298    /// a leaf container (Markdown under the `directives` extension
5299    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
5300    /// page-break` fence).
5301    ///
5302    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
5303    /// places a rule, and for the same reason: a directive is a block, so twig
5304    /// alone has nowhere to put one mid-paragraph and lands it after the
5305    /// caret's whole block. A bare paragraph is therefore parted at the caret
5306    /// first and the break aimed at the *first* half. See that method for the
5307    /// whole of the rule, including why a code block, a list item, a table and
5308    /// a setext heading are left unsplit.
5309    ///
5310    /// The frontends that paginate read the row's
5311    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
5312    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
5313    /// directive gets.
5314    pub fn insert_page_break(&mut self) {
5315        if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
5316            return;
5317        }
5318        self.caret = self.skip_trailing_close_delims(self.caret);
5319        // A selection is replaced by the break, as a rule replaces one.
5320        if let Some((s, e)) = self.selection() {
5321            self.splice(s, e, "", EditKind::Other);
5322        }
5323        self.anchor = None;
5324        self.record_caret();
5325        let at = self.caret;
5326        if self.caret_parts_bare_paragraph() {
5327            // A failure here is not fatal: the break still lands after the
5328            // block, which is what this call was trying to improve on.
5329            let _ = self.editor.split_block(at);
5330        }
5331        match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
5332            Ok(change) => {
5333                self.last_edit_kind = None;
5334                self.refresh();
5335                self.anchor = None;
5336                self.caret = change.new.end;
5337                self.dirty = self.source != self.clean_source;
5338                self.status = None;
5339                self.clamp_caret();
5340                self.record_caret();
5341            }
5342            Err(e) => self.status = Some(format!("page break: {e}")),
5343        }
5344    }
5345
5346    /// The alignment in force at the caret, or `None` for the theme's default —
5347    /// which swatch of an alignment control is lit.
5348    ///
5349    /// Read off the nearest node that names one: the block the caret is in, and
5350    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
5351    /// shape, one property along.
5352    pub fn alignment_at_caret(&mut self) -> Option<Align> {
5353        self.presentation_chain()
5354            .iter()
5355            .find_map(|attrs| Align::from_attrs(attrs))
5356    }
5357
5358    /// The line spacing in force at the caret, or `None` for the theme's own.
5359    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
5360    pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
5361        self.presentation_chain()
5362            .iter()
5363            .find_map(|attrs| LineHeight::from_attrs(attrs))
5364    }
5365
5366    /// The size in force at the caret, or `None` for the theme's own — the
5367    /// entry a size menu shows ticked.
5368    ///
5369    /// Run-level, so the chain starts one node deeper: the attributed span the
5370    /// caret stands in, then its block, then the `div`s around it. The nearest
5371    /// wins, which is the rule the walker draws by.
5372    ///
5373    /// A name or a value, whichever the nearest node wrote. A `data-size` the
5374    /// grammar does not cover — a `huge` from elsewhere — is not a size this
5375    /// can answer, so the answer is `None` and the menu ticks *Default*, the
5376    /// same thing it did before the vocabulary opened.
5377    pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
5378        self.presentation_chain()
5379            .iter()
5380            .find_map(|attrs| FontSize::from_attrs(attrs))
5381    }
5382
5383    /// The face in force at the caret, or `None` for the theme's body face.
5384    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
5385    pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
5386        self.presentation_chain()
5387            .iter()
5388            .find_map(|attrs| FontFace::from_attrs(attrs))
5389    }
5390
5391    /// The *text* colour in force at the caret, or `None` for the theme's.
5392    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
5393    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
5394    /// highlight's background off a `mark`, and a `mark` is never in this chain.
5395    pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
5396        self.presentation_chain()
5397            .iter()
5398            .find_map(|attrs| TextColor::from_attrs(attrs))
5399    }
5400
5401    /// The selection-or-caret half of the three run-level gestures: a span over
5402    /// a real selection, the caret's block over none.
5403    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
5404        match self.selection() {
5405            Some((start, end)) => {
5406                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
5407                self.write_run_attrs(what, start, end, attrs);
5408            }
5409            None => {
5410                let own = self.block_attrs_at_caret();
5411                if value.is_none()
5412                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
5413                {
5414                    return;
5415                }
5416                let attrs = with_attr(&own, key, value);
5417                self.write_block_attrs(what, attrs);
5418            }
5419        }
5420    }
5421
5422    /// A clear this gesture cannot carry out, said out loud instead of written:
5423    /// the node it rewrites — the caret's block, or the `<div>` around it that
5424    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
5425    /// — does not name the property at all, and a `div` further out does.
5426    ///
5427    /// Handing twig the block's attributes with the key already absent changes
5428    /// no byte, and the query goes on answering `Some` off the div: the menu
5429    /// entry the author pressed stays unticked, and nothing says why. Twig's
5430    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
5431    /// write on a node it is not rewriting — the honest answer is the status
5432    /// line, in the voice the other refusals use.
5433    ///
5434    /// `names` is the property's own reading of an attribute list, because
5435    /// alignment lives in a `class` token rather than a key of its own. Spans
5436    /// are skipped: one inside the block is not what a *block* gesture writes
5437    /// either, but neither is it "the div around the block", and the run-level
5438    /// gestures reach it through a selection.
5439    fn refuse_clear_from_div(
5440        &mut self,
5441        what: &str,
5442        own: &Attrs,
5443        names: impl Fn(&Attrs) -> bool,
5444    ) -> bool {
5445        if names(own) {
5446            return false;
5447        }
5448        let caret = self.caret.min(self.source.len());
5449        if !self
5450            .attr_chain_at(caret)
5451            .iter()
5452            .any(|(span, attrs)| !span && names(attrs))
5453        {
5454            return false;
5455        }
5456        self.status = Some(format!("{what}: set on the div around the block"));
5457        true
5458    }
5459
5460    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
5461    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
5462    /// has.
5463    ///
5464    /// **The caret keeps its place in the text, not its byte offset.** How a
5465    /// format spells a block's attributes is markup written *around* the block
5466    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
5467    /// it in Markdown, a longer opening tag in HTML — and every one of those
5468    /// grows or shrinks above the author's own bytes. Where twig's change
5469    /// rewrites the block whole (Markdown's div is spliced as one region, block
5470    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
5471    /// and parks the caret at the end of the splice, past the closing `</div>`:
5472    /// the caret is then in no block at all, so a second press of the same menu
5473    /// answers "no block at the caret" and the toolbar's queries read nothing.
5474    /// [`reanchor_in_block`] is what carries it across instead — the block's
5475    /// content span before and after, which is the one thing the respelling
5476    /// leaves alone.
5477    ///
5478    /// Read *before* the splice and applied *after* `refresh`, because both
5479    /// halves of that mapping are facts about a tree twig is between: the
5480    /// block's old bytes are gone once the edit lands, and its new ones are not
5481    /// in `self.source` until the refresh puts them there.
5482    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
5483        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
5484            return;
5485        }
5486        // A blank line has no block to carry an attribute, and twig answers
5487        // `NotFound` there — say so in leaf's own words instead.
5488        let Some(at) = self.block_offset_for_caret() else {
5489            self.status = Some(format!("{what}: no block at the caret"));
5490            return;
5491        };
5492        self.record_caret();
5493        let pairs = attr_pairs(&attrs);
5494        let was = self.block_content_at(at);
5495        let text = was.clone().map(|s| self.source[s].to_string());
5496        match self.editor.set_block_attrs(at, &pairs) {
5497            Ok(change) => {
5498                let (caret, anchor) = (self.caret, self.anchor);
5499                self.last_edit_kind = None; // structural edit is its own undo step
5500                self.refresh();
5501                let now = self.block_content_in(&change.new, text.as_deref());
5502                // A block the two halves cannot both name — a code block, a
5503                // caret in a list's marker — takes the plain arithmetic, which
5504                // is what it had before.
5505                let block = was.as_ref().zip(now.as_ref());
5506                self.caret = reanchor_in_block(caret, &change, block);
5507                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
5508                self.dirty = self.source != self.clean_source;
5509                self.status = None;
5510                // The clamp reads the caret floor off the map, and this edit
5511                // can move the floor: taking the `{…}` line off a djot
5512                // document's first block moves the first rendered offset to 0,
5513                // and a floor read from the old map stood the caret past the
5514                // block's text. So the map is this revision's before the clamp
5515                // — see `open_paragraph_at_block_edge`.
5516                self.rebuild_map();
5517                self.clamp_caret();
5518                self.record_caret();
5519            }
5520            Err(e) => self.status = Some(format!("{what}: {e}")),
5521        }
5522    }
5523
5524    /// The content span of the innermost paragraph or heading covering `off` —
5525    /// the author's own bytes, without the `# ` or the `<p>` that spells the
5526    /// block around them.
5527    ///
5528    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
5529    /// reads, so that what a gesture re-anchors by is the block it wrote to.
5530    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
5531        self.nodes()
5532            .into_iter()
5533            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5534            .filter(|n| n.span.start <= off && off <= n.span.end)
5535            .min_by_key(|n| n.span.end - n.span.start)
5536            .map(|n| n.content_span.unwrap_or(n.span))
5537    }
5538
5539    /// [`block_content_at`](Self::block_content_at)'s other half: the content
5540    /// span of the block `region` holds now, found by the bytes it held before.
5541    ///
5542    /// Matched on the text rather than taken as the first block in the region,
5543    /// because a rewritten region is markup and all — `<div class="center">`
5544    /// carries words of its own — and because the block this gesture moved is
5545    /// the one whose content the respelling did not touch. `None` where the
5546    /// region holds no block at all, which is djot's every case: the `{…}` line
5547    /// is spliced above the block and the block itself never moves through the
5548    /// change at all, only past it.
5549    fn block_content_in(
5550        &mut self,
5551        region: &Range<usize>,
5552        text: Option<&str>,
5553    ) -> Option<Range<usize>> {
5554        let text = text?;
5555        let spans: Vec<Range<usize>> = self
5556            .nodes()
5557            .into_iter()
5558            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5559            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
5560            .map(|n| n.content_span.unwrap_or(n.span))
5561            .collect();
5562        spans
5563            .into_iter()
5564            .find(|s| self.source.get(s.clone()) == Some(text))
5565    }
5566
5567    /// Hand `attrs` to twig as the attribute set of the span over `[start,
5568    /// end)` — wrapping one, or re-styling the one the range already lies in,
5569    /// or unwrapping it when `attrs` is empty.
5570    ///
5571    /// What the splice leaves selected is the span's **content** — the author's
5572    /// words — and not the whole of `change.new`, which is markup and all:
5573    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
5574    /// selection reaching past the node's own span lies in no span at all, so a
5575    /// second press of the menu would nest a fresh one instead of re-styling
5576    /// the one just written.
5577    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
5578        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
5579            return;
5580        }
5581        self.record_caret();
5582        let pairs = attr_pairs(&attrs);
5583        match self.editor.wrap_range_attrs(start, end, &pairs) {
5584            Ok(change) => {
5585                self.last_edit_kind = None;
5586                self.refresh();
5587                let content = self.span_content_in(&change.new);
5588                self.anchor = Some(content.start);
5589                self.caret = content.end;
5590                self.dirty = self.source != self.clean_source;
5591                self.status = None;
5592                self.clamp_caret();
5593                self.record_caret();
5594            }
5595            Err(e) => self.status = Some(format!("{what}: {e}")),
5596        }
5597    }
5598
5599    /// The content range of the attributed span `spliced` now holds — the
5600    /// outermost one inside it, since that is the one just written — or
5601    /// `spliced` itself where the splice left no span, which is what an unwrap
5602    /// leaves behind.
5603    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
5604        self.nodes()
5605            .into_iter()
5606            .filter(wysiwyg::is_run_span)
5607            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
5608            .max_by_key(|n| n.span.end - n.span.start)
5609            .and_then(|n| n.content_span)
5610            .unwrap_or_else(|| spliced.clone())
5611    }
5612
5613    /// The attribute set `set_block_attrs` is about to **replace** at the caret
5614    /// — which is the block's own, except in Markdown, where twig writes a
5615    /// block's attributes onto a `<div>` around it and rewrites that div when
5616    /// the block is its sole child. Reading the paragraph there would hand back
5617    /// an empty list and quietly drop everything the div said.
5618    ///
5619    /// Empty when the caret is in no block at all, which is the same list a
5620    /// block carrying no attributes gives — and the right one either way, since
5621    /// the gesture then refuses on its own.
5622    fn block_attrs_at_caret(&mut self) -> Attrs {
5623        let Some(off) = self.block_offset_for_caret() else {
5624            return Vec::new();
5625        };
5626        let nodes = self.nodes();
5627        let Some(block) = nodes
5628            .iter()
5629            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5630            .filter(|n| n.span.start <= off && off <= n.span.end)
5631            .min_by_key(|n| n.span.end - n.span.start)
5632        else {
5633            return Vec::new();
5634        };
5635        if self.format == Format::Markdown
5636            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
5637            && wysiwyg::element_tag(parent) == Some("div")
5638            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
5639        {
5640            return parent.attrs.clone();
5641        }
5642        block.attrs.clone()
5643    }
5644
5645    /// The attribute set `wrap_range_attrs` is about to **replace** over
5646    /// `[start, end)` — the innermost attributed span the range lies inside,
5647    /// which twig re-styles rather than nesting a second one in. Empty when the
5648    /// range lies in no span, where the gesture mints a fresh one.
5649    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5650        self.nodes()
5651            .into_iter()
5652            .filter(wysiwyg::is_run_span)
5653            .filter(|n| n.span.start <= start && end <= n.span.end)
5654            .min_by_key(|n| n.span.end - n.span.start)
5655            .map(|n| n.attrs)
5656            .unwrap_or_default()
5657    }
5658
5659    /// The attribute lists that bear on a presentation query, **nearest first**:
5660    /// the attributed spans the caret stands in (innermost first), then its
5661    /// block, then the `div`s around it. A `find_map` down this is the whole of
5662    /// each query, and the order is the rule the walker draws by.
5663    ///
5664    /// Read at the caret, and at the selection's *start* when the caret stands
5665    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5666    /// caret one past the span it just wrote — `toggle`'s convention — so
5667    /// asking the menu which entry that press just ticked must not answer
5668    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5669    fn presentation_chain(&mut self) -> Vec<Attrs> {
5670        let caret = self.caret.min(self.source.len());
5671        let mut chain = self.attr_chain_at(caret);
5672        if !chain.iter().any(|(span, _)| *span)
5673            && let Some((start, _)) = self.selection()
5674        {
5675            let alt = self.attr_chain_at(start);
5676            if alt.iter().any(|(span, _)| *span) {
5677                chain = alt;
5678            }
5679        }
5680        chain.into_iter().map(|(_, attrs)| attrs).collect()
5681    }
5682
5683    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5684    /// node bearing the vocabulary that covers it, innermost first, each paired
5685    /// with whether it is an attributed span (which is what tells the caller
5686    /// its run-level answer came from a run).
5687    ///
5688    /// Sorted by span length, which *is* the nesting order: a span lies inside
5689    /// its block and a block inside its div, so shortest-first is
5690    /// nearest-first without a second tree walk.
5691    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5692        let off = off.min(self.source.len());
5693        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5694        for n in self.nodes() {
5695            let span = wysiwyg::is_run_span(&n);
5696            let block = matches!(n.kind, Kind::Para | Kind::Heading);
5697            let div = wysiwyg::element_tag(&n) == Some("div");
5698            if !(span || block || div) {
5699                continue;
5700            }
5701            // A span is half-open, the way a mark is: the offset one past it is
5702            // the text after it. A block and a div claim their end too, so a
5703            // caret resting at the end of a line still reads its paragraph.
5704            let inside = if span {
5705                n.span.start <= off && off < n.span.end
5706            } else {
5707                n.span.start <= off && off <= n.span.end
5708            };
5709            if !inside {
5710                continue;
5711            }
5712            hits.push((n.span.end - n.span.start, span, n.attrs));
5713        }
5714        hits.sort_by_key(|(len, _, _)| *len);
5715        hits.into_iter()
5716            .map(|(_, span, attrs)| (span, attrs))
5717            .collect()
5718    }
5719
5720    /// Convert the block at the caret to a heading level or paragraph.
5721    pub fn set_block(&mut self, kind: BlockKind) {
5722        // The read-only gate — this door reaches twig without the splice.
5723        if self.read_only {
5724            return;
5725        }
5726        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5727            return;
5728        }
5729        self.record_caret();
5730        // A blank line has no node to convert, and twig opens a block there
5731        // rather than declining — so the caret's own offset is the right thing
5732        // to hand it when `block_offset_for_caret` finds nothing.
5733        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5734        match self.editor.set_block(offset, kind) {
5735            Ok(change) => {
5736                self.last_edit_kind = None;
5737                self.refresh();
5738                // Opening a block on a blank line writes a marker the caret
5739                // belongs *after*; converting an existing one moves nothing.
5740                self.caret = self.caret.max(change.new.end);
5741                self.clamp_caret();
5742                self.anchor = None;
5743                self.dirty = self.source != self.clean_source;
5744                self.status = None;
5745                self.record_caret();
5746            }
5747            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5748        }
5749    }
5750
5751    /// Whether `off` is inside a text block (paragraph, heading, code block…).
5752    fn has_block_at(&mut self, off: usize) -> bool {
5753        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5754            chain
5755                .iter()
5756                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5757        })
5758    }
5759
5760    /// The offset to hand twig's `set_block`: the caret when it is already inside
5761    /// a block, otherwise nudged onto the previous character (a caret at a line
5762    /// end sits at the doc level, outside the block). `None` when the caret is on
5763    /// a blank line — a new paragraph with no block node to convert.
5764    fn block_offset_for_caret(&mut self) -> Option<usize> {
5765        let caret = self.caret.min(self.source.len());
5766        if self.has_block_at(caret) {
5767            return Some(caret);
5768        }
5769        // Nudge to the previous character — but never across a newline: that would
5770        // target the previous block, and a blank line genuinely has no block.
5771        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5772            && ch != '\n'
5773            && self.has_block_at(i)
5774        {
5775            return Some(i);
5776        }
5777        None
5778    }
5779
5780    /// The heading level of the text block at the caret, or `None` when that
5781    /// block is not a heading.
5782    pub fn current_heading_level(&mut self) -> Option<u32> {
5783        let caret = self.caret;
5784        self.nodes()
5785            .into_iter()
5786            .filter(|n| n.kind == Kind::Heading)
5787            .find(|n| n.span.start <= caret && caret <= n.span.end)
5788            .and_then(|n| n.level)
5789    }
5790
5791    /// The inline marks in force at the caret (or over the selection) — what a
5792    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5793    /// inline counterpart. Cheap enough to call every frame: one twig
5794    /// `ancestors_at` query per caret (two with a selection), each walking root
5795    /// → deepest node at one offset. It never snapshots the tree the way
5796    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5797    /// the only allocation is twig's own small ancestor `Vec`.
5798    ///
5799    /// **A selection reports a mark only when the mark covers *all* of it.**
5800    /// That's what every real toolbar means by an active button — Bold lit over
5801    /// a half-bold selection would claim a press turns bold *off*, when
5802    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5803    /// Whole-coverage is asked as "is the same mark node standing over both the
5804    /// first and the last character?": inline nodes are contiguous, so one node
5805    /// covering both ends covers every byte between them. Two touching runs
5806    /// (`**a****b**`) are two nodes, and correctly light nothing.
5807    ///
5808    /// At a bare caret a mark is active when the caret stands inside the mark's
5809    /// span — `span.start <= caret < span.end`, delimiters included, which is
5810    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5811    /// opening `*` (2) through the last byte of the closing `**` (9) are all
5812    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5813    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5814    /// typing would actually land inside the marked run. The offset one past the
5815    /// mark (10) is the text after it and reports nothing, at the end of the
5816    /// buffer exactly as in the middle.
5817    pub fn active_inline_marks(&mut self) -> InlineMarks {
5818        let Some((start, end)) = self.selection() else {
5819            // The marks actually in force at the caret, flipped by any armed
5820            // sticky delta — so `⌘b` at a bare caret lights the Bold button
5821            // immediately, before a single character is typed.
5822            let base: InlineMarks = self
5823                .marks_at(self.caret)
5824                .into_iter()
5825                .map(|(k, _)| k)
5826                .collect();
5827            return base.xor(self.pending_here());
5828        };
5829        // The selection's *last character*, not its exclusive end: `end` is the
5830        // offset one past the selection, which for a selection ending exactly at
5831        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5832        // entirely bold, but offset 10 is the space after).
5833        let last = prev_boundary(&self.source, end);
5834        let head = self.marks_at(start);
5835        let tail = self.marks_at(last);
5836        head.into_iter()
5837            .filter(|m| tail.contains(m))
5838            .map(|(k, _)| k)
5839            .collect()
5840    }
5841
5842    /// The inline marks whose span covers `off`, each with the id of the node
5843    /// carrying it — the id is what lets a selection tell one mark node from
5844    /// another of the same kind.
5845    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5846        let off = off.min(self.source.len());
5847        self.editor
5848            .ancestors_at(off)
5849            .unwrap_or_default()
5850            .into_iter()
5851            // `span.end` is the offset one *past* the mark, so it isn't in it.
5852            // twig already resolves a boundary to whatever starts there — in
5853            // `**bold** x` offset 8 is the following text, not the strong — but
5854            // when nothing follows, the tie has nobody to break for and the
5855            // chain still ends at the mark. That would make the answer at the
5856            // last offset of the document depend on whether the file happens to
5857            // end in a newline; the rule is `span.start <= off < span.end`, and
5858            // it's the same rule at the end of a buffer as in the middle.
5859            .filter(|m| off < m.span.end)
5860            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5861            .collect()
5862    }
5863
5864    /// Toggle a heading at the caret: if the block is already this heading level,
5865    /// revert it to a paragraph; otherwise convert it to this heading level.
5866    /// This gives the heading commands the same toggle feel as bold/italic/code —
5867    /// re-applying a heading a line already has turns it back into body text.
5868    pub fn toggle_heading(&mut self, level: u32) {
5869        if self.current_heading_level() == Some(level) {
5870            self.set_block(BlockKind::Paragraph);
5871        } else {
5872            self.set_block(BlockKind::Heading(level));
5873        }
5874    }
5875
5876    /// Toggle a block quote around the selection, or around the block at the
5877    /// caret — the toolbar's Quote button.
5878    pub fn toggle_blockquote(&mut self) {
5879        self.toggle_container(BlockContainerKind::BlockQuote);
5880    }
5881
5882    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5883    /// over the block at the caret — one op with the kind as a flag, the way
5884    /// `toggle_heading` takes its level, so a frontend needs no twig type to
5885    /// name the two buttons.
5886    ///
5887    /// Pressing the *other* list's button while in a list converts in place
5888    /// rather than nesting, so the pair reads as one three-state control
5889    /// (bulleted / numbered / neither) rather than two independent wrappers.
5890    pub fn toggle_list(&mut self, ordered: bool) {
5891        self.toggle_container(if ordered {
5892            BlockContainerKind::OrderedList
5893        } else {
5894            BlockContainerKind::BulletList
5895        });
5896    }
5897
5898    /// Toggle a fenced code block over the selection, or over the block at the
5899    /// caret — the toolbar's Code Block button, and the only way the rich view
5900    /// offers to open one: a typed backtick is escaped there, since it is a
5901    /// character the author wrote and not markup they meant.
5902    ///
5903    /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
5904    /// against the body, keeps a quote's `> ` on every line, and peels a fence
5905    /// (or dedents an indented block) on the way back. What is leaf's is the
5906    /// shape twig declines: a blank line holds no block to fence, and the
5907    /// gesture nobody should have to learn is "type something first" — so leaf
5908    /// spells the empty fence itself, with the caret on the empty line inside it
5909    /// and a blank line either side, which is what typing into a new block and
5910    /// then Backspacing out of it would have left.
5911    ///
5912    /// Inside a list item twig refuses in both directions (a fence at column
5913    /// zero would swallow the item's marker), and the refusal is reported rather
5914    /// than worked around.
5915    pub fn toggle_code_block(&mut self) {
5916        // The read-only gate — this door reaches twig without the splice.
5917        if self.read_only {
5918            return;
5919        }
5920        if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
5921            return;
5922        }
5923        let selected = self.selection();
5924        // The caret at a code block's rows resolves to its *content*, and twig
5925        // finds the block from any offset inside its span — but a caret parked
5926        // on the closing fence's own line end is past it, so the block's start
5927        // is handed over whenever the caret is in one at all.
5928        let (start, end) = match selected {
5929            Some(range) => range,
5930            None => match self.code_block_start_at_caret() {
5931                Some(start) => (start, start),
5932                None => match self.block_offset_for_caret() {
5933                    Some(off) => (off, off),
5934                    None => return self.open_empty_code_block(),
5935                },
5936            },
5937        };
5938        self.record_caret();
5939        match self.editor.toggle_code_block(start, end, None) {
5940            Ok(change) => {
5941                // Read the caret's place out of the *pre-edit* source, before
5942                // `refresh` swaps it out — and how many lines the region held,
5943                // which is what says whether a fence line went in above the
5944                // caret's line or came off it.
5945                let place = selected
5946                    .is_none()
5947                    .then(|| self.caret_line_tail(&change.old));
5948                let old_lines = self.source[change.old.clone()].matches('\n').count();
5949                self.last_edit_kind = None; // structural edit is its own undo step
5950                self.refresh();
5951                match place {
5952                    // From a selection: keep the block selected, so a second
5953                    // press reverses the first (twig unfences from any offset in
5954                    // the fence's span, the region's start included).
5955                    None => {
5956                        self.anchor = Some(change.new.start);
5957                        self.caret = change.new.end;
5958                    }
5959                    // From a caret: the same line, the same distance from its
5960                    // end, shifted by the opening fence that was written above
5961                    // it (or peeled off). Dedenting an indented block keeps the
5962                    // lines one-to-one, and shifts nothing.
5963                    Some((line, tail)) => {
5964                        let new_lines = self.source[change.new.start.min(self.source.len())
5965                            ..change.new.end.min(self.source.len())]
5966                            .matches('\n')
5967                            .count();
5968                        let line = match new_lines.cmp(&old_lines) {
5969                            std::cmp::Ordering::Greater => line + 1,
5970                            std::cmp::Ordering::Less => line.saturating_sub(1),
5971                            std::cmp::Ordering::Equal => line,
5972                        };
5973                        self.anchor = None;
5974                        self.caret = self.line_tail_offset(&change.new, (line, tail));
5975                    }
5976                }
5977                self.dirty = self.source != self.clean_source;
5978                self.status = None;
5979                self.clamp_caret();
5980                self.record_caret();
5981            }
5982            Err(e) => self.status = Some(format!("code block: {e}")),
5983        }
5984    }
5985
5986    /// Write an empty fenced block on the blank line the caret stands on, and
5987    /// put the caret on the empty line inside it — the half of
5988    /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
5989    /// twig reports `NotFound` for a range no block covers.
5990    ///
5991    /// Inside a quote the fence lines and the empty line keep the quote's
5992    /// prefix, as twig's own fencing does. A blank line goes in on whichever
5993    /// side has text against it, since a fence may interrupt a paragraph in
5994    /// Markdown but a block standing tight against its neighbours is not the
5995    /// document any editor writes.
5996    fn open_empty_code_block(&mut self) {
5997        let caret = self.caret.min(self.source.len());
5998        let prefix = self.quote_prefix_at(caret);
5999        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
6000        let line_end = self.source[caret..]
6001            .find('\n')
6002            .map_or(self.source.len(), |i| caret + i);
6003        let prev_blank = line_start == 0
6004            || self.source[..line_start - 1]
6005                .rsplit('\n')
6006                .next()
6007                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6008        let next_blank = line_end >= self.source.len()
6009            || self.source[line_end + 1..]
6010                .split('\n')
6011                .next()
6012                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6013        // A blank line inside a quote is a bare `>`: the space after it is
6014        // the content's, and there is none.
6015        let blank = prefix.trim_end();
6016        let mut text = String::new();
6017        if !prev_blank {
6018            text.push_str(blank);
6019            text.push('\n');
6020        }
6021        text.push_str(&prefix);
6022        text.push_str("```\n");
6023        text.push_str(&prefix);
6024        let inside = text.len();
6025        text.push('\n');
6026        text.push_str(&prefix);
6027        text.push_str("```");
6028        if !next_blank {
6029            text.push('\n');
6030            text.push_str(blank);
6031        }
6032        // Over whatever the blank line held (its quote prefix, trailing
6033        // spaces), so the block's own prefix is the one twig will read back.
6034        let at = line_start;
6035        if !self.splice(at, line_end, &text, EditKind::Other) {
6036            return;
6037        }
6038        self.caret = at + inside;
6039        self.anchor = None;
6040        self.clamp_caret();
6041        self.record_caret();
6042    }
6043
6044    /// Whether the caret stands in a code block, fenced or indented — what
6045    /// lights the Code Block button. Wider than
6046    /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
6047    /// narrower question a language prompt needs.
6048    pub fn caret_in_code_block(&mut self) -> bool {
6049        self.code_block_start_at_caret().is_some()
6050    }
6051
6052    // ── Task list items ──────────────────────────────────────────────────────
6053    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
6054    // inline content of the item's first paragraph rather than part of its
6055    // marker, so adding or removing one must leave the item's continuation
6056    // indentation alone, and an item inside a quote is found past the quote
6057    // markers. leaf names the gesture and the offset; the spelling is twig's.
6058
6059    /// Whether the list item at the caret carries a checkbox, and which way it
6060    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
6061    /// item or no item at all. What a toolbar reads to light its checkbox button.
6062    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
6063        self.task_checked_at(self.caret)
6064    }
6065
6066    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
6067    /// offset — what a frontend asks before deciding a click landed on a box.
6068    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
6069        self.innermost_list_item(offset.min(self.source.len()))?
6070            .checked
6071    }
6072
6073    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
6074    /// A no-op with a reported reason when the caret is in no task item — minting
6075    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
6076    pub fn toggle_task_checked(&mut self) {
6077        self.toggle_task_at(self.caret);
6078    }
6079
6080    /// Tick or untick the task item covering `offset` — what a *click* on a
6081    /// rendered checkbox is. Separate from the caret form because a click carries
6082    /// its own offset and must not first move the caret there: ticking a box
6083    /// three paragraphs away should not take the cursor with it.
6084    pub fn toggle_task_at(&mut self, offset: usize) {
6085        // The read-only gate — this door reaches twig without the splice.
6086        if self.read_only {
6087            return;
6088        }
6089        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
6090            return;
6091        }
6092        let offset = offset.min(self.source.len());
6093        self.record_caret();
6094        match self.editor.toggle_task_checked(offset) {
6095            Ok(_) => self.after_task_edit(),
6096            Err(e) => self.status = Some(format!("task: {e}")),
6097        }
6098    }
6099
6100    /// Give the list item at the caret a checkbox, or take its checkbox away —
6101    /// the gesture that converts between a plain bullet and a task. A new box
6102    /// arrives unticked.
6103    pub fn toggle_task_item(&mut self) {
6104        // The read-only gate — this door reaches twig without the splice.
6105        if self.read_only {
6106            return;
6107        }
6108        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
6109            return;
6110        }
6111        let caret = self.caret.min(self.source.len());
6112        self.record_caret();
6113        match self.editor.toggle_task_item(caret) {
6114            Ok(_) => self.after_task_edit(),
6115            Err(e) => self.status = Some(format!("task: {e}")),
6116        }
6117    }
6118
6119    /// Settle after a task gesture. The caret rides its old byte offset and is
6120    /// clamped back in: a box is three or four bytes on the item's first line, so
6121    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
6122    /// real stop either way.
6123    fn after_task_edit(&mut self) {
6124        self.last_edit_kind = None;
6125        self.refresh();
6126        self.anchor = None;
6127        self.dirty = self.source != self.clean_source;
6128        self.status = None;
6129        self.clamp_caret();
6130        self.record_caret();
6131    }
6132
6133    // ── Tables ───────────────────────────────────────────────────────────────
6134    // A table is a grid, and twig edits it as one — add/remove/move a row or
6135    // column, set a column's alignment — re-spelling the whole table in a single
6136    // splice. Every gesture is anchored at the caret's cell. leaf just names the
6137    // gesture and re-reads the result; the whole table's numbering, borders, and
6138    // delimiter are twig's to keep straight.
6139
6140    /// Whether the caret is inside a table — what a frontend asks to enable or
6141    /// disable its table controls.
6142    ///
6143    /// An HTML `<table>` still answers `true`: the caret really is in a table,
6144    /// and the reason the grid controls stay dark there is
6145    /// [`Capabilities::table`], which is a fact about the document's format
6146    /// rather than about the caret. A frontend needs both.
6147    pub fn caret_in_table(&mut self) -> bool {
6148        let caret = self.caret.min(self.source.len());
6149        self.editor
6150            .ancestors_at(caret)
6151            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
6152            .unwrap_or(false)
6153    }
6154
6155    /// One grid op, guarded and settled — the shared body of the seven below.
6156    ///
6157    /// The guard is why this exists rather than seven copies of the same three
6158    /// lines, and it is the one guard leaf cannot delegate to twig. The table
6159    /// editor is the gesture family that consults no `Syntax` table (it spells a
6160    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
6161    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
6162    /// grid as a *pipe table* and reports success, swapping the element out for
6163    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
6164    /// downstream could tell that from a successful edit — the splice is real,
6165    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
6166    /// stopping at the door rather than detecting after the fact. See
6167    /// [`spells_pipe_tables`].
6168    fn table_op(
6169        &mut self,
6170        what: &str,
6171        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
6172    ) {
6173        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
6174            return;
6175        }
6176        self.record_caret();
6177        let at = self.caret;
6178        let r = op(&mut self.editor, at);
6179        self.apply_table(r, what);
6180    }
6181
6182    /// Insert an empty row below (`below`) or above the caret's row.
6183    pub fn table_insert_row(&mut self, below: bool) {
6184        self.table_op("table row", |e, at| e.table_insert_row(at, below));
6185    }
6186
6187    /// Delete the caret's row (not the header, not the last body row).
6188    pub fn table_delete_row(&mut self) {
6189        self.table_op("table row", |e, at| e.table_delete_row(at));
6190    }
6191
6192    /// Insert an empty column right (`right`) or left of the caret's column.
6193    pub fn table_insert_column(&mut self, right: bool) {
6194        self.table_op("table column", |e, at| e.table_insert_column(at, right));
6195    }
6196
6197    /// Delete the caret's column (unless it is the only one).
6198    pub fn table_delete_column(&mut self) {
6199        self.table_op("table column", |e, at| e.table_delete_column(at));
6200    }
6201
6202    /// Set the caret's column to `alignment`.
6203    pub fn table_set_alignment(&mut self, alignment: Alignment) {
6204        self.table_op("table alignment", |e, at| {
6205            e.table_set_alignment(at, alignment)
6206        });
6207    }
6208
6209    /// Move the caret's row one place down (`down`) or up, within the body rows.
6210    pub fn table_move_row(&mut self, down: bool) {
6211        self.table_op("table row", |e, at| e.table_move_row(at, down));
6212    }
6213
6214    /// Move the caret's column one place right (`right`) or left.
6215    pub fn table_move_column(&mut self, right: bool) {
6216        self.table_op("table column", |e, at| e.table_move_column(at, right));
6217    }
6218
6219    /// Settle the caret and document flags after a table op (or report its
6220    /// error). twig re-spells the whole table, so the caret rides its old byte
6221    /// offset and is clamped back into the rebuilt bytes — near enough to where
6222    /// it was, since the op preserves the cells' content and order around it.
6223    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
6224        match result {
6225            Ok(()) => {
6226                self.last_edit_kind = None;
6227                self.refresh();
6228                self.anchor = None;
6229                self.clamp_caret();
6230                self.dirty = self.source != self.clean_source;
6231                self.status = None;
6232                self.record_caret();
6233            }
6234            Err(e) => self.status = Some(format!("{what}: {e}")),
6235        }
6236    }
6237
6238    /// One `toggle_block_container` over the block-level target.
6239    ///
6240    /// leaf says *where*; twig decides everything else — which blocks the range
6241    /// covers, whether that means wrapping, unwrapping, nesting or converting,
6242    /// and how this document's format spells the prefix. The rule that a
6243    /// container only comes off when the range covers every block it holds is
6244    /// what the re-anchoring below is built around.
6245    fn toggle_container(&mut self, kind: BlockContainerKind) {
6246        // The read-only gate — this door reaches twig without the splice.
6247        if self.read_only {
6248            return;
6249        }
6250        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
6251            return;
6252        }
6253        let selected = self.selection();
6254        // A blank line holds no block, and twig opens an *empty* container on one
6255        // — since 3.2.0; it used to decline the range with `NotFound`, which is
6256        // why this used to lend it a scratch paragraph to wrap. Worth knowing
6257        // here because the line-for-line caret mapping below cannot describe it:
6258        // opening one under a paragraph writes the blank line the format needs
6259        // above the marker too, so the rewritten region has a line the old one
6260        // didn't, and "the same line, the same distance from its end" lands on
6261        // that new blank instead of in the container.
6262        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
6263        // Without a selection the target is the caret's own block, resolved the
6264        // way `set_block` resolves it — a caret at a line end sits at the doc
6265        // level and has to be nudged back onto the block it looks like it's in.
6266        // An empty range is enough: twig widens to the whole lines it touches.
6267        let (start, end) = match selected {
6268            Some(range) => range,
6269            None => {
6270                let off = self.block_offset_for_caret().unwrap_or(self.caret);
6271                (off, off)
6272            }
6273        };
6274        self.record_caret();
6275        match self.editor.toggle_block_container(start, end, kind) {
6276            Ok(change) => {
6277                // Read the caret's place out of the *pre-edit* source, before
6278                // `refresh` swaps that source out from under it.
6279                let place = (selected.is_none() && !opened_empty)
6280                    .then(|| self.caret_line_tail(&change.old));
6281                self.last_edit_kind = None; // structural edit is its own undo step
6282                self.refresh();
6283                match place {
6284                    // Both land the caret at the far end of what twig wrote, and
6285                    // differ only in what they leave selected.
6286                    //
6287                    // From a selection: select what the container now holds, the
6288                    // way `toggle` keeps its marked region selected — and for a
6289                    // stronger reason than symmetry: a container comes *off* only
6290                    // a range covering every block it holds, so a selection left
6291                    // on its old bytes (now short by a prefix per line) would nest
6292                    // on the second press instead of reversing the first.
6293                    //
6294                    // From a blank line: nothing to select, and the end of the
6295                    // region is exactly past the bare `> ` / `- ` twig wrote —
6296                    // the caret standing inside the container that was asked for.
6297                    None => {
6298                        self.anchor = (!opened_empty).then_some(change.new.start);
6299                        self.caret = change.new.end;
6300                    }
6301                    Some(place) => {
6302                        self.anchor = None;
6303                        self.caret = self.line_tail_offset(&change.new, place);
6304                    }
6305                }
6306                self.dirty = self.source != self.clean_source;
6307                self.status = None;
6308                self.clamp_caret();
6309                self.record_caret();
6310            }
6311            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6312        }
6313    }
6314
6315    /// The caret's place inside the region a container toggle is rewriting, in
6316    /// the only terms the rewrite preserves: which of the region's lines it sits
6317    /// on, and how many bytes of that line lie ahead of it.
6318    ///
6319    /// A container's markup goes in at column 0 and never touches what follows
6320    /// on the line, so that pair survives the edit exactly where a byte offset
6321    /// does not — a caret left on its old offset slides back by one prefix per
6322    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
6323    /// `> ` it just asked for.
6324    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
6325        let caret = self.caret.clamp(old.start, old.end);
6326        let line = self.source[old.start..caret].matches('\n').count();
6327        let end = self.source[caret..old.end]
6328            .find('\n')
6329            .map_or(old.end, |i| caret + i);
6330        (line, end - caret)
6331    }
6332
6333    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
6334    /// region: the offset `tail` bytes back from the end of the region's `line`.
6335    ///
6336    /// Both walks are clamped rather than trusted, because the one op that does
6337    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
6338    /// the items back apart with blank lines between them — and a caret landing
6339    /// on the nearest line of the right item beats one landing out of the region
6340    /// entirely.
6341    fn line_tail_offset(
6342        &self,
6343        new: &std::ops::Range<usize>,
6344        (line, tail): (usize, usize),
6345    ) -> usize {
6346        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
6347        let mut start = 0;
6348        for _ in 0..line {
6349            match region[start..].find('\n') {
6350                Some(i) => start += i + 1,
6351                None => break,
6352            }
6353        }
6354        let end = region[start..]
6355            .find('\n')
6356            .map_or(region.len(), |i| start + i);
6357        new.start + end.saturating_sub(tail).max(start)
6358    }
6359
6360    /// Link the selection to `destination` — the toolbar's Link button. With no
6361    /// selection it acts at the caret, which re-points a link the caret is
6362    /// already standing in (twig replaces an existing link's destination and
6363    /// keeps its text) and otherwise spells a link that has no text of its own:
6364    /// an autolink (`<https://x.dev>`) where the destination is one, and
6365    /// `[destination](destination)` where it isn't.
6366    ///
6367    /// `destination` reaches twig raw. Escaping it is format knowledge and the
6368    /// two formats genuinely disagree — Markdown ends a destination at the first
6369    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
6370    /// itself — so the side holding the document is the side that gets to spell
6371    /// it. A destination twig can't carry at all (one with a newline) comes back
6372    /// as an error rather than a quietly rewritten URL.
6373    pub fn insert_link(&mut self, destination: &str) {
6374        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6375            return;
6376        }
6377        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6378        self.record_caret();
6379        match self.editor.insert_link(start, end, destination) {
6380            Ok(change) => {
6381                self.last_edit_kind = None;
6382                self.refresh();
6383                match self.link_text_span(change.new.start) {
6384                    // A link with text of its own: select it, so typing replaces
6385                    // a `[dest](dest)`'s stand-in label and a second press
6386                    // re-points what the first one linked.
6387                    Some(text) => {
6388                        self.anchor = (text.start != text.end).then_some(text.start);
6389                        self.caret = text.end;
6390                    }
6391                    // An autolink is finished the moment it's written — its text
6392                    // *is* the URL. Leaving it selected would aim the next press
6393                    // at the one shape twig still wraps instead of re-points.
6394                    None => {
6395                        self.anchor = None;
6396                        self.caret = change.new.end;
6397                    }
6398                }
6399                self.dirty = self.source != self.clean_source;
6400                self.status = None;
6401                self.clamp_caret();
6402                self.record_caret();
6403            }
6404            Err(e) => self.status = Some(format!("link: {e}")),
6405        }
6406    }
6407
6408    /// Insert a block-level image at the caret: `![alt](destination)`. Any
6409    /// selection becomes the alt text (so "select a caption, insert image" labels
6410    /// it); with no selection, `alt` is used — empty for none. The caret lands
6411    /// just past the inserted image.
6412    ///
6413    /// Both halves go through twig (`insert_literal` for the alt text,
6414    /// `insert_image` for the image), so neither is spelled here. That used to be a
6415    /// `format!`, and it was wrong the first time an app inserted a real filename:
6416    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
6417    /// not an image at all — and the fix is per-format, since moving into the
6418    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
6419    pub fn insert_image(&mut self, destination: &str, alt: &str) {
6420        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
6421            return;
6422        }
6423        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6424        self.record_caret();
6425        // With no selection and an explicit `alt`, the alt text has to exist in the
6426        // document before it can be the image's — and it is raw caller input, so
6427        // it goes in through `insert_literal`, which escapes it for the format
6428        // rather than letting a `]` in someone's caption close the image early.
6429        let (start, end) = if start == end && !alt.is_empty() {
6430            match self.editor.insert_literal(start, alt) {
6431                Ok(change) => (change.new.start, change.new.end),
6432                Err(e) => {
6433                    self.status = Some(format!("image: {e}"));
6434                    return;
6435                }
6436            }
6437        } else {
6438            (start, end)
6439        };
6440        match self.editor.insert_image(start, end, destination) {
6441            Ok(change) => {
6442                self.last_edit_kind = None;
6443                self.refresh();
6444                // Just past the image, nothing selected — where a caret belongs
6445                // after inserting one.
6446                self.anchor = None;
6447                self.caret = change.new.end;
6448                self.dirty = self.source != self.clean_source;
6449                self.status = None;
6450                self.clamp_caret();
6451                self.record_caret();
6452            }
6453            Err(e) => self.status = Some(format!("image: {e}")),
6454        }
6455    }
6456
6457    /// Insert a block-level image, video, or audio at the caret. The image case
6458    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
6459    /// HTML elements, which is the only spelling Markdown and Djot have for them:
6460    ///
6461    /// ```text
6462    /// <video src="clip.mp4" controls>alt</video>
6463    /// <audio src="take.mp3" controls>alt</audio>
6464    /// ```
6465    ///
6466    /// HTML rather than a `::video{…}` directive deliberately. A directive means
6467    /// something only to an app that knows the vocabulary, so the document would
6468    /// read as literal punctuation everywhere else; `<video>` is what every other
6469    /// renderer already understands, and what leaf's own reader picks back up
6470    /// through `html_elements` promotion (see [`parse_extensions`]).
6471    ///
6472    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
6473    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
6474    /// `html_elements`. Before that only the multi-line form parsed as a block at
6475    /// all, and this wrote three lines to work around it.
6476    ///
6477    /// `controls` is always written: a player with no transport is a still frame
6478    /// the reader can't do anything with. Any selection becomes the element's
6479    /// fallback text, exactly as it becomes an image's alt.
6480    ///
6481    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
6482    /// applies, and bites harder here: a `"` in `destination` closes the
6483    /// attribute. A frontend taking these from a file picker is fine; one taking
6484    /// them from free text should keep them tame.
6485    ///
6486    /// [`MediaInfo`]: crate::MediaInfo
6487    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6488        if kind == MediaKind::Image {
6489            return self.insert_image(destination, alt);
6490        }
6491        // Gated on the *image* gesture, not on one of its own — there isn't one,
6492        // since the bytes below are spelled here rather than by twig, and an HTML
6493        // document would in fact parse them. The button is one control with three
6494        // kinds behind it, and two of them working in a format where the third
6495        // cannot is a worse surface than three that agree — especially as
6496        // `insert_image` is the kind anyone reaches for first.
6497        if self.refuse_unsupported("media", Gesture::InsertImage) {
6498            return;
6499        }
6500        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6501        let alt_text = self
6502            .selected_text()
6503            .map(str::to_string)
6504            .unwrap_or_else(|| alt.to_string());
6505        let tag = match kind {
6506            MediaKind::Audio => "audio",
6507            _ => "video",
6508        };
6509        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
6510        self.edit(start, end, &markup);
6511    }
6512
6513    /// Append media at the **end** of the document, as a block of its own —
6514    /// the verb for a picture that *arrives* rather than one the writer
6515    /// places: an attachment imported while the caret was wherever it last
6516    /// was, a drawing placed from a tray under the body. At the caret it
6517    /// would land inline in front of whatever word the caret happened to be
6518    /// beside, which for an editor nobody has tapped yet is the first word
6519    /// of the document.
6520    ///
6521    /// The document is ended with a blank line first, where it does not
6522    /// already end with one, so the media is a paragraph of its own rather
6523    /// than a lazy continuation of the last one; an empty document needs no
6524    /// separator. Then [`insert_media`](Self::insert_media) at the new end,
6525    /// with everything that means: the same markup per kind, the same
6526    /// refusal on a format without images. The selection is dropped — the
6527    /// verb is about the end of the document, not about what was selected —
6528    /// and the caret is left past the media, as `insert_media` leaves it.
6529    pub fn append_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6530        if self.read_only || self.refuse_unsupported("media", Gesture::InsertImage) {
6531            return;
6532        }
6533        let separator = if self.source.is_empty() || self.source.ends_with("\n\n") {
6534            ""
6535        } else if self.source.ends_with('\n') {
6536            "\n"
6537        } else {
6538            "\n\n"
6539        };
6540        let end = self.source.len();
6541        self.anchor = None;
6542        self.caret = end;
6543        if !separator.is_empty() {
6544            self.edit(end, end, separator);
6545        }
6546        self.anchor = None;
6547        self.caret = self.source.len();
6548        self.insert_media(kind, destination, alt);
6549    }
6550
6551    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
6552    /// button. Spelling and placement are both twig's; leaf used to write `---`
6553    /// itself, which was the Markdown spelling in a djot document too.
6554    ///
6555    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
6556    /// mid-paragraph and lands it after the caret's whole block. To get a rule
6557    /// *at* the caret — the paragraph parted in two around it, which is what a
6558    /// rule button is understood to do — the paragraph is first divided with
6559    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
6560    /// the offset `split_block` returns puts the rule after the *second* half
6561    /// instead, which is a rule in the right document and the wrong place.
6562    ///
6563    /// Only a plain paragraph is split, and only where there is something to
6564    /// part: at the paragraph's end the split has no second half to mint and
6565    /// would write the separator anyway — a blank line and the empty slot Enter
6566    /// leaves for the next paragraph, which the rule then lands above and
6567    /// nothing fills — so there the rule goes straight after the paragraph,
6568    /// which is where the split-and-aim was sending it regardless. At the
6569    /// paragraph's *start* the split is kept, though it parts nothing either:
6570    /// `|para` becomes `\npara` with the caret on the new blank line, and a
6571    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
6572    /// "before the paragraph" is said through a gesture that only knows
6573    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
6574    /// else the rule simply lands after the block, which is both twig's own
6575    /// answer and the better one: splitting a fenced code block would leave two
6576    /// fences with a rule between them, and splitting a list item would mint an
6577    /// item nobody asked for on the way to a rule that lands after the list
6578    /// regardless. A table and a setext heading refuse the split outright, so
6579    /// they take the same path by themselves.
6580    pub fn insert_thematic_break(&mut self) {
6581        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
6582        {
6583            return;
6584        }
6585        self.caret = self.skip_trailing_close_delims(self.caret);
6586        // A selection is replaced by the rule, so collapse it first and let the
6587        // split-and-rule below run from the caret it leaves behind.
6588        if let Some((s, e)) = self.selection() {
6589            self.splice(s, e, "", EditKind::Other);
6590        }
6591        self.anchor = None;
6592        self.record_caret();
6593        let at = self.caret;
6594        if self.caret_parts_bare_paragraph() {
6595            // A failure here is not fatal: the rule still lands after the block,
6596            // which is exactly what this call was trying to improve on.
6597            let _ = self.editor.split_block(at);
6598        }
6599        match self.editor.insert_thematic_break(at) {
6600            Ok(change) => {
6601                self.last_edit_kind = None;
6602                self.refresh();
6603                self.anchor = None;
6604                self.caret = change.new.end;
6605                self.dirty = self.source != self.clean_source;
6606                self.status = None;
6607                self.clamp_caret();
6608                self.record_caret();
6609            }
6610            Err(e) => self.status = Some(format!("thematic break: {e}")),
6611        }
6612    }
6613
6614    /// Insert a fresh table at the caret — the toolbar's Table button. One
6615    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
6616    /// the document's own dialect and placed the way its thematic break is:
6617    /// after the caret's block, blank-separated. A bare paragraph is parted
6618    /// around the caret first, exactly as
6619    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
6620    /// table lands *at* the caret rather than after everything the caret's
6621    /// paragraph says.
6622    ///
6623    /// The caret ends in the first header cell, selected the way Tab selects
6624    /// a cell — the natural next act is to type the heading, and Tab then
6625    /// walks the grid. That cell is read back from the rebuilt table map
6626    /// rather than computed from the splice, because twig's blank line and
6627    /// quote prefix put the first bar at an offset only the reparse knows.
6628    ///
6629    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
6630    /// rows or columns is twig's refusal (a header with nothing under it is
6631    /// what its row delete refuses to leave), reported through `status`.
6632    pub fn insert_table(&mut self, rows: usize, cols: usize) {
6633        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
6634            return;
6635        }
6636        self.caret = self.skip_trailing_close_delims(self.caret);
6637        if let Some((s, e)) = self.selection() {
6638            self.splice(s, e, "", EditKind::Other);
6639        }
6640        self.anchor = None;
6641        self.record_caret();
6642        let at = self.caret;
6643        if self.caret_parts_bare_paragraph() {
6644            let _ = self.editor.split_block(at);
6645        }
6646        match self.editor.insert_table(at, rows, cols) {
6647            Ok(change) => {
6648                self.last_edit_kind = None;
6649                self.refresh();
6650                self.anchor = None;
6651                self.caret = change.new.end;
6652                self.dirty = self.source != self.clean_source;
6653                self.status = None;
6654                self.clamp_caret();
6655                // Into the first header cell of the table just written: the
6656                // first table whose grid begins inside the splice.
6657                self.rebuild_map();
6658                let first_cell = self
6659                    .vmap
6660                    .tables
6661                    .iter()
6662                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
6663                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
6664                    .map(|cell| (cell.start, cell.end));
6665                if let Some((start, end)) = first_cell {
6666                    self.select_cell(start, end);
6667                }
6668                self.record_caret();
6669            }
6670            Err(e) => self.status = Some(format!("table: {e}")),
6671        }
6672    }
6673
6674    /// Whether the caret sits in a paragraph and nothing else — no list item, no
6675    /// quote, no fence, no table — with paragraph text still ahead of it. The
6676    /// one shape where parting the block around the caret is unambiguously what
6677    /// a rule button means; see
6678    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
6679    /// container is left to take the rule after itself.
6680    ///
6681    /// The "text ahead" half is what keeps `split_block` from running at the
6682    /// one edge where its output composes badly. At a paragraph's end twig
6683    /// cannot mint the empty second half (no format spells an empty
6684    /// paragraph), so it writes only the separator — a blank line and the
6685    /// slot Enter leaves for the paragraph to come — and a block then aimed at
6686    /// the first half lands above a slot that nothing fills: `para\n` with the
6687    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
6688    /// as nothing ahead, since the split would shed it as the second half's
6689    /// leading indent and leave the same slot. Which end of the newline a
6690    /// paragraph's span stops at differs between the formats (Markdown before
6691    /// it, djot after), which is why this reads the remaining bytes rather
6692    /// than comparing offsets. The paragraph's
6693    /// start is deliberately not the same case — see
6694    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
6695    /// split is kept.
6696    fn caret_parts_bare_paragraph(&mut self) -> bool {
6697        let caret = self.caret.min(self.source.len());
6698        let Ok(chain) = self.editor.ancestors_at(caret) else {
6699            return false;
6700        };
6701        let mut para_end = None;
6702        for m in chain {
6703            match m.kind {
6704                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
6705                Kind::ListItem
6706                | Kind::TaskListItem
6707                | Kind::BlockQuote
6708                | Kind::CodeBlock
6709                | Kind::Table => return false,
6710                _ => {}
6711            }
6712        }
6713        match para_end {
6714            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
6715            _ => false,
6716        }
6717    }
6718
6719    /// The destination of the link under the caret — what a Link prompt shows so
6720    /// ⌘K on an existing link edits its URL instead of asking for it again.
6721    /// `None` when the caret stands in no link.
6722    ///
6723    /// An autolink carries no separate destination: its text *is* the URL, so
6724    /// that's what comes back for one.
6725    pub fn link_destination_at_caret(&mut self) -> Option<String> {
6726        self.link_destination_at(self.caret)
6727    }
6728
6729    /// The destination of the link at `off`.
6730    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
6731    /// the caret isn't.
6732    ///
6733    /// The offset form exists for the same reason
6734    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
6735    /// the document somewhere else — a footnote's text in a popover, say — has
6736    /// rows and runs but no caret in them, and still needs to know which of those
6737    /// runs a reader can follow.
6738    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
6739        self.nodes()
6740            .into_iter()
6741            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
6742            .filter(|n| n.span.start <= off && off < n.span.end)
6743            .max_by_key(|n| n.span.start)
6744            .and_then(|n| n.destination.or(n.text))
6745    }
6746
6747    /// The heading the caret is under — the nearest heading at or above it,
6748    /// whatever its level. See [`heading_at`](Self::heading_at).
6749    pub fn heading_at_caret(&mut self) -> Option<Heading> {
6750        self.heading_at(self.caret)
6751    }
6752
6753    /// The heading `off` is under: the last heading that starts at or before
6754    /// it, or the one `off` stands in. `None` above the first heading.
6755    ///
6756    /// The question between [`link_destination_at`](Self::link_destination_at)
6757    /// ("which link is this") and [`locate`](Self::locate) ("where does this
6758    /// fragment land"): a host writing a link *to* a place needs the heading
6759    /// that place is under, to name it by `#slug`. Nearest, not enclosing —
6760    /// a level-3 heading under a level-2 is the answer for the text below the
6761    /// level-3, because it is the finer place to land.
6762    pub fn heading_at(&mut self, off: usize) -> Option<Heading> {
6763        let nodes = self.nodes();
6764        let h = nodes
6765            .iter()
6766            .filter(|n| n.kind == Kind::Heading && n.span.start <= off)
6767            .max_by_key(|n| n.span.start)?;
6768        let mut text = String::new();
6769        inline_text(&nodes, h.first_child, &mut text);
6770        Some(Heading {
6771            text: text.trim().to_string(),
6772            level: h.level.unwrap_or(1),
6773            span: h.span.clone(),
6774        })
6775    }
6776
6777    /// Where the locator `id` lands in this document — the `#v2` half of a
6778    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
6779    /// answers to it.
6780    ///
6781    /// The other end of a link, and the reason this exists: without it a
6782    /// destination has only file granularity, so following a citation into a
6783    /// chapter drops the reader at the top of it to hunt for the verse. Which is
6784    /// also why it is a *document* query rather than a caret one — the document
6785    /// being asked is usually not the one the reader is in.
6786    ///
6787    /// Three readings, tried in order, because the same `#some-heading` is
6788    /// written three ways across the formats leaf opens:
6789    ///
6790    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
6791    ///    the auto-ids djot mints for its headings. The only exact answer, so it
6792    ///    goes first — a document that says `{#v1}` has settled the question.
6793    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
6794    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
6795    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
6796    ///    authored anywhere land on a djot heading.
6797    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
6798    ///    none and `{#custom}` is literal text in a Markdown heading — so for
6799    ///    the format most vaults are written in, the heading's own words are the
6800    ///    only thing a fragment can name. This is the rule every Markdown
6801    ///    renderer already follows, which is what makes `#a-heading` mean in
6802    ///    diaryx what it means on the web.
6803    ///
6804    /// Ties go to the earliest match, then to the widest: a duplicated id is the
6805    /// document's mistake and the first one is the answer every anchor
6806    /// implementation gives, while preferring the wider span picks the section
6807    /// over the heading that opens it — more for a peek to show, same place to
6808    /// land.
6809    pub fn locate(&mut self, id: &str) -> Option<Landing> {
6810        let id = id.trim();
6811        if id.is_empty() {
6812            return None;
6813        }
6814        let nodes = self.nodes();
6815
6816        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
6817        // is picking, among nodes that start together, the one that ends last.
6818        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
6819            matches
6820                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
6821                .map(|n| Landing {
6822                    start: n.span.start,
6823                    end: n.span.end,
6824                })
6825        };
6826
6827        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
6828            return Some(landing);
6829        }
6830        let want = slug(id);
6831        if want.is_empty() {
6832            return None;
6833        }
6834        if let Some(landing) = pick(
6835            &mut nodes
6836                .iter()
6837                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
6838        ) {
6839            return Some(landing);
6840        }
6841
6842        // A heading by its words. Its span is one line, so the end comes from
6843        // where the *section* it opens gives out — the next heading that is not
6844        // under it, or the end of the document. A Markdown heading has no
6845        // section node to ask (twig only builds those for djot), and a peek that
6846        // showed the heading alone would answer "what does that say" with the
6847        // title of the thing it says.
6848        let heading = nodes
6849            .iter()
6850            .filter(|n| n.kind == Kind::Heading)
6851            .filter(|n| {
6852                n.content_span
6853                    .clone()
6854                    .and_then(|s| self.source.get(s))
6855                    .is_some_and(|text| slug(text) == want)
6856            })
6857            .min_by_key(|n| n.span.start)?;
6858        let level = heading.level.unwrap_or(u32::MAX);
6859        let end = nodes
6860            .iter()
6861            .filter(|n| n.kind == Kind::Heading)
6862            .filter(|n| n.span.start > heading.span.start)
6863            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
6864            .map(|n| n.span.start)
6865            .min()
6866            .unwrap_or(self.source.len());
6867        Some(Landing {
6868            start: heading.span.start,
6869            end,
6870        })
6871    }
6872
6873    /// Write a footnote at the caret — the toolbar's Footnote button, and the
6874    /// one gesture in the footnote story that *authors* rather than follows.
6875    ///
6876    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6877    /// the `[^1]:` definition at the end of the document. Half a footnote is not
6878    /// a footnote — a bare reference with nothing defining it renders as literal
6879    /// brackets — so a single button that wrote only the reference would leave
6880    /// the author to hand-spell the other half in a document that had just
6881    /// stopped showing them what the first half meant. One edit also means one
6882    /// undo takes both back.
6883    ///
6884    /// The definition's body is left empty and **the caret lands in it**, which
6885    /// is the whole point of pressing the button: nobody wants a reference to a
6886    /// note they have not written yet. Getting back to where they were writing
6887    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6888    /// the same return leg a reader following a reference already uses, so the
6889    /// author is left standing on the near end of a round trip that works.
6890    ///
6891    /// A selection collapses to its *end* rather than being replaced: a
6892    /// reference annotates the words before it, so "select the claim, add a
6893    /// footnote" should mark that claim, not consume it.
6894    pub fn insert_footnote(&mut self) {
6895        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6896            return;
6897        }
6898        let at = self.selection().map_or(self.caret, |(_, end)| end);
6899        self.anchor = None;
6900        self.caret = at;
6901        self.record_caret();
6902        let label = self.next_footnote_label();
6903        match self.editor.insert_footnote(at, &label) {
6904            Ok(change) => {
6905                self.last_edit_kind = None;
6906                self.refresh();
6907                self.anchor = None;
6908                // `change.new` runs from the reference to the end of the
6909                // document, so its start is the `[^1]` just written and
6910                // `footnote_at` resolves it to the note the same way a reader's
6911                // tap does — and to the note's *body*, which is already a caret
6912                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6913                // and has none), so this needs no snap on top. The fallback is
6914                // the reference's own offset: a format that spelled the pair some
6915                // way leaf can't read back should still leave the caret on the
6916                // edit rather than at the far end of a document it just grew.
6917                self.caret = self
6918                    .footnote_at(change.new.start)
6919                    .and_then(|note| note.offset)
6920                    .unwrap_or(change.new.start);
6921                self.dirty = self.source != self.clean_source;
6922                self.status = None;
6923                self.clamp_caret();
6924                self.record_caret();
6925            }
6926            Err(e) => self.status = Some(format!("footnote: {e}")),
6927        }
6928    }
6929
6930    /// The label to give a footnote the author has not named: the lowest counting
6931    /// number no footnote in the document is already wearing.
6932    ///
6933    /// twig takes the label rather than minting one, because it holds no opinion
6934    /// about what a document's footnotes should be called — and it is right not
6935    /// to. Numbering them is what every author of a numbered note expects, and
6936    /// re-using a taken number would silently point the new reference at somebody
6937    /// else's note (twig reuses an existing definition rather than appending a
6938    /// second one, which is the right rule for citing a note twice on purpose and
6939    /// exactly the wrong accident to have by default).
6940    ///
6941    /// *References* are counted alongside definitions, not just definitions: a
6942    /// document carrying a dangling `[^2]` has a 2 that means something to
6943    /// whoever wrote it, and minting a definition for it here would answer a
6944    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6945    /// count entirely — they take no number, so they block none.
6946    fn next_footnote_label(&mut self) -> String {
6947        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6948            .into_iter()
6949            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6950            .filter_map(|label| label.parse().ok())
6951            .collect();
6952        taken.extend(
6953            self.nodes()
6954                .into_iter()
6955                .filter(|n| n.kind == Kind::FootnoteReference)
6956                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6957                .filter_map(|label| label.parse::<u32>().ok()),
6958        );
6959        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6960    }
6961
6962    /// The footnote reference under the caret, resolved to the note it names.
6963    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6964    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6965        self.footnote_at(self.caret)
6966    }
6967
6968    /// The footnote reference at `off`, resolved to the note it names — what a
6969    /// frontend shows when a reader activates a `[^1]`.
6970    ///
6971    /// A reference is not a link node, so
6972    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6973    /// (and should not) answer for one: a link names a destination to leave for,
6974    /// a reference names a note that is already in this document. Following one
6975    /// is a move within the page, which is why this hands back an `offset`
6976    /// rather than something to open.
6977    ///
6978    /// Offset-based rather than caret-only because the gesture that wants this
6979    /// most is the one that must not move the caret: a pointer hovering a `[1]`
6980    /// asks what note it names without disturbing where the reader was typing.
6981    /// The caret is just the offset a click already placed —
6982    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6983    ///
6984    /// `None` when `off` stands in no reference. A reference whose note the
6985    /// document never defines is *not* `None` — it answers with the label it
6986    /// looked for and no text, which is what lets a frontend say so instead of
6987    /// silently doing nothing.
6988    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6989        // Innermost-wins by latest start, the rule its link sibling uses.
6990        let span = self
6991            .nodes()
6992            .into_iter()
6993            .filter(|n| n.kind == Kind::FootnoteReference)
6994            .filter(|n| n.span.start <= off && off < n.span.end)
6995            .max_by_key(|n| n.span.start)?
6996            .span;
6997        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6998
6999        // The note itself. Definitions are roots beside `doc` rather than
7000        // children of it, so they're asked for directly — see
7001        // `wysiwyg::footnote_definitions`.
7002        let note = wysiwyg::footnote_definitions(&mut self.editor)
7003            .into_iter()
7004            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
7005        let Some(note) = note else {
7006            return Some(FootnoteRef {
7007                label,
7008                text: None,
7009                offset: None,
7010                end: None,
7011            });
7012        };
7013        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
7014        Some(FootnoteRef {
7015            label,
7016            text: body
7017                .clone()
7018                .and_then(|b| self.source.get(b))
7019                .map(str::to_string),
7020            // The body's start, not the definition's — see `FootnoteRef::offset`.
7021            offset: body.clone().map(|b| b.start),
7022            end: body.map(|b| b.end),
7023        })
7024    }
7025
7026    /// The footnote *definition* the caret stands in, and where the reference
7027    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
7028    /// at the caret's offset.
7029    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
7030        self.footnote_definition_at(self.caret)
7031    }
7032
7033    /// The footnote definition spanning `off`, and where the reference that
7034    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
7035    ///
7036    /// The mirror image, deliberately: the same gesture that takes a reader from
7037    /// `[1]` down to the note takes them from the note back up to `[1]`, so
7038    /// following a footnote is a round trip rather than a fall. It needs no
7039    /// memory of how the reader arrived — the document says where the reference
7040    /// is — which is what makes it work for a reader who scrolled to the notes
7041    /// themselves, and what keeps it right after an edit moves either end.
7042    ///
7043    /// `None` when `off` stands in no definition. A definition nothing cites is
7044    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
7045    /// its label and no offset, so a frontend can say "nothing refers to this"
7046    /// rather than offer a jump that goes nowhere.
7047    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
7048        // Definitions are roots beside `doc`, so `nodes()` — which walks the
7049        // document body — never reports one. They're asked for directly, the way
7050        // `footnote_at` asks for the note it resolves to.
7051        //
7052        // Closed at the end, unlike the half-open test its neighbours use. A
7053        // definition's span stops at its last content byte — the newline ending
7054        // the line is outside it — so `span.end` is the caret stop at the end of
7055        // the note's own row, not the first byte of anything after. Excluding it
7056        // meant the one caret an author is guaranteed to have, the one left
7057        // sitting at the end of the note they just typed, was in no definition at
7058        // all: writing a note and then asking to go back to its reference
7059        // answered nothing. Two definitions in a row still can't both match —
7060        // there is a blank line between them — and `max_by_key` decides anyway.
7061        let note = wysiwyg::footnote_definitions(&mut self.editor)
7062            .into_iter()
7063            .filter(|m| m.span.start <= off && off <= m.span.end)
7064            .max_by_key(|m| m.span.start)?;
7065        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
7066
7067        // The earliest reference carrying this label. `min` rather than a `find`,
7068        // because `nodes()` reports a flattened walk whose order is twig's
7069        // business, not document order. Bound first: the walk needs `&mut self`
7070        // and reading the labels back out needs `&self.source`.
7071        let nodes = self.nodes();
7072        let offset = nodes
7073            .into_iter()
7074            .filter(|n| n.kind == Kind::FootnoteReference)
7075            .filter(|n| {
7076                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
7077            })
7078            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
7079            .map(|n| n.span.start + 2)
7080            .min();
7081        Some(FootnoteDef { label, offset })
7082    }
7083
7084    /// The destination of the image under the caret — what an image prompt shows
7085    /// so editing an existing image starts from its current URL instead of blank,
7086    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
7087    /// `None` when the caret stands in no image. A caret resting just after a
7088    /// block image (its trailing stop) is still "in" it — the half-open span test
7089    /// excludes that offset, which is the intended precision: past the image is
7090    /// past it.
7091    pub fn image_destination_at_caret(&mut self) -> Option<String> {
7092        let off = self.caret;
7093        self.nodes()
7094            .into_iter()
7095            .filter(|n| n.kind == Kind::Image)
7096            .filter(|n| n.span.start <= off && off < n.span.end)
7097            .max_by_key(|n| n.span.start)
7098            .and_then(|n| n.destination)
7099    }
7100
7101    /// The language of the fenced code block the caret stands in — what a
7102    /// language prompt shows so editing it starts from the current value rather
7103    /// than blank. `None` when the caret is in no code block, or in one whose
7104    /// fence carries no language (or an indented block, which has no fence).
7105    pub fn code_language_at_caret(&mut self) -> Option<String> {
7106        let start = self.code_block_start_at_caret()?;
7107        wysiwyg::code_language(&self.source, start)
7108    }
7109
7110    /// Whether the caret stands in a fenced code block — the one a language
7111    /// prompt could edit. A frontend gates its "set language" affordance on this
7112    /// (an indented block, which can't carry a language, reports `false`).
7113    pub fn caret_in_fenced_code(&mut self) -> bool {
7114        self.code_block_start_at_caret()
7115            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
7116    }
7117
7118    /// Set (or clear, with `""`) the language of the fenced code block the caret
7119    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
7120    /// block, and a reported error for a language the format's fence cannot
7121    /// carry.
7122    ///
7123    /// twig rewrites the info string, so the fence's own width — measured
7124    /// against a body neither side touches — is kept, and a language holding a
7125    /// space, a line end or the fence character is refused rather than written
7126    /// out to reparse as something else. Leaf used to splice over the info span
7127    /// itself and `trim()` the input, which handled the one bad case it had
7128    /// thought of.
7129    pub fn set_code_language(&mut self, lang: &str) {
7130        // The read-only gate — this door reaches twig without the splice.
7131        if self.read_only {
7132            return;
7133        }
7134        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
7135            return;
7136        }
7137        if self.code_block_start_at_caret().is_none() {
7138            return;
7139        }
7140        let lang = lang.trim();
7141        // `None` clears the info string; `Some("")` asks for an empty one. Both
7142        // write a bare fence, and the prompt's empty value means "clear".
7143        let want = (!lang.is_empty()).then_some(lang);
7144        self.record_caret();
7145        match self.editor.set_code_language(self.caret, want) {
7146            Ok(_) => {
7147                self.last_edit_kind = None;
7148                self.refresh();
7149                self.anchor = None;
7150                self.dirty = self.source != self.clean_source;
7151                self.status = None;
7152                self.clamp_caret();
7153                self.record_caret();
7154            }
7155            Err(e) => self.status = Some(format!("code language: {e}")),
7156        }
7157    }
7158
7159    /// The `span.start` of the code block covering the caret — the anchor
7160    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
7161    /// in none.
7162    fn code_block_start_at_caret(&mut self) -> Option<usize> {
7163        let off = self.caret;
7164        self.nodes()
7165            .into_iter()
7166            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
7167            .max_by_key(|n| n.span.start)
7168            .map(|n| n.span.start)
7169    }
7170
7171    /// The source range of the text inside the link covering `off` — what sits
7172    /// between its `[` and `]`. `None` when twig reports no link there.
7173    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
7174        self.nodes()
7175            .into_iter()
7176            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
7177            // the other's `span.start`; the link that starts latest at or before
7178            // `off` is the one `off` is actually in.
7179            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
7180            .max_by_key(|n| n.span.start)
7181            .and_then(|n| n.content_span)
7182    }
7183
7184    // ── undo / redo ───────────────────────────────────────────────────────────
7185    // twig owns the history of *bytes* (it owns the buffer) and now carries the
7186    // caret through it too: `record_caret` stashes each state's caret in twig's
7187    // opaque per-step blob, and undo/redo hand it back with the source they
7188    // restore. So leaf keeps no history of its own — no parallel stacks to march
7189    // in lockstep and silently drift out of it.
7190
7191    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
7192    /// where they were when that step began. Whether it did: `false` when
7193    /// there was nothing to undo, the document is read-only, or twig refused —
7194    /// the answer a host composing several histories into one walks on by.
7195    pub fn undo(&mut self) -> bool {
7196        if self.read_only {
7197            return false;
7198        }
7199        // Stepping through the history ends a group: what comes after is not
7200        // part of the step just taken back.
7201        self.close_undo_group();
7202        let (undone, redoable) = (self.undo_steps, self.redo_steps);
7203        match self.editor.undo() {
7204            Ok(Some(change)) => {
7205                self.after_history(change);
7206                // `refresh` counted the restore as an edit; it was a step back.
7207                self.undo_steps = undone.saturating_sub(1);
7208                self.redo_steps = redoable + 1;
7209                true
7210            }
7211            Ok(None) => {
7212                self.undo_steps = 0;
7213                self.status = Some("nothing to undo".into());
7214                false
7215            }
7216            Err(e) => {
7217                self.status = Some(format!("undo: {e}"));
7218                false
7219            }
7220        }
7221    }
7222
7223    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
7224    /// selection back where that step originally left them. Whether it did,
7225    /// as [`undo`](Self::undo) reports.
7226    pub fn redo(&mut self) -> bool {
7227        if self.read_only {
7228            return false;
7229        }
7230        self.close_undo_group();
7231        let (undone, redoable) = (self.undo_steps, self.redo_steps);
7232        match self.editor.redo() {
7233            Ok(Some(change)) => {
7234                self.after_history(change);
7235                // `refresh` counted the restore as an edit; it was a step forward.
7236                self.undo_steps = (undone + 1).min(UNDO_CAP);
7237                self.redo_steps = redoable.saturating_sub(1);
7238                true
7239            }
7240            Ok(None) => {
7241                self.redo_steps = 0;
7242                self.status = Some("nothing to redo".into());
7243                false
7244            }
7245            Err(e) => {
7246                self.status = Some(format!("redo: {e}"));
7247                false
7248            }
7249        }
7250    }
7251
7252    /// Fold the edit just made into the undo step before it — twig's
7253    /// `coalesce_last_undo`, with the `undo_steps` mirror following
7254    /// it. twig merges only when there are two steps to merge. Call it after
7255    /// [`refresh`](Self::refresh) has counted the edit being folded.
7256    ///
7257    /// Inside an [undo group](Self::begin_undo_group) it does nothing: the
7258    /// group's edits are already one step, which [`refresh`](Self::refresh)
7259    /// folds as they come, so a fold here could only reach across the group's
7260    /// start into the step before it.
7261    fn coalesce_last_undo(&mut self) {
7262        if self.undo_group.is_some() {
7263            return;
7264        }
7265        self.fold_last_undo();
7266    }
7267
7268    /// twig's `coalesce_last_undo`, unconditionally, with the mirror following.
7269    fn fold_last_undo(&mut self) {
7270        if self.editor.coalesce_last_undo().is_ok() && self.undo_steps >= 2 {
7271            self.undo_steps -= 1;
7272        }
7273    }
7274
7275    /// Open an undo group: every edit from here to the matching
7276    /// [`end_undo_group`](Self::end_undo_group) is one step, which a single
7277    /// [`undo`](Self::undo) takes back whole and a single [`redo`](Self::redo)
7278    /// puts back — a Replace All over twelve matches, or a Writing Tools
7279    /// session, rather than twelve presses of ⌘Z.
7280    ///
7281    /// Groups nest, and only the outermost end closes one. The step is folded
7282    /// as each edit lands (twig's `coalesce_last_undo`, one edit at a time),
7283    /// so a group of a thousand edits holds one step on the history and never
7284    /// meets twig's cap. Nothing typed before the group folds into it, and
7285    /// nothing typed after: it is a step of its own, even when it is a single
7286    /// edit. A group no edit landed in leaves no step at all.
7287    ///
7288    /// An [`undo`](Self::undo) or [`redo`](Self::redo) closes any open group,
7289    /// whatever its depth — the history has moved, and an edit made after it
7290    /// is not part of the step it moved past. A later `end_undo_group` is
7291    /// then a no-op.
7292    pub fn begin_undo_group(&mut self) {
7293        match &mut self.undo_group {
7294            Some(g) => g.depth += 1,
7295            None => {
7296                self.undo_group = Some(UndoGroup {
7297                    depth: 1,
7298                    has_step: false,
7299                });
7300                // The first edit inside is not a continuation of the typing
7301                // before it.
7302                self.last_edit_kind = None;
7303            }
7304        }
7305    }
7306
7307    /// Close the undo group [`begin_undo_group`](Self::begin_undo_group)
7308    /// opened. A no-op when none is open.
7309    pub fn end_undo_group(&mut self) {
7310        let Some(g) = &mut self.undo_group else {
7311            return;
7312        };
7313        if g.depth > 1 {
7314            g.depth -= 1;
7315        } else {
7316            self.close_undo_group();
7317        }
7318    }
7319
7320    /// Whether an undo group is open.
7321    pub fn in_undo_group(&self) -> bool {
7322        self.undo_group.is_some()
7323    }
7324
7325    /// Close any open group, however deeply nested.
7326    fn close_undo_group(&mut self) {
7327        if self.undo_group.take().is_some() {
7328            // Typing after the group is not a continuation of the last edit
7329            // in it.
7330            self.last_edit_kind = None;
7331        }
7332    }
7333
7334    /// Refresh the cached source and put the caret back where the step being
7335    /// undone/redone had it, clearing any active run.
7336    ///
7337    /// The caret comes from twig's blob for the restored state (what
7338    /// `record_caret` stored). `change` is only the fallback for a state with no
7339    /// blob — a caret at the end of the restored text, which is where this always
7340    /// landed before the blobs were kept. It is the edit site, not where the user
7341    /// was standing, so it's a floor and not the behaviour: undoing should hand
7342    /// back the document *and* the place you were working, which for an edit made
7343    /// anywhere but under the caret are two different places.
7344    fn after_history(&mut self, change: Change) {
7345        self.refresh();
7346        match self
7347            .editor
7348            .caret_blob()
7349            .ok()
7350            .and_then(|b| CaretState::from_blob(&b))
7351        {
7352            Some(state) => {
7353                self.caret = state.caret.min(self.source.len());
7354                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
7355            }
7356            None => {
7357                self.caret = change.new.end.min(self.source.len());
7358                self.anchor = None;
7359            }
7360        }
7361        self.goal_col = None;
7362        self.last_edit_kind = None;
7363        self.dirty = self.source != self.clean_source;
7364        self.status = None;
7365        self.clamp_caret();
7366    }
7367
7368    // ── the file ──────────────────────────────────────────────────────────────
7369
7370    #[cfg(feature = "fs")]
7371    pub fn save(&mut self) {
7372        if self.is_untitled() {
7373            // No path to write and no name to invent: ⌘S on an untitled document
7374            // is a Save As, and only a frontend has a picker to ask with. Say so
7375            // rather than failing at the filesystem with an empty path.
7376            self.status = Some("untitled — save as…".into());
7377            return;
7378        }
7379        let path = self.path.clone();
7380        if self.write(&path) {
7381            self.mark_saved();
7382        }
7383    }
7384
7385    /// Save As: write the document to `path` and *move* it there — `self.path`
7386    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
7387    /// what Save As means; a copy would leave the user editing a document whose
7388    /// name is no longer where their keystrokes go.
7389    ///
7390    /// The move only happens if the bytes actually landed. A failed write leaves
7391    /// the path, `dirty`, and the disk watermark exactly as they were, with the
7392    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
7393    /// must never come away believing it was saved.
7394    ///
7395    /// An existing `path` is overwritten, and the caller is the one that knows
7396    /// whether to ask first: a Save As picker has already run that prompt, and a
7397    /// second confirmation from down here would be the same question twice.
7398    ///
7399    /// `format` does **not** follow the new extension. The buffer is parsed as
7400    /// the format it was opened with, and re-reading it as another one is a
7401    /// conversion — a different, lossy operation that would throw away the undo
7402    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
7403    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
7404    /// until it's reopened.
7405    #[cfg(feature = "fs")]
7406    pub fn save_as(&mut self, path: PathBuf) {
7407        if !self.write(&path) {
7408            return;
7409        }
7410        self.path = path;
7411        self.mark_saved();
7412    }
7413
7414    /// Put `source` on disk at `path`, reporting whether it got there. The one
7415    /// place leaf writes a document, so a save and a Save As can't disagree
7416    /// about what a failure looks like.
7417    #[cfg(feature = "fs")]
7418    fn write(&mut self, path: &Path) -> bool {
7419        match std::fs::write(path, self.source.as_bytes()) {
7420            Ok(()) => true,
7421            Err(e) => {
7422                self.status = Some(format!("save failed: {e}"));
7423                false
7424            }
7425        }
7426    }
7427
7428    /// Re-base the document's saved watermark to the current bytes: clears
7429    /// `dirty`, records `source` as the new clean state (so undoing back to here
7430    /// clears the flag again), and re-stamps the on-disk hash.
7431    ///
7432    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
7433    /// the hook a **filesystem-free host** calls itself once it has persisted
7434    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
7435    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
7436    /// are already where that host wants them, and this just tells the model they
7437    /// are safe.
7438    pub fn mark_saved(&mut self) {
7439        self.clean_source = self.source.clone();
7440        self.dirty = false;
7441        // The bytes on disk are now ours, so this is the new watermark: without
7442        // re-stamping it, every save would report its own work as an external
7443        // change forever after.
7444        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
7445        self.status = Some(format!("saved {}", self.file_name()));
7446    }
7447
7448    /// What the file looks like now against the bytes leaf last read or wrote.
7449    ///
7450    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
7451    /// so this is a filesystem round-trip, not a per-frame question — ask it
7452    /// when a window regains focus, on a timer, or before a save.
7453    ///
7454    /// This *only* reports the file. Whether the document also has unsaved edits
7455    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
7456    /// [`DiskState::Changed`] means a save overwrites someone's work and a
7457    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
7458    /// it has no way to ask — so it hands a frontend both halves and lets it put
7459    /// the question to the person who can answer it.
7460    #[cfg(feature = "fs")]
7461    pub fn disk_state(&self) -> DiskState {
7462        let Some(want) = self.disk_hash else {
7463            return DiskState::Untitled;
7464        };
7465        match std::fs::read(&self.path) {
7466            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
7467            Ok(_) => DiskState::Changed,
7468            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
7469            Err(_) => DiskState::Unreadable,
7470        }
7471    }
7472
7473    /// Re-read the file and replace the document with what's there — the other
7474    /// answer to a [`DiskState::Changed`].
7475    ///
7476    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
7477    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
7478    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
7479    /// document shouldn't have to argue with a guard.
7480    ///
7481    /// **The undo history survives, and the reload is one step in it.** The
7482    /// whole buffer is spliced with the file's bytes through the same door every
7483    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
7484    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
7485    /// swapped the document out from under a reader gives them back what they
7486    /// were looking at, marked dirty, and ^Z again carries on into whatever they
7487    /// had done before it. This used to build a fresh parse and drop the stack,
7488    /// on the reasoning that twig's history belongs to the buffer and these are
7489    /// different bytes; that is true of *rebasing* a step onto them and not of
7490    /// recording the swap itself as one, which is all this is. A splice twig
7491    /// won't take falls back to the fresh parse, and only that path still costs
7492    /// the history.
7493    ///
7494    /// The caret keeps its byte offset, clamped to the new length; the selection
7495    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
7496    /// file changed, so it can't know where the caret "still" is. Clamping keeps
7497    /// it where the user left it in the common case (a change further down the
7498    /// file, or none in the text they're sitting in), and never puts it
7499    /// somewhere invalid. A selection has two such offsets and no such excuse —
7500    /// silently reinterpreting one over changed bytes would arm the *next*
7501    /// keystroke to delete something the user never selected.
7502    ///
7503    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
7504    /// document alone with a status.
7505    #[cfg(feature = "fs")]
7506    pub fn reload(&mut self) {
7507        if self.is_untitled() {
7508            self.status = Some("no file to reload".into());
7509            return;
7510        }
7511        let bytes = match std::fs::read(&self.path) {
7512            Ok(b) => b,
7513            Err(e) => {
7514                self.status = Some(format!("reload failed: {e}"));
7515                return;
7516            }
7517        };
7518        let Ok(source) = String::from_utf8(bytes) else {
7519            self.status = Some("reload failed: file is not UTF-8".into());
7520            return;
7521        };
7522        // Already these bytes — someone saved a file back unchanged, or leaf's
7523        // own write is being read back. Re-baseline against it and stop: a
7524        // splice of the text onto itself would put an undo step on the stack for
7525        // something nobody did.
7526        if source == self.source {
7527            self.disk_hash = Some(hash_bytes(source.as_bytes()));
7528            self.clean_source = source;
7529            self.dirty = false;
7530            self.status = Some(format!("reloaded {}", self.file_name()));
7531            return;
7532        }
7533        let caret = self.caret;
7534        // The pre-reload caret, so undoing the swap puts it back where the
7535        // reader was standing — the same bracketing `splice_exact` does.
7536        self.record_caret();
7537        if self
7538            .editor
7539            .edit_range(0, self.source.len(), &source)
7540            .is_ok()
7541        {
7542            self.refresh();
7543        } else {
7544            // twig wouldn't take the splice. Start over from the bytes, which is
7545            // what this always did, and is the one path that still costs the
7546            // history — `format` is the format this document *is*, not what the
7547            // (unchanged) name now says, see `save_as`.
7548            match new_editor(source.as_bytes(), self.format) {
7549                Ok(editor) => {
7550                    self.editor = editor;
7551                    // A fresh editor, and so a fresh history — with no
7552                    // step in it for an open group to fold into.
7553                    self.undo_steps = 0;
7554                    self.redo_steps = 0;
7555                    if let Some(g) = &mut self.undo_group {
7556                        g.has_step = false;
7557                    }
7558                    self.source = source.clone();
7559                    // Not going through `refresh`, so the revision has to move
7560                    // here or every frontend keeps painting the old file from
7561                    // cache.
7562                    self.revision += 1;
7563                }
7564                Err(e) => {
7565                    self.status = Some(format!("reload failed: {e}"));
7566                    return;
7567                }
7568            }
7569        }
7570        self.disk_hash = Some(hash_bytes(source.as_bytes()));
7571        self.clean_source = self.source.clone();
7572        self.caret = caret.min(self.source.len());
7573        self.anchor = None;
7574        self.goal_col = None;
7575        self.last_edit_kind = None;
7576        self.dirty = false;
7577        self.status = Some(format!("reloaded {}", self.file_name()));
7578        self.clamp_caret();
7579        // And the post-reload caret, so a redo restores it.
7580        self.record_caret();
7581    }
7582
7583    /// Re-read the source from twig after it has changed the document. The one
7584    /// funnel every edit, undo, and redo comes through — so it's where the
7585    /// revision moves, and anything cached against the text dies here.
7586    fn refresh(&mut self) {
7587        if let Ok(s) = self.editor.source_str() {
7588            self.source = s;
7589        }
7590        self.revision += 1;
7591        // An edit is a step onto the history and the end of anything undone;
7592        // `undo`/`redo` come through here too and correct this after.
7593        self.undo_steps = (self.undo_steps + 1).min(UNDO_CAP);
7594        self.redo_steps = 0;
7595        // Inside a group, fold each step into the group's first as it lands.
7596        // Not for `undo`/`redo`, which close the group before they get here.
7597        if let Some(g) = &mut self.undo_group {
7598            if g.has_step {
7599                self.fold_last_undo();
7600            } else {
7601                g.has_step = true;
7602            }
7603        }
7604        self.clamp_caret();
7605    }
7606
7607    /// Whether [`undo`](Self::undo) has a step to take back — for a native
7608    /// Edit menu to enable its item by, and exactly when `undo` would move.
7609    pub fn can_undo(&self) -> bool {
7610        !self.read_only && self.undo_steps > 0
7611    }
7612
7613    /// Whether [`redo`](Self::redo) has an undone step to restore.
7614    pub fn can_redo(&self) -> bool {
7615        !self.read_only && self.redo_steps > 0
7616    }
7617
7618    // ── caret movement ─────────────────────────────────────────────────────────
7619    // `extend` grows the selection (Shift+motion): it pins the anchor on the
7620    // first extended step and moves only the caret; an un-extended motion drops
7621    // the selection.
7622
7623    /// Place the caret at byte `offset` (clamped to a char boundary), extending
7624    /// the selection when `extend` is set. The public form of `move_to`, for a
7625    /// frontend that hit-tests pixels straight to a source offset.
7626    pub fn place_caret(&mut self, offset: usize, extend: bool) {
7627        self.goal_col = None;
7628        let before = self.caret;
7629        // A pixel hit-test can land between the visible caret stops — in the
7630        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
7631        // Snap to the nearest real stop so the caret can't come to rest where it
7632        // would draw in one place and type in another. The `(row, col)` click
7633        // path (`click`) already snaps this way through `offset_of_pos`; the
7634        // source view reaches every byte, so it snaps to nothing.
7635        let target = match self.view {
7636            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
7637            // The source view reaches every byte, so there is no stop to snap
7638            // to — but "every byte" still means every *character* boundary. A
7639            // caret resting inside a multi-byte character draws nowhere real
7640            // and panics the next time anything slices there.
7641            View::Source => self.char_boundary_at_or_before(offset),
7642        };
7643        self.move_to(target, extend);
7644        self.clamp_caret();
7645        self.debug_assert_on_a_stop(before);
7646    }
7647
7648    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
7649    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
7650    /// grab the metadata) while the source view still selects the literal whole.
7651    pub fn select_all(&mut self) {
7652        self.anchor = Some(self.caret_floor());
7653        self.caret = self.source.len();
7654        self.goal_col = None;
7655        self.last_edit_kind = None;
7656        self.status = None;
7657    }
7658
7659    /// Select the word (or whitespace / punctuation run) at `offset` — the
7660    /// double-click gesture. Anchors on the run's start with the caret at its
7661    /// end so a following Shift-motion extends from the far edge.
7662    pub fn select_word_at(&mut self, offset: usize) {
7663        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
7664        self.anchor = Some(s);
7665        self.caret = e;
7666        self.goal_col = None;
7667        self.last_edit_kind = None;
7668        self.status = None;
7669        self.clamp_caret();
7670    }
7671
7672    /// Select the whole enclosing text block (paragraph, heading, list item's
7673    /// text…) at `offset` — the triple-click gesture. Reads the range straight
7674    /// from the AST (twig's `content_span`), so it selects the entire *logical*
7675    /// paragraph even when that paragraph soft-wraps across several visual rows —
7676    /// where a visual-row-based select breaks down, because one source offset at
7677    /// a wrap boundary belongs to two rows at once.
7678    pub fn select_block_at(&mut self, offset: usize) {
7679        let off = offset.min(self.source.len());
7680        let range = self
7681            .editor
7682            .ancestors_at(off)
7683            .ok()
7684            .and_then(|chain| {
7685                // Ancestors run root → deepest; the deepest node that is neither
7686                // an inline span nor a multi-block container is the text block
7687                // the caret sits in (a paragraph, a heading, a code block…).
7688                chain
7689                    .into_iter()
7690                    .rev()
7691                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
7692                    .map(|m| m.content_span.unwrap_or(m.span))
7693            })
7694            .unwrap_or_else(|| source_line_range(&self.source, off));
7695        self.anchor = Some(range.start.min(self.source.len()));
7696        self.caret = range.end.min(self.source.len());
7697        self.goal_col = None;
7698        self.last_edit_kind = None;
7699        self.status = None;
7700        self.clamp_caret();
7701    }
7702
7703    /// Select the exact source range `[start, end)` — anchor at `start`, caret
7704    /// at `end` — without snapping either end to a visible caret stop.
7705    ///
7706    /// The one caret verb that takes a range it was *handed* rather than one it
7707    /// worked out, for a host that already knows the bytes it means: a search
7708    /// hit, an annotation's footprint, a quote re-anchored through
7709    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
7710    /// wrong tool for that, and not by a little — it snaps to the nearest
7711    /// *visible* stop, and where a range butts up against a hidden delimiter
7712    /// the nearest stop is the one before it, so selecting the "needle" of
7713    /// `**needle**` comes back with "needl" and an edit against it strands the
7714    /// "e".
7715    ///
7716    /// What `place_caret` does that is bookkeeping rather than snapping still
7717    /// happens here, because a host handing in a range is not asking to opt out
7718    /// of the invariants:
7719    ///
7720    /// - both ends are clamped into the document and up to
7721    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
7722    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
7723    ///   types into the metadata;
7724    /// - both land on character boundaries, so nothing slices a `é` in half;
7725    /// - the sticky vertical goal column is dropped, and any armed inline mark
7726    ///   disarmed, since a range from outside inherits neither.
7727    ///
7728    /// An empty range is a caret rather than a selection —
7729    /// [`selection`](Self::selection) reports `None` for it, as it does for any
7730    /// anchor that has met the caret.
7731    pub fn select_range(&mut self, start: usize, end: usize) {
7732        let floor = self.caret_floor();
7733        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
7734        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
7735        self.anchor = Some(anchor);
7736        self.caret = caret;
7737        self.goal_col = None;
7738        self.status = None;
7739        self.last_edit_kind = None;
7740        self.clear_pending();
7741    }
7742
7743    /// `offset` itself if it is a character boundary, else the boundary before
7744    /// it. An offset that isn't one draws nowhere real and panics the next time
7745    /// anything slices there.
7746    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
7747        let mut o = offset.min(self.source.len());
7748        while o > 0 && !self.source.is_char_boundary(o) {
7749            o -= 1;
7750        }
7751        o
7752    }
7753
7754    /// The lowest source offset the caret may occupy in the active view. In
7755    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
7756    /// the first rendered offset; the source view reaches everything, so it's 0.
7757    fn caret_floor(&self) -> usize {
7758        match self.view {
7759            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
7760            View::Source => 0,
7761        }
7762    }
7763
7764    /// Land in a table cell with its whole content selected — the anchor at the
7765    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
7766    /// like tabbing into a form field: the text comes up selected, so typing
7767    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
7768    /// end`) collapses to a plain caret home (an empty selection is no selection).
7769    fn select_cell(&mut self, start: usize, end: usize) {
7770        self.select_range(start, end);
7771    }
7772
7773    fn move_to(&mut self, offset: usize, extend: bool) {
7774        if extend {
7775            if self.anchor.is_none() {
7776                self.anchor = Some(self.caret);
7777            }
7778        } else {
7779            self.anchor = None;
7780        }
7781        self.caret = offset.min(self.source.len()).max(self.caret_floor());
7782        self.status = None;
7783        // A caret move ends the current typing/deletion run, so the next edit
7784        // starts a fresh undo group rather than coalescing across the gap.
7785        self.last_edit_kind = None;
7786        // Moving away disarms any sticky mark — "start bold" applies only where
7787        // it was asked for, not wherever the caret next lands.
7788        self.clear_pending();
7789    }
7790
7791    // In the source view, motion walks source bytes / source lines. In the
7792    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
7793    // what steps the caret cleanly over hidden delimiters.
7794
7795    pub fn move_left(&mut self, extend: bool) {
7796        self.goal_col = None;
7797        if !extend && let Some((s, _e)) = self.selection() {
7798            self.move_to(s, false);
7799            return;
7800        }
7801        let target = match self.view {
7802            View::Source => {
7803                if self.caret > 0 {
7804                    prev_boundary(&self.source, self.caret)
7805                } else {
7806                    0
7807                }
7808            }
7809            // Walks caret *stops*, not columns: decoration (a table border, a
7810            // cell's padding) is stepped over in one press, and a hidden
7811            // delimiter never holds the caret up — though the end of a mark's
7812            // content is a stop of its own (`VisualMap::mark_ends`), so
7813            // leaving `**bold**` from past its `**` is a press onto the end of
7814            // the bold and another onto the `d`.
7815            View::Wysiwyg => self
7816                .vmap
7817                .caret_stop_before(self.caret)
7818                .unwrap_or(self.caret),
7819        };
7820        let before = self.caret;
7821        self.move_to(target, extend);
7822        self.debug_assert_on_a_stop(before);
7823    }
7824
7825    pub fn move_right(&mut self, extend: bool) {
7826        self.goal_col = None;
7827        if !extend && let Some((_s, e)) = self.selection() {
7828            self.move_to(e, false);
7829            return;
7830        }
7831        let target = match self.view {
7832            View::Source => {
7833                if self.caret < self.source.len() {
7834                    next_boundary(&self.source, self.caret)
7835                } else {
7836                    self.caret
7837                }
7838            }
7839            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
7840        };
7841        let before = self.caret;
7842        self.move_to(target, extend);
7843        self.debug_assert_on_a_stop(before);
7844    }
7845
7846    /// Move to the start of the previous word (⌥← / Ctrl+←).
7847    pub fn move_word_left(&mut self, extend: bool) {
7848        self.goal_col = None;
7849        let before = self.caret;
7850        let target = self.word_left_from(self.caret);
7851        self.move_to(target, extend);
7852        self.debug_assert_on_a_stop(before);
7853    }
7854
7855    /// Move to the end of the next word (⌥→ / Ctrl+→).
7856    pub fn move_word_right(&mut self, extend: bool) {
7857        self.goal_col = None;
7858        let before = self.caret;
7859        let target = self.word_right_from(self.caret);
7860        self.move_to(target, extend);
7861        self.debug_assert_on_a_stop(before);
7862    }
7863
7864    // Word boundaries are found in the space the *view* is in. The source view
7865    // walks the source, because there the source is what's rendered. WYSIWYG
7866    // walks the rendered text instead: `**` is invisible to the user, so it has
7867    // to be invisible to word motion too — a caret parked inside one draws in
7868    // the column after `bold` and types two bytes earlier, and a word-delete
7869    // that stops there shreds the markup into `a ** c`.
7870
7871    /// The word boundary to the left of `off` in the active view's space.
7872    fn word_left_from(&self, off: usize) -> usize {
7873        match self.view {
7874            View::Source => prev_word(&self.source, off),
7875            View::Wysiwyg => self.glyph_word_left(off),
7876        }
7877    }
7878
7879    /// The word boundary to the right of `off` in the active view's space.
7880    fn word_right_from(&self, off: usize) -> usize {
7881        match self.view {
7882            View::Source => next_word(&self.source, off),
7883            View::Wysiwyg => self.glyph_word_right(off),
7884        }
7885    }
7886
7887    /// The character class of the glyph drawn at stop `off`.
7888    ///
7889    /// Read from the source, because a stop points at the source byte its glyph
7890    /// came from — the source *is* where the rendered character is written. What
7891    /// makes the walk glyph space rather than source space is that it only ever
7892    /// visits stops, and the hidden bytes between them have none.
7893    fn class_at(&self, off: usize) -> Class {
7894        self.source
7895            .get(off..)
7896            .and_then(|s| s.chars().next())
7897            .map_or(Class::Space, classify)
7898    }
7899
7900    /// [`next_word`] in glyph space: skip any leading separators, then consume
7901    /// the following word run, with the stop table standing in for the source's
7902    /// characters.
7903    fn glyph_word_right(&self, from: usize) -> usize {
7904        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
7905            return from;
7906        };
7907        let mut in_word = false;
7908        loop {
7909            match self.class_at(off) {
7910                Class::Word => in_word = true,
7911                _ if in_word => return off,
7912                _ => {}
7913            }
7914            match self.vmap.stop_after(off) {
7915                Some(next) => off = next,
7916                None => return off,
7917            }
7918        }
7919    }
7920
7921    /// [`prev_word`] in glyph space: skip separators walking left, then consume
7922    /// the preceding word run.
7923    fn glyph_word_left(&self, from: usize) -> usize {
7924        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
7925            return from;
7926        };
7927        let mut in_word = false;
7928        while let Some(prev) = self.vmap.stop_before(off) {
7929            match self.class_at(prev) {
7930                Class::Word => in_word = true,
7931                _ if in_word => return off,
7932                _ => {}
7933            }
7934            off = prev;
7935        }
7936        off
7937    }
7938
7939    /// After a motion that walks the visual map, the caret must be *on* the map.
7940    /// A stop is the only offset where the caret draws and edits in the same
7941    /// place, and it's the invariant both a caret parked inside an emoji and one
7942    /// parked inside a `**` were quietly breaking.
7943    ///
7944    /// Only when the caret actually moved: a walk with nowhere to go leaves it
7945    /// where it was, which is wherever the floor or a frontend put it rather
7946    /// than somewhere this motion chose.
7947    fn debug_assert_on_a_stop(&self, before: usize) {
7948        debug_assert!(
7949            self.view != View::Wysiwyg
7950                || self.vmap.num_rows() == 0
7951                || self.caret == before
7952                || self.vmap.is_stop(self.caret),
7953            "motion left the caret at {}, which is not a caret stop: it would draw in \
7954             one place and type in another",
7955            self.caret
7956        );
7957    }
7958
7959    // Up and Down run off the ends of the document rather than stopping dead at
7960    // them: Up from the first row lands at the document's start, Down from the
7961    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
7962    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
7963    // reaching the end of the text is what a reader means by it.
7964    //
7965    // The views used to disagree here by accident rather than by decision: the
7966    // source view fell into the edge behaviour through `row_col_to_offset`
7967    // clamping an out-of-range row to the end of the string, while WYSIWYG had
7968    // no row below to walk to and did nothing at all. They share the rule now,
7969    // each in its own space — the source view reaches every byte, WYSIWYG only
7970    // the offsets it draws.
7971
7972    pub fn move_up(&mut self, extend: bool) {
7973        let (row, col) = self.caret_pos();
7974        let goal = self.goal_col.unwrap_or(col);
7975        let target = match self.view {
7976            View::Source => match row.checked_sub(1) {
7977                Some(r) => row_col_to_offset(&self.source, r, goal),
7978                None => self.reachable_start(),
7979            },
7980            // A table's border rules are drawn but hold no caret, so Up steps
7981            // over them to the row that does.
7982            View::Wysiwyg => match self.vmap.navigable_above(row) {
7983                Some(r) => self.row_target(r, goal),
7984                None => self.reachable_start(),
7985            },
7986        };
7987        self.step_vertical(target, goal, extend);
7988    }
7989
7990    pub fn move_down(&mut self, extend: bool) {
7991        let (row, col) = self.caret_pos();
7992        let goal = self.goal_col.unwrap_or(col);
7993        let target = match self.view {
7994            View::Source => match self.source_row_below(row) {
7995                Some(r) => row_col_to_offset(&self.source, r, goal),
7996                None => self.reachable_end(),
7997            },
7998            View::Wysiwyg => match self.vmap.navigable_below(row) {
7999                Some(r) => self.row_target(r, goal),
8000                None => self.reachable_end(),
8001            },
8002        };
8003        self.step_vertical(target, goal, extend);
8004    }
8005
8006    /// Land a vertical motion at `target`, latching the `goal` column it aimed
8007    /// with so the rest of the run keeps aiming there.
8008    ///
8009    /// A motion with nowhere to go changes *nothing*, the goal column included:
8010    /// the latch used to run before the early return at the top of the document,
8011    /// so an Up that did nothing still armed a column, and the next Down aimed
8012    /// at one the caret had never been in.
8013    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
8014        let before = self.caret;
8015        if target == before {
8016            return;
8017        }
8018        self.goal_col = Some(goal);
8019        self.move_to(target, extend);
8020        self.debug_assert_on_a_stop(before);
8021    }
8022
8023    /// The source line below `row`, or `None` when `row` is the last one. Lines
8024    /// are counted by newline, so a trailing one leaves a real, empty last line
8025    /// for the caret to sit on — the document ends below it, not on it.
8026    fn source_row_below(&self, row: usize) -> Option<usize> {
8027        let last = self.source.bytes().filter(|&b| b == b'\n').count();
8028        (row < last).then_some(row + 1)
8029    }
8030
8031    /// Where a vertical motion aiming at the `goal` column lands on visual row
8032    /// `r`: the column clamped to the row, mapped to its offset, then held
8033    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
8034    /// column belongs to the row below, and a gutter's column 0 points at the
8035    /// block rather than at this row.
8036    fn row_target(&self, r: usize, goal: usize) -> usize {
8037        let (start, end) = self.row_bounds(r);
8038        self.vmap
8039            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
8040            .clamp(start, end)
8041    }
8042
8043    /// The first and last offsets the caret can reach in the active view.
8044    ///
8045    /// Not the same span in both: the source view shows every byte, so it can
8046    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
8047    /// sits below the first stop, and a document's trailing newline is drawn
8048    /// nowhere and so sits past the last.
8049    fn reachable_start(&self) -> usize {
8050        match self.view {
8051            View::Source => 0,
8052            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
8053        }
8054    }
8055
8056    fn reachable_end(&self) -> usize {
8057        match self.view {
8058            View::Source => self.source.len(),
8059            View::Wysiwyg => self
8060                .vmap
8061                .stop_at_or_before(self.source.len())
8062                .unwrap_or(self.caret),
8063        }
8064    }
8065
8066    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
8067    /// including the space a soft wrap ate off its end, which is drawn on this
8068    /// row however much the offset past it belongs to the next one.
8069    fn row_span(&self, r: usize) -> (usize, usize) {
8070        let start = self
8071            .vmap
8072            .row_start(r)
8073            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
8074        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
8075        (start.min(end), end)
8076    }
8077
8078    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
8079    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
8080    /// this row's last position is the one before it — the offset before the
8081    /// space the wrap ate, where the caret draws just past the row's last word
8082    /// and types there too.
8083    ///
8084    /// Aiming at the shared offset instead is what stalled End: it is the row's
8085    /// last *column*, so End pressed on the row reached it and then read back as
8086    /// the row below's start, where a second press ran on to that row's end and
8087    /// the next to the one after — End walking down the paragraph a row a press.
8088    fn row_bounds(&self, r: usize) -> (usize, usize) {
8089        let (start, end) = self.row_span(r);
8090        let wraps = self
8091            .vmap
8092            .navigable_below(r)
8093            .and_then(|b| self.vmap.row_start(b))
8094            .is_some_and(|off| off == end);
8095        match wraps {
8096            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
8097            false => (start, end),
8098        }
8099    }
8100
8101    /// The `[start, end]` of the line Home and End aim at: the visual row in
8102    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
8103    ///
8104    /// A soft-wrapped row is a line here, because it is one to the eye and the
8105    /// eye is what these keys are aimed by — a reader pressing End means the end
8106    /// of the line they can see. (`select_block_at` wants the opposite and reads
8107    /// the AST for it: a triple-click grabs the whole paragraph, however many
8108    /// rows it folds into.)
8109    fn line_bounds(&self) -> (usize, usize) {
8110        let (row, _) = self.caret_pos();
8111        match self.view {
8112            View::Source => {
8113                let start = line_start(&self.source, row);
8114                (start, line_end_from(&self.source, start))
8115            }
8116            View::Wysiwyg => self.row_bounds(row),
8117        }
8118    }
8119
8120    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
8121    /// *drawn* — what a kill takes.
8122    ///
8123    /// The two part only at a soft wrap, over the space the wrap ate: the caret
8124    /// can't stand after it (that offset opens the row below, and End stopping
8125    /// there would walk), but it is on this row, and a kill that spared it would
8126    /// leave a double space behind where the row's text had been. Deleting it
8127    /// joins nothing — a wrap is drawn, not written.
8128    fn line_span(&self) -> (usize, usize) {
8129        let (row, _) = self.caret_pos();
8130        match self.view {
8131            View::Source => self.line_bounds(),
8132            View::Wysiwyg => self.row_span(row),
8133        }
8134    }
8135
8136    /// The first offset in `[start, end]` holding something other than
8137    /// whitespace, or `end` when the line holds nothing else — where Home aims.
8138    ///
8139    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
8140    /// so a hidden delimiter is never taken for the line's first character (nor
8141    /// landed on), and the source view steps the source it is showing.
8142    fn first_non_space(&self, start: usize, end: usize) -> usize {
8143        let mut off = start;
8144        while off < end {
8145            if self.class_at(off) != Class::Space {
8146                return off;
8147            }
8148            off = match self.view {
8149                View::Source => next_boundary(&self.source, off),
8150                View::Wysiwyg => match self.vmap.stop_after(off) {
8151                    Some(next) => next,
8152                    None => return end,
8153                },
8154            };
8155        }
8156        end
8157    }
8158
8159    /// Home: to the first character on the line, or to column 0 when the caret
8160    /// is already on it — the two-press toggle every editor spells this way.
8161    /// The indentation is somewhere the caret has to be able to reach and almost
8162    /// never where a reader is headed, so it costs the second press.
8163    pub fn move_home(&mut self, extend: bool) {
8164        self.goal_col = None;
8165        let (start, end) = self.line_bounds();
8166        let text = self.first_non_space(start, end);
8167        let target = if self.caret == text { start } else { text };
8168        let before = self.caret;
8169        self.move_to(target, extend);
8170        self.debug_assert_on_a_stop(before);
8171    }
8172
8173    /// End: to the end of the line.
8174    pub fn move_end(&mut self, extend: bool) {
8175        self.goal_col = None;
8176        let (_, end) = self.line_bounds();
8177        let before = self.caret;
8178        self.move_to(end, extend);
8179        self.debug_assert_on_a_stop(before);
8180    }
8181
8182    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
8183    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
8184    /// when the caret isn't in a table, or is already in the last/first cell — the
8185    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
8186    /// meaning everywhere else.
8187    pub fn cell_hop(&mut self, forward: bool) -> bool {
8188        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8189            return false;
8190        };
8191        // Flatten to document (row-major) order and step one cell either way.
8192        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
8193        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
8194        let next = if forward {
8195            i.checked_add(1)
8196        } else {
8197            i.checked_sub(1)
8198        };
8199        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
8200            return false; // at the table's edge; leave Tab to the frontend
8201        };
8202        self.select_cell(start, end);
8203        true
8204    }
8205
8206    /// Move the caret to the cell directly above (`down == false`) or below in
8207    /// the same column, landing with the cell's whole content selected (see
8208    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
8209    /// when the caret isn't in a table), so the frontend can fall through — the
8210    /// vertical counterpart of [`Self::cell_hop`].
8211    ///
8212    /// A ragged row that is short a column clamps to its last cell, so Down never
8213    /// falls out of the table over a gap the row above happened to have.
8214    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
8215        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8216            return false;
8217        };
8218        let target = match down {
8219            true => r + 1,
8220            false if r == 0 => return false,
8221            false => r - 1,
8222        };
8223        let Some(row) = grid.get(target) else {
8224            return false;
8225        };
8226        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
8227            return false;
8228        };
8229        self.select_cell(start, end);
8230        true
8231    }
8232
8233    /// The table containing `off` as a row-major grid of `(start, end)` cell
8234    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
8235    /// isn't in a table. Read straight off the visual map's laid-out grid, so
8236    /// every cell (an empty one included, whose derived home twig gives no
8237    /// `content_span` for) is present and in the order Tab walks them.
8238    // Grid, row, column — three returns that only ever travel together, and a
8239    // named type for the pair of them would be read at one call site.
8240    #[allow(clippy::type_complexity)]
8241    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
8242        for t in &self.vmap.tables {
8243            let mut pos = None;
8244            let grid: Vec<Vec<(usize, usize)>> = t
8245                .grid
8246                .iter()
8247                .enumerate()
8248                .map(|(r, row)| {
8249                    row.cells
8250                        .iter()
8251                        .enumerate()
8252                        .map(|(c, cell)| {
8253                            if pos.is_none() && off >= cell.start && off <= cell.end {
8254                                pos = Some((r, c));
8255                            }
8256                            (cell.start, cell.end)
8257                        })
8258                        .collect()
8259                })
8260                .collect();
8261            if let Some((r, c)) = pos {
8262                return Some((grid, r, c));
8263            }
8264        }
8265        None
8266    }
8267
8268    // ── table key policy ──────────────────────────────────────────────────────
8269    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
8270    // as one policy every frontend shares, rather than each re-deriving it. Each
8271    // reports whether it acted *as a table key*; a `false` hands the key back to
8272    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
8273    // everywhere else.
8274
8275    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
8276    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
8277    /// back and simply stays put at the very first cell. `false` when the caret
8278    /// isn't in a table.
8279    pub fn cell_tab(&mut self, forward: bool) -> bool {
8280        if !self.caret_in_table() {
8281            return false;
8282        }
8283        if self.cell_hop(forward) {
8284            return true;
8285        }
8286        // Off the last cell: grow the table by a row and step into its first
8287        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
8288        if forward {
8289            self.append_row_and_enter(0);
8290        }
8291        true
8292    }
8293
8294    /// Return inside a table: drop to the cell below in the same column,
8295    /// appending a new row when the caret is already in the last one. `false`
8296    /// when the caret isn't in a table, so the frontend inserts a newline.
8297    pub fn cell_return(&mut self) -> bool {
8298        if !self.caret_in_table() {
8299            return false;
8300        }
8301        if self.cell_move_vertical(true) {
8302            return true;
8303        }
8304        // Already on the last row: grow one below and drop into the same column.
8305        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
8306        self.append_row_and_enter(col);
8307        true
8308    }
8309
8310    /// Append a row below the caret's (last) row and land in `col` of it. The
8311    /// caret is in the last row, so twig's "insert below" makes the fresh row the
8312    /// table's new last — but twig re-spells the whole table, moving every byte,
8313    /// so the destination is read back from the rebuilt grid by the table's
8314    /// position (stable across a row insert), not from the pre-edit caret.
8315    fn append_row_and_enter(&mut self, col: usize) {
8316        let table = self.caret_table_index();
8317        self.table_insert_row(true);
8318        self.rebuild_map();
8319        let Some((start, end)) = table
8320            .and_then(|ti| self.vmap.tables.get(ti))
8321            .and_then(|t| t.grid.last())
8322            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
8323            .map(|cell| (cell.start, cell.end))
8324        else {
8325            return;
8326        };
8327        self.select_cell(start, end);
8328    }
8329
8330    /// The index, among the document's tables, of the one the caret sits in —
8331    /// `None` when it's in none. Used to re-find a table after an edit re-spells
8332    /// it (a row insert leaves the table order unchanged).
8333    fn caret_table_index(&self) -> Option<usize> {
8334        let off = self.caret;
8335        self.vmap.tables.iter().position(|t| {
8336            t.grid
8337                .iter()
8338                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
8339        })
8340    }
8341
8342    /// Shift+Return inside a table: insert a hard line break *within* the current
8343    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
8344    /// table, so the frontend inserts an ordinary line break.
8345    ///
8346    /// A table row is a single source line, so the newline-spelled hard break
8347    /// can't live in a cell. twig spells the in-cell break the format's way
8348    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
8349    /// break round-trips as structure the renderer reads back as a line — not the
8350    /// opaque raw HTML the old raw-splice left behind.
8351    ///
8352    /// Djot has no idiomatic in-cell break, so twig refuses it
8353    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
8354    /// would render as the literal text `<br>`. The gesture is still *consumed*
8355    /// there — returning `false` would let the frontend insert a real newline,
8356    /// which splits the one-line row — it just leaves the cell unchanged and says
8357    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
8358    ///
8359    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
8360    /// have to be read together: djot is not the only `false`, and naming it in
8361    /// the message was already a guess that HTML — which spells the break as its
8362    /// own `<br>` — would have made wrong.
8363    pub fn cell_line_break(&mut self) -> bool {
8364        if self.read_only || !self.caret_in_table() {
8365            return false;
8366        }
8367        self.record_caret();
8368        match self.editor.insert_line_break(self.caret) {
8369            Ok(change) => {
8370                self.last_edit_kind = None;
8371                self.refresh();
8372                self.caret = change.new.end;
8373                self.anchor = None;
8374                self.goal_col = None;
8375                self.clamp_caret();
8376                self.dirty = self.source != self.clean_source;
8377                self.status = None;
8378                self.record_caret();
8379            }
8380            Err(twig::Error::UnsupportedFormat) => {
8381                self.status = Some(format!(
8382                    "in-cell line breaks aren't supported in {}",
8383                    self.format_name()
8384                ));
8385            }
8386            Err(_) => {}
8387        }
8388        true
8389    }
8390
8391    /// Rebuild the visual map at the width the last build used. A structural edit
8392    /// bumps the revision and swaps the source in, but leaves the *map* stale;
8393    /// when a single gesture edits and then moves over the result (Tab appending
8394    /// a row, then stepping into it), the move needs the map to already show the
8395    /// edit rather than waiting for the frontend's next frame.
8396    fn rebuild_map(&mut self) {
8397        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
8398        self.build_map(wrap);
8399    }
8400
8401    /// Move the caret to the very start of the document (⌘↑ on macOS,
8402    /// Ctrl+Home on Windows/Linux).
8403    pub fn move_doc_start(&mut self, extend: bool) {
8404        self.goal_col = None;
8405        self.move_to(0, extend);
8406    }
8407
8408    /// Move the caret to the very end of the document (⌘↓ on macOS,
8409    /// Ctrl+End on Windows/Linux).
8410    pub fn move_doc_end(&mut self, extend: bool) {
8411        self.goal_col = None;
8412        let end = self.source.len();
8413        self.move_to(end, extend);
8414    }
8415
8416    /// Point the caret at the body cell `(row, col)` the mouse landed on —
8417    /// `col` being a cell of the terminal grid, which is what a display column
8418    /// is. A click on the far cell of a wide character lands at that
8419    /// character's start; the mapping's own doc-comments carry the rule.
8420    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
8421        self.goal_col = None;
8422        let target = match self.view {
8423            View::Source => row_col_to_offset(&self.source, row, col),
8424            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
8425        };
8426        let before = self.caret;
8427        self.move_to(target, extend);
8428        self.debug_assert_on_a_stop(before);
8429    }
8430
8431    /// A click in the blank space under the document's last block.
8432    ///
8433    /// Not a click *on* anything, so it lands on nothing in particular: the
8434    /// caret goes onto an empty paragraph under the last block, wherever the
8435    /// pointer was horizontally — and if the document does not end with one,
8436    /// one is opened, which is the only way to get out from under a block Enter
8437    /// cannot leave. Enter inside a fenced code block is a literal newline (see
8438    /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
8439    /// way out at all; and a click under any last block used to land at the
8440    /// pointer's x on the block's last line, which is what a click on that line
8441    /// means and not what a click under it does.
8442    ///
8443    /// "Ends with an empty paragraph" is two trailing newlines: the first closes
8444    /// the last line and the second opens the blank line the visual map lays
8445    /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
8446    /// document ending inside an *unclosed* fence gets the fence closed first,
8447    /// since a newline written there would only be another line of code. An
8448    /// empty document has nothing to be under, and the caret simply goes to its
8449    /// start.
8450    ///
8451    /// In the source view the gesture is the ordinary one: the caret goes to the
8452    /// end of the source, and nothing is written. A read-only document likewise.
8453    pub fn click_past_end(&mut self) {
8454        self.goal_col = None;
8455        let len = self.source.len();
8456        if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
8457            self.move_to(len, false);
8458            return;
8459        }
8460        let mut tail = String::new();
8461        if let Some(fence) = self.unclosed_fence_at_end() {
8462            if !self.source.ends_with('\n') {
8463                tail.push('\n');
8464            }
8465            tail.push_str(&fence);
8466            tail.push('\n');
8467        }
8468        let joined = format!("{}{tail}", self.source);
8469        let trailing = joined.len() - joined.trim_end_matches('\n').len();
8470        for _ in trailing..2 {
8471            tail.push('\n');
8472        }
8473        if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
8474            return;
8475        }
8476        let end = self.source.len();
8477        self.move_to(end, false);
8478    }
8479
8480    /// The closing fence a document ending inside an unclosed fenced code block
8481    /// needs — the opening fence's own run, behind the quote prefix the block
8482    /// wears — or `None` when the last block is closed, indented, or not a code
8483    /// block at all.
8484    ///
8485    /// Unclosed is when twig's content span reaches the block's end: a closing
8486    /// fence line would lie between the two. `code_info_span`'s read of the
8487    /// fence is not used because it starts at the block's span, which inside a
8488    /// quote is the quote marker rather than the fence.
8489    fn unclosed_fence_at_end(&mut self) -> Option<String> {
8490        let content_end = self.source.trim_end_matches('\n').len();
8491        let block = self
8492            .nodes()
8493            .into_iter()
8494            .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
8495            .max_by_key(|n| n.span.start)?;
8496        if block.content_span.as_ref()?.end < block.span.end {
8497            return None;
8498        }
8499        let line = self.source[block.span.start..].lines().next()?;
8500        let opening = line.trim_start_matches(['>', ' ', '\t']);
8501        let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
8502        let run: String = opening.chars().take_while(|&c| c == fence).collect();
8503        let prefix = self.quote_prefix_at(block.span.start);
8504        Some(format!("{prefix}{run}"))
8505    }
8506
8507    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
8508    /// screen if it has moved since the last frame, and never scroll past the
8509    /// last of `rows`.
8510    ///
8511    /// Only if it has *moved* — that's the whole point. Revealing the caret on
8512    /// every frame ties the viewport to it, and a scroll wheel that fights the
8513    /// caret for the viewport loses: the view snaps back the instant it tries to
8514    /// pass the caret's row, so the document can't be scrolled beyond what's
8515    /// already on screen. A caret move is the frontend's cue to follow; a scroll
8516    /// with the caret sitting still is the reader's cue to leave it alone.
8517    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
8518        if self.drawn_caret != Some(self.caret) {
8519            if caret_row < self.scroll {
8520                self.scroll = caret_row;
8521            } else if height > 0 && caret_row >= self.scroll + height {
8522                self.scroll = caret_row + 1 - height;
8523            }
8524            self.drawn_caret = Some(self.caret);
8525        }
8526        self.scroll = self.scroll.min(rows.saturating_sub(1));
8527    }
8528
8529    /// The caret's screen position `(row, col)` in the active view's grid, with
8530    /// `col` a display column: the cell to draw the caret in, which on a line of
8531    /// `你好` or emoji is not the count of characters before it.
8532    pub fn caret_pos(&self) -> (usize, usize) {
8533        match self.view {
8534            View::Source => offset_to_row_col(&self.source, self.caret),
8535            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
8536        }
8537    }
8538
8539    fn clamp_caret(&mut self) {
8540        if self.caret > self.source.len() {
8541            self.caret = self.source.len();
8542        }
8543        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
8544        // any selection anchor) to the first rendered offset.
8545        let floor = self.caret_floor();
8546        if self.caret < floor {
8547            self.caret = floor;
8548        }
8549        if let Some(a) = self.anchor
8550            && a < floor
8551        {
8552            self.anchor = Some(floor);
8553        }
8554        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
8555            self.caret -= 1;
8556        }
8557    }
8558}
8559
8560// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
8561
8562// Left/right motion and backspace/delete step by *grapheme cluster*, not
8563// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
8564// marks moves and deletes as the single character a user sees. Grapheme
8565// boundaries are a superset of char boundaries, so the caret stays valid for twig.
8566
8567/// How an insert of `text` groups for undo: a single typed character folds into
8568/// the run of typing around it, while a newline or a multi-character insert is a
8569/// step of its own.
8570fn typed_edit_kind(text: &str) -> EditKind {
8571    if text.chars().take(2).count() == 1 && text != "\n" {
8572        EditKind::Insert
8573    } else {
8574        EditKind::Other
8575    }
8576}
8577
8578fn prev_boundary(s: &str, i: usize) -> usize {
8579    let mut cursor = GraphemeCursor::new(i, s.len(), true);
8580    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
8581}
8582
8583fn next_boundary(s: &str, i: usize) -> usize {
8584    let mut cursor = GraphemeCursor::new(i, s.len(), true);
8585    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
8586}
8587
8588// ── word boundaries ──────────────────────────────────────────────────────────
8589// The shared primitive behind word-wise motion, word deletion, and
8590// double-click-to-select-a-word. A "word" is a maximal run of one character
8591// class; whitespace and punctuation are their own classes, so motion skips
8592// cleanly between them the way native text fields do.
8593
8594#[derive(PartialEq, Eq, Clone, Copy)]
8595enum Class {
8596    Word,
8597    Space,
8598    Other,
8599}
8600
8601/// The source range of an inline node's own visible text — the part of it a
8602/// WYSIWYG caret can reach, as against the delimiters that only spell it.
8603/// `None` for a node with no interior to empty (a `str`, a break).
8604///
8605/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
8606/// one delimiter in from the span — the same place the renderer maps it to. A
8607/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
8608/// against the source rather than trusted: a range guessed wrong here is text
8609/// deleted wrong.
8610fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
8611    if let Some(span) = n.content_span.clone() {
8612        return Some(span);
8613    }
8614    match n.kind.as_str() {
8615        "verbatim" | "inline_math" => {
8616            let text = n.text.as_ref()?;
8617            let start = n.span.start + 1;
8618            let range = start..start + text.len();
8619            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
8620        }
8621        _ => None,
8622    }
8623}
8624
8625/// The `id` a node declares, or `None` for one that declares none — the
8626/// attribute djot writes for a `{#v1}` and mints for a heading.
8627///
8628/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
8629/// names nothing, so it reads as absent rather than as the empty string.
8630fn declared_id(n: &FlatNode) -> Option<&str> {
8631    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
8632}
8633
8634/// A heading's words reduced to the form a link fragment spells them in:
8635/// lowercase, runs of anything else collapsed to a single `-`, with none left
8636/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
8637///
8638/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
8639/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
8640/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
8641/// any other language is still a heading someone will link to. Underscores
8642/// survive for the same reason they do on the web: they are word characters
8643/// wherever identifiers are written.
8644fn slug(text: &str) -> String {
8645    let mut out = String::new();
8646    let mut pending = false;
8647    for c in text.chars() {
8648        if c.is_alphanumeric() || c == '_' {
8649            if pending && !out.is_empty() {
8650                out.push('-');
8651            }
8652            pending = false;
8653            out.extend(c.to_lowercase());
8654        } else {
8655            pending = true;
8656        }
8657    }
8658    out
8659}
8660
8661/// The text of the inline run starting at `first` and its siblings, markup
8662/// stripped: each leaf's payload in order, a line break as a space. `nodes` is
8663/// the arena `Doc::nodes` returns, indexed by id.
8664fn inline_text(nodes: &[FlatNode], first: Option<NodeId>, out: &mut String) {
8665    let mut next = first;
8666    while let Some(id) = next {
8667        let Some(n) = nodes.get(id.0 as usize) else {
8668            return;
8669        };
8670        match (&n.text, &n.kind) {
8671            (Some(text), _) => out.push_str(text),
8672            (None, Kind::SoftBreak | Kind::HardBreak) => out.push(' '),
8673            (None, _) => inline_text(nodes, n.first_child, out),
8674        }
8675        next = n.next_sibling;
8676    }
8677}
8678
8679fn is_block_container(kind: &Kind) -> bool {
8680    matches!(
8681        kind,
8682        Kind::Doc
8683            | Kind::Section
8684            | Kind::BlockQuote
8685            | Kind::BulletList
8686            | Kind::OrderedList
8687            | Kind::TaskList
8688            | Kind::ListItem
8689            | Kind::TaskListItem
8690            // Every `container` — a directive in any of its three forms, or a
8691            // promoted HTML element. A *text* directive is really inline, so
8692            // claiming it here is a small overreach, and the deliberate one this
8693            // function's kind-only peer `is_inline_kind` documents: the pair is
8694            // consulted together, and answering "block container" for something
8695            // inline is what keeps an ancestor walk from stopping short of the
8696            // paragraph that actually holds it.
8697            | Kind::Container
8698    )
8699}
8700
8701/// The `[start, end)` byte range of the source line containing `off` (newline
8702/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
8703/// line between paragraphs).
8704fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
8705    let off = off.min(s.len());
8706    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
8707    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
8708    start..end
8709}
8710
8711/// How many leading bytes an outdent takes off `line`: a whole indent level
8712/// where the line has one, and whatever it has where it has less.
8713///
8714/// A leading tab counts as a level on its own. It's indentation some other
8715/// editor wrote, and one tab is one level everywhere it came from — measuring it
8716/// in spaces it doesn't contain would leave it untouchable.
8717fn outdent_width(line: &str, unit: usize) -> usize {
8718    if line.starts_with('\t') {
8719        return 1;
8720    }
8721    line.bytes().take(unit).take_while(|b| *b == b' ').count()
8722}
8723
8724/// A list marker found at the head of a line, together with everything before it
8725/// that a sibling line has to repeat.
8726///
8727/// The three offsets differ only inside a block quote, where `>   - b` opens with
8728/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
8729/// `line_start == marker_start`, and `text` is the plain `"  - "`.
8730#[derive(Clone, Debug)]
8731struct ListMarker {
8732    /// The line's first byte.
8733    line_start: usize,
8734    /// Where the marker proper begins, past any quote prefix. The offset to hand
8735    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
8736    marker_start: usize,
8737    /// `line_start` through the marker's trailing space — quote prefix, indent
8738    /// and bullet together, which is what the next item's line opens with.
8739    text: String,
8740}
8741
8742impl ListMarker {
8743    /// Where the item's content starts — one past the marker's trailing space.
8744    fn content_start(&self) -> usize {
8745        self.line_start + self.text.len()
8746    }
8747}
8748
8749fn classify(c: char) -> Class {
8750    if c == '_' || c.is_alphanumeric() {
8751        Class::Word
8752    } else if c.is_whitespace() {
8753        Class::Space
8754    } else {
8755        Class::Other
8756    }
8757}
8758
8759/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
8760/// skip any leading separators, then consume the following word run.
8761fn next_word(s: &str, i: usize) -> usize {
8762    let mut off = i;
8763    let mut in_word = false;
8764    for c in s[i..].chars() {
8765        if classify(c) == Class::Word {
8766            in_word = true;
8767        } else if in_word {
8768            break;
8769        }
8770        off += c.len_utf8();
8771    }
8772    off
8773}
8774
8775/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
8776/// skip separators walking left, then consume the preceding word run.
8777fn prev_word(s: &str, i: usize) -> usize {
8778    let mut off = i;
8779    let mut in_word = false;
8780    for c in s[..i].chars().rev() {
8781        if classify(c) == Class::Word {
8782            in_word = true;
8783        } else if in_word {
8784            break;
8785        }
8786        off -= c.len_utf8();
8787    }
8788    off
8789}
8790
8791/// The `[start, end)` run of same-class characters surrounding `off` — the
8792/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
8793/// the run ending there is used.
8794fn word_range_at(s: &str, off: usize) -> (usize, usize) {
8795    if s.is_empty() {
8796        return (0, 0);
8797    }
8798    let off = off.min(s.len());
8799    let reference = if off < s.len() {
8800        s[off..].chars().next()
8801    } else {
8802        s[..off].chars().next_back()
8803    };
8804    let Some(rc) = reference else {
8805        return (off, off);
8806    };
8807    let class = classify(rc);
8808
8809    let mut start = off;
8810    for c in s[..start].chars().rev() {
8811        if classify(c) == class {
8812            start -= c.len_utf8();
8813        } else {
8814            break;
8815        }
8816    }
8817    let mut end = off;
8818    for c in s[end..].chars() {
8819        if classify(c) == class {
8820            end += c.len_utf8();
8821        } else {
8822            break;
8823        }
8824    }
8825    (start, end)
8826}
8827
8828/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
8829/// the line's start — terminal cells, not characters, so the column names the
8830/// cell the caret is drawn in even on a line of `你好` or emoji.
8831fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
8832    let off = off.min(s.len());
8833    let mut row = 0;
8834    let mut line_start = 0;
8835    for (i, &b) in s.as_bytes().iter().enumerate() {
8836        if i >= off {
8837            break;
8838        }
8839        if b == b'\n' {
8840            row += 1;
8841            line_start = i + 1;
8842        }
8843    }
8844    (row, wysiwyg::text_width(&s[line_start..off]))
8845}
8846
8847/// The byte offset at display column `col` of `row` (clamped to that line's
8848/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
8849///
8850/// A column landing *inside* a character — the second cell of `你`, or any cell
8851/// but the first of an emoji — resolves to that character's start, which is the
8852/// column the caret would have been drawn at to begin with. So both cells of a
8853/// wide character mean the character, and every offset survives the round trip
8854/// out to a column and back. The walk steps by grapheme cluster for the same
8855/// reason the caret does: a cluster is the character, and the cells belong to it
8856/// rather than to the codepoints spelling it.
8857fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
8858    let start = line_start(s, row);
8859    let end = line_end_from(s, start);
8860    let mut off = start;
8861    let mut at = 0; // the display column `off` sits at
8862    while off < end {
8863        let next = next_boundary(s, off).min(end);
8864        let cells = wysiwyg::text_width(&s[off..next]);
8865        if at + cells > col {
8866            break; // `col` is one of this cluster's own cells
8867        }
8868        at += cells;
8869        off = next;
8870    }
8871    off
8872}
8873
8874fn line_start(s: &str, row: usize) -> usize {
8875    if row == 0 {
8876        return 0;
8877    }
8878    let mut r = 0;
8879    for (i, &b) in s.as_bytes().iter().enumerate() {
8880        if b == b'\n' {
8881            r += 1;
8882            if r == row {
8883                return i + 1;
8884            }
8885        }
8886    }
8887    s.len()
8888}
8889
8890fn line_end_from(s: &str, start: usize) -> usize {
8891    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
8892}
8893
8894/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
8895/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
8896/// twig applies writing the mark out, so the toolbar can light the same button
8897/// that made the node.
8898///
8899/// `None` for every other kind, including the inline nodes that aren't marks at
8900/// all (`str`, `link`, `image`, the math and break kinds): they're things a
8901/// caret stands in, not formatting a button toggles.
8902/// Whether a match from an ancestor chain is an inline run whose delimiters
8903/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
8904/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
8905/// span is a [`Kind::Container`], which the kind alone cannot tell from a
8906/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
8907/// answer is the node's own. Every delete and caret step that walks over a
8908/// `**` walks over a span's tags by this test; without it Backspace after
8909/// `</span>` took the `>` and left the paragraph unparseable.
8910fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
8911    inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
8912}
8913
8914fn inline_kind(kind: &Kind) -> Option<InlineKind> {
8915    Some(match kind {
8916        Kind::Strong => InlineKind::Strong,
8917        Kind::Emph => InlineKind::Emph,
8918        Kind::Verbatim => InlineKind::Verbatim,
8919        Kind::Mark => InlineKind::Mark,
8920        Kind::Superscript => InlineKind::Superscript,
8921        Kind::Subscript => InlineKind::Subscript,
8922        Kind::Insert => InlineKind::Insert,
8923        Kind::Delete => InlineKind::Delete,
8924        _ => return None,
8925    })
8926}
8927
8928/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
8929/// a highlight's opening `==`.
8930///
8931/// Two enums for one closed vocabulary, and the duplication is the boundary
8932/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
8933/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
8934/// the editor writes. Spelled as a match rather than routed through the two
8935/// crates' name strings so that a colour added on either side is a compile
8936/// error here, where the pairing is decided, rather than a runtime `None` that
8937/// would read as "clear the colour".
8938fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
8939    match color {
8940        MarkColor::Red => twig::MarkColor::Red,
8941        MarkColor::Orange => twig::MarkColor::Orange,
8942        MarkColor::Yellow => twig::MarkColor::Yellow,
8943        MarkColor::Green => twig::MarkColor::Green,
8944        MarkColor::Blue => twig::MarkColor::Blue,
8945        MarkColor::Purple => twig::MarkColor::Purple,
8946        MarkColor::Brown => twig::MarkColor::Brown,
8947    }
8948}
8949
8950/// Where an offset lands after a splice it didn't make — twig's own rule, from
8951/// [`Change`]: shift anything at or past the replaced range's end by the length
8952/// the replacement gained or lost, and leave anything before it alone.
8953///
8954/// An offset *inside* the replaced range has no text of its own to ride any
8955/// more, and lands at the end of what replaced it: for
8956/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
8957/// the prefix is cleared, which then sits where the highlighted text begins.
8958/// One node's attribute list, twig's own `(key, value)` pairs owned — what
8959/// every presentation gesture reads, edits one key of, and passes back whole.
8960type Attrs = Vec<(String, Option<String>)>;
8961
8962/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
8963/// writes it and the walker draws it, and a frontend that paginates matches a
8964/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
8965/// stated once.
8966pub const PAGE_BREAK: &str = "page-break";
8967
8968/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
8969/// every other attribute kept in its place — the read-edit-write half of twig's
8970/// replace-not-merge contract for a `data-` key.
8971///
8972/// **A key that is already there is rewritten where it stands**, and only a key
8973/// the node did not have goes on the end. That is what makes the proposal's
8974/// worked example true: `class="lead center" id="intro"
8975/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
8976/// data-line-height="1.5"` — the same document with one token changed, and a
8977/// one-line diff. Removing the key and pushing it back would reorder the
8978/// author's attributes on every press, so a document that passed through the
8979/// editor came out shuffled even where nothing about it had changed.
8980///
8981/// A duplicate key — which no format leaf opens can spell, but twig reports
8982/// verbatim — collapses onto the first of its copies, since twig is handed one
8983/// value for one key either way.
8984fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
8985    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
8986    let mut written = false;
8987    for (k, v) in attrs {
8988        if k != key {
8989            out.push((k.clone(), v.clone()));
8990            continue;
8991        }
8992        if let Some(new) = value.filter(|_| !written) {
8993            out.push((k.clone(), Some(new.to_string())));
8994            written = true;
8995        }
8996    }
8997    if let Some(new) = value.filter(|_| !written) {
8998        out.push((key.to_string(), Some(new.to_string())));
8999    }
9000    out
9001}
9002
9003/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
9004/// `token` added, with the rest of the list kept in order.
9005///
9006/// `class` is a space-separated token list, and leaf owns three of the tokens in
9007/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
9008/// goes out as `class="lead right"`; one whose last owned token goes and which
9009/// carried nothing else loses the key, so a block that has lost its whole
9010/// vocabulary is spelled bare again. `class` itself keeps its place among the
9011/// attributes, because [`with_attr`] does the writing.
9012fn with_class_token(
9013    attrs: &[(String, Option<String>)],
9014    mine: impl Fn(&str) -> bool,
9015    token: Option<&str>,
9016) -> Attrs {
9017    let kept: Vec<&str> = attrs
9018        .iter()
9019        .find(|(k, _)| k == "class")
9020        .and_then(|(_, v)| v.as_deref())
9021        .unwrap_or_default()
9022        .split_whitespace()
9023        .filter(|t| !mine(t))
9024        .collect();
9025    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
9026    with_attr(
9027        attrs,
9028        "class",
9029        (!class.is_empty()).then_some(class.as_str()),
9030    )
9031}
9032
9033/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
9034///
9035/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
9036/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
9037/// (djot has no bare attribute, so no format reads one back everywhere), and
9038/// `hidden=""` is the same document where `hidden` is; dropping it instead
9039/// would lose what the author wrote, which is the one thing these gestures
9040/// promise not to do.
9041fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
9042    attrs
9043        .iter()
9044        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
9045        .collect()
9046}
9047
9048fn reanchor(off: usize, change: &Change) -> usize {
9049    if off < change.old.start {
9050        return off;
9051    }
9052    if off < change.old.end {
9053        return change.new.end;
9054    }
9055    (off + change.new.end).saturating_sub(change.old.end)
9056}
9057
9058/// [`reanchor`] for an edit that respells the markup *around* a block and
9059/// leaves the block's own bytes alone — which is every attribute gesture.
9060///
9061/// `block` is that block's content span before and after the splice, so an
9062/// offset standing in the text keeps its distance from the text's start and how
9063/// many bytes twig wrote above it never enters the arithmetic. That is the whole
9064/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
9065/// on the same div lengthens the attribute line, clearing the last one takes the
9066/// div away entirely, and both are the same sum. `None` where the splice named
9067/// no block at either end, which is every djot case — the `{…}` line is written
9068/// above the block, and the block itself only shifts past it.
9069///
9070/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
9071/// shifted by its delta after it, and at the splice's end for an offset that
9072/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
9073/// text to keep.
9074fn reanchor_in_block(
9075    off: usize,
9076    change: &Change,
9077    block: Option<(&Range<usize>, &Range<usize>)>,
9078) -> usize {
9079    if let Some((was, now)) = block
9080        && was.start <= off
9081        && off <= was.end
9082    {
9083        return now.start + (off - was.start).min(now.end - now.start);
9084    }
9085    reanchor(off, change)
9086}
9087
9088/// A watermark for a file's contents (see `Doc::disk_hash`).
9089///
9090/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
9091/// watermark is compared only against one taken by the same process moments
9092/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
9093/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
9094fn hash_bytes(bytes: &[u8]) -> u64 {
9095    use std::hash::{Hash, Hasher};
9096    let mut h = std::collections::hash_map::DefaultHasher::new();
9097    bytes.hash(&mut h);
9098    h.finish()
9099}
9100
9101#[cfg(feature = "fs")]
9102fn detect_format(path: &Path) -> Result<Format> {
9103    let ext = path
9104        .extension()
9105        .and_then(|e| e.to_str())
9106        .unwrap_or("")
9107        .to_ascii_lowercase();
9108    Ok(match ext.as_str() {
9109        "dj" | "djot" => Format::Djot,
9110        "md" | "markdown" => Format::Markdown,
9111        "xml" => Format::Xml,
9112        "html" | "htm" => Format::Html,
9113        other => return Err(anyhow!("unknown document extension: .{other}")),
9114    })
9115}
9116
9117#[cfg(test)]
9118mod tests {
9119    use super::*;
9120    use crate::style::{FontFamily, LineSpacing, SizeStep};
9121
9122    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
9123    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
9124    /// without one is a view no user is ever in.
9125    fn doc_in(view: View, name: &str, body: &str) -> Doc {
9126        // The fixture name doubles as the temp file's, so two tests picking the
9127        // same one raced under the parallel runner and read each other's body —
9128        // a green suite proving the wrong thing. The counter makes that
9129        // unreachable rather than asking every future caller to notice.
9130        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9131        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9132        let mut p = std::env::temp_dir();
9133        p.push(format!("leaf_test_{name}_{seq}.md"));
9134        std::fs::write(&p, body).unwrap();
9135        let mut d = Doc::open(p).unwrap();
9136        d.view = view;
9137        if view == View::Wysiwyg {
9138            d.build_visual(80);
9139        }
9140        d
9141    }
9142
9143    // Source-view document for the source-behaviour tests. `Doc::open` now
9144    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
9145    // `wysiwyg_doc` builds the rich-text variant on top of this.
9146    fn doc_with(name: &str, body: &str) -> Doc {
9147        doc_in(View::Source, name, body)
9148    }
9149
9150    /// Every visual row's drawn text — what the reader actually sees, which is
9151    /// the only thing the reveal preference is supposed to change.
9152    fn drawn_rows(d: &Doc) -> Vec<String> {
9153        d.vmap
9154            .rows
9155            .iter()
9156            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9157            .collect()
9158    }
9159
9160    /// Put the caret at the first byte of `needle` and rebuild, so the row under
9161    /// it becomes the revealed line.
9162    fn caret_at(d: &mut Doc, needle: &str) {
9163        d.caret = d.source.find(needle).expect("needle in source");
9164        d.build_visual(80);
9165    }
9166
9167    #[test]
9168    fn blockquote_after_a_list_is_not_bulleted() {
9169        // twig nests a following top-level block quote under the `bullet_list`
9170        // (a direct child, not a `list_item`). The map must render it de-nested —
9171        // `│ quote`, never `• │ quote` — with a blank separator, like any block
9172        // that follows a list. Regression for the "combined list + blockquote" bug.
9173        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
9174        d.build_visual(80);
9175        let rows: Vec<String> = d
9176            .vmap
9177            .rows
9178            .iter()
9179            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9180            .collect();
9181        assert!(
9182            rows.iter().any(|r| r == "│ quote"),
9183            "block quote should render on its own gutter, got rows: {rows:?}"
9184        );
9185        assert!(
9186            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
9187            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
9188        );
9189    }
9190
9191    // ── the map is built at most once per (revision, wrap) ───────────────────
9192    //
9193    // A frontend repaints for reasons that have nothing to do with the text — a
9194    // blinking caret, a scroll — and rebuilding the map is O(document). These
9195    // pin *that the cache fires*, which a passing suite can't tell you: a cache
9196    // that never hits is invisible to every other test in this file.
9197    //
9198    // The probe is to wreck the built map and ask for it again. A rebuild
9199    // repairs it; a cache hit hands the wreckage straight back. Nothing else
9200    // can distinguish the two from outside.
9201
9202    #[test]
9203    fn a_rebuild_with_nothing_changed_reuses_the_map() {
9204        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
9205        d.build_visual(80);
9206        assert!(!d.vmap.rows.is_empty());
9207        d.vmap.rows.clear(); // wreck it
9208        d.build_visual(80);
9209        assert!(
9210            d.vmap.rows.is_empty(),
9211            "the map was rebuilt though nothing changed — the cache never fired"
9212        );
9213    }
9214
9215    #[test]
9216    fn an_edit_rebuilds_the_map() {
9217        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
9218        d.build_visual(80);
9219        let before = d.revision();
9220        d.vmap.rows.clear();
9221        d.insert("x");
9222        d.build_visual(80);
9223        assert!(d.revision() > before, "an edit must move the revision");
9224        assert!(
9225            !d.vmap.rows.is_empty(),
9226            "an edited document must not paint from a stale map"
9227        );
9228    }
9229
9230    #[test]
9231    fn a_width_change_rebuilds_the_map() {
9232        // The map is a function of the wrap width too, so a resize is a miss
9233        // even though the text is untouched.
9234        let mut d = doc_in(
9235            View::Wysiwyg,
9236            "cache_width",
9237            "one two three four five six\n",
9238        );
9239        d.build_visual(80);
9240        d.vmap.rows.clear();
9241        d.build_visual(12);
9242        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
9243        // And the unwrapped map is its own key, not the same as any width.
9244        d.vmap.rows.clear();
9245        d.build_visual_unwrapped();
9246        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
9247    }
9248
9249    #[test]
9250    fn a_motion_does_not_rebuild_the_map() {
9251        // The whole point: moving the caret changes nothing the map is built
9252        // from. If a motion bumped the revision, every arrow key would cost a
9253        // full rebuild and the cache would be worthless.
9254        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
9255        d.build_visual(80);
9256        let rev = d.revision();
9257        d.move_right(false);
9258        d.move_right(true);
9259        d.move_down(false);
9260        assert_eq!(d.revision(), rev, "a motion must not move the revision");
9261        d.vmap.rows.clear();
9262        d.build_visual(80);
9263        assert!(
9264            d.vmap.rows.is_empty(),
9265            "a motion should not rebuild the map"
9266        );
9267    }
9268
9269    #[test]
9270    fn saving_does_not_rebuild_the_map() {
9271        // Saving changes `dirty`, not the text.
9272        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
9273        d.insert("x");
9274        d.build_visual(80);
9275        let rev = d.revision();
9276        d.save();
9277        assert_eq!(d.revision(), rev, "a save must not move the revision");
9278        assert!(!d.dirty, "the save should have cleaned the document");
9279    }
9280
9281    #[test]
9282    fn a_reload_rebuilds_the_map() {
9283        // Reload replaces the text without going through `refresh`, so it has to
9284        // move the revision itself — else the editor paints the old file.
9285        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
9286        d.build_visual(80);
9287        let rev = d.revision();
9288        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
9289        d.reload();
9290        assert!(d.revision() > rev, "a reload must move the revision");
9291        d.build_visual(80);
9292        let text: String = d
9293            .vmap
9294            .rows
9295            .iter()
9296            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
9297            .collect();
9298        assert!(
9299            text.contains("wholly new"),
9300            "the reloaded text should be on screen, got {text:?}"
9301        );
9302    }
9303
9304    // ── golden-case harness ──────────────────────────────────────────────────
9305    // The pattern the whole parity suite can reuse: write a fixture with the
9306    // caret marked by `|`, run one action, and compare the rendered result —
9307    // also caret-marked — against the expected string. One readable line per
9308    // behavior, and it exercises the exact `Doc` ops both frontends call.
9309
9310    /// Split a `|`-marked fixture into `(source, caret_offset)`.
9311    fn parse_caret(marked: &str) -> (String, usize) {
9312        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
9313        (marked.replacen('|', "", 1), caret)
9314    }
9315
9316    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
9317    /// selection) so a result reads like the fixtures.
9318    fn render_caret(d: &Doc) -> String {
9319        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
9320        // so the caret always renders inside its own selection.
9321        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
9322        if let Some((s, e)) = d.selection() {
9323            marks.push((s, 0, '['));
9324            marks.push((e, 2, ']'));
9325        }
9326        // Insert right-to-left: descending offset, then descending rank.
9327        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
9328        let mut out = d.source.clone();
9329        for (at, _, ch) in marks {
9330            out.insert(at, ch);
9331        }
9332        out
9333    }
9334
9335    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
9336    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
9337        golden_in(View::Source, name, marked, action)
9338    }
9339
9340    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
9341    /// the same fixture has to read the same way in both.
9342    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
9343        let (src, caret) = parse_caret(marked);
9344        let mut d = doc_in(view, name, &src);
9345        d.caret = caret;
9346        action(&mut d);
9347        render_caret(&d)
9348    }
9349
9350    #[test]
9351    fn word_motion_walks_word_by_word() {
9352        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
9353        assert_eq!(
9354            g("hello wor|ld", |d| d.move_word_left(false)),
9355            "hello |world"
9356        );
9357        assert_eq!(
9358            g("hello| world", |d| d.move_word_left(false)),
9359            "|hello world"
9360        );
9361        assert_eq!(
9362            g("hel|lo world", |d| d.move_word_right(false)),
9363            "hello| world"
9364        );
9365        assert_eq!(
9366            g("hello| world", |d| d.move_word_right(false)),
9367            "hello world|"
9368        );
9369        // Punctuation is its own class, so motion stops at the boundary.
9370        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
9371    }
9372
9373    #[test]
9374    fn word_motion_extends_the_selection_when_asked() {
9375        assert_eq!(
9376            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
9377            "hello [world|]"
9378        );
9379    }
9380
9381    #[test]
9382    fn delete_word_removes_a_whole_word() {
9383        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
9384        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
9385        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
9386        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
9387    }
9388
9389    // ── Home / End ───────────────────────────────────────────────────────────
9390
9391    #[test]
9392    fn home_toggles_between_the_line_s_text_and_its_margin() {
9393        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
9394        // indent to the markup it spells everywhere it means one, so the fixture
9395        // with whitespace left to walk is a code block, which is verbatim.
9396        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
9397        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
9398        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
9399        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
9400        // A line with no indentation has one place to go, so the toggle is a
9401        // no-op rather than a trip to nowhere.
9402        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
9403        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
9404
9405        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
9406        let indent = d.source.find("    indented").unwrap();
9407        d.caret = indent + 6; // inside "indented"
9408        d.move_home(false);
9409        assert_eq!(
9410            d.caret,
9411            indent + 4,
9412            "wysiwyg: Home aims at the code line's text"
9413        );
9414        d.move_home(false);
9415        assert_eq!(
9416            d.caret, indent,
9417            "wysiwyg: the second press takes the indent"
9418        );
9419        d.move_home(false);
9420        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
9421    }
9422
9423    #[test]
9424    fn end_takes_the_line_the_view_is_showing() {
9425        // The line differs by view for the same document, and that is the point:
9426        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
9427        // as a space on one row and the source view as two lines.
9428        let mut d = doc_with("end_src", "one two\nthree\n");
9429        d.caret = 1;
9430        d.move_end(false);
9431        assert_eq!(d.caret, 7, "source: the end of the source line");
9432
9433        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
9434        d.caret = 1;
9435        d.move_end(false);
9436        assert_eq!(
9437            d.caret, 13,
9438            "wysiwyg: the end of the row, soft break and all"
9439        );
9440    }
9441
9442    #[test]
9443    fn home_and_end_extend_the_selection_when_asked() {
9444        for (view, tag) in VIEWS {
9445            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
9446            d.caret = 6;
9447            d.move_end(true);
9448            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
9449            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
9450            d.caret = 6;
9451            d.move_home(true);
9452            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
9453        }
9454    }
9455
9456    // ── kill to the line's start / end ───────────────────────────────────────
9457
9458    #[test]
9459    fn kill_to_the_line_start_and_end_in_both_views() {
9460        for (view, tag) in VIEWS {
9461            // The gap that reads as a paragraph break in each view: the source
9462            // view's lines are the renderer's rows only where the source says so.
9463            let gap = if view == View::Source { "\n" } else { "\n\n" };
9464            let mut d = doc_in(
9465                view,
9466                &format!("kill_end_{tag}"),
9467                &format!("one two{gap}three\n"),
9468            );
9469            d.caret = 3;
9470            d.delete_to_line_end();
9471            assert_eq!(
9472                d.source,
9473                format!("one{gap}three\n"),
9474                "{tag}: ^K to the line's end"
9475            );
9476            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
9477
9478            let mut d = doc_in(
9479                view,
9480                &format!("kill_start_{tag}"),
9481                &format!("one two{gap}three\n"),
9482            );
9483            d.caret = 7; // the end of the first line
9484            d.delete_to_line_start();
9485            assert_eq!(
9486                d.source,
9487                format!("{gap}three\n"),
9488                "{tag}: ⌘⌫ to the line's start"
9489            );
9490            assert_eq!(d.caret, 0, "{tag}");
9491        }
9492    }
9493
9494    #[test]
9495    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
9496        // The decision: at the boundary both kills do nothing, rather than
9497        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
9498        // at a soft wrap as often as at a newline, where there is nothing
9499        // written to delete — and a source newline is only half of the blank
9500        // line between two paragraphs, so taking it leaves a soft break rather
9501        // than the join it looks like. Backspace and Delete are the keys for it.
9502        for (view, tag) in VIEWS {
9503            let gap = if view == View::Source { "\n" } else { "\n\n" };
9504            let src = format!("one{gap}three\n");
9505            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
9506            d.caret = 3; // the end of "one"
9507            d.delete_to_line_end();
9508            assert_eq!(
9509                d.source, src,
9510                "{tag}: ^K at the line's end joined it to the next"
9511            );
9512
9513            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
9514            d.caret = 3 + gap.len(); // the start of "three"
9515            d.delete_to_line_start();
9516            assert_eq!(
9517                d.source, src,
9518                "{tag}: ⌘⌫ at the line's start joined it to the last"
9519            );
9520        }
9521    }
9522
9523    #[test]
9524    fn a_kill_takes_the_selection_when_there_is_one() {
9525        // What every other delete here does with one, so these two as well.
9526        for (view, tag) in VIEWS {
9527            for (name, kill) in [
9528                (
9529                    "end",
9530                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
9531                ),
9532                ("start", |d: &mut Doc| d.delete_to_line_start()),
9533            ] {
9534                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
9535                d.anchor = Some(4);
9536                d.caret = 7; // "two"
9537                kill(&mut d);
9538                assert_eq!(
9539                    d.source, "one  three\n",
9540                    "{tag}: {name} ignored the selection"
9541                );
9542                assert_eq!(d.selection(), None, "{tag}: {name}");
9543            }
9544        }
9545    }
9546
9547    #[test]
9548    fn a_kill_takes_the_markup_it_empties_with_it() {
9549        // The same hazard a word-delete has: a WYSIWYG range covers what the
9550        // user can see, which for `**bold**` is the word and never the
9551        // delimiters, so a kill that stopped at the text would leave `a ****` —
9552        // markup wrapped around nothing.
9553        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
9554        d.caret = d.source.find("bold").unwrap();
9555        d.delete_to_line_end();
9556        assert_eq!(d.source, "a \n");
9557    }
9558
9559    #[test]
9560    fn a_kill_is_undone_in_one_step() {
9561        for (view, tag) in VIEWS {
9562            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
9563            d.caret = 3;
9564            d.delete_to_line_end();
9565            assert_eq!(d.source, "one\n", "{tag}");
9566            d.undo();
9567            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
9568        }
9569    }
9570
9571    #[test]
9572    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
9573        // Regression: triple-click used move_home/move_end over visual rows, so
9574        // it only worked on a paragraph's first row (a wrap-boundary offset maps
9575        // to the earlier row). select_block_at reads the AST, so every offset in
9576        // the paragraph selects the whole thing.
9577        let body = "one two three four five six seven eight\n";
9578        let mut d = doc_with("sel_block", body);
9579        d.view = View::Wysiwyg;
9580        d.build_visual(12); // force the paragraph to wrap into several rows
9581        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
9582        let para = (0, "one two three four five six seven eight".len());
9583        for off in [0usize, 8, 19, 28, 38] {
9584            d.caret = 0;
9585            d.anchor = None;
9586            d.select_block_at(off);
9587            assert_eq!(
9588                d.selection(),
9589                Some(para),
9590                "offset {off} should select the paragraph"
9591            );
9592        }
9593    }
9594
9595    #[test]
9596    fn select_block_uses_content_span_for_a_heading() {
9597        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
9598        d.select_block_at(4); // inside "Title"
9599        // content_span excludes the "# " marker.
9600        assert_eq!(d.selected_text(), Some("Title"));
9601        d.select_block_at(10); // inside "body"
9602        assert_eq!(d.selected_text(), Some("body"));
9603    }
9604
9605    #[test]
9606    fn select_all_spans_the_document() {
9607        let mut d = doc_with("sel_all", "abc\n\ndef\n");
9608        d.select_all();
9609        assert_eq!(d.selection(), Some((0, d.source.len())));
9610    }
9611
9612    #[test]
9613    fn select_word_at_picks_the_surrounding_word() {
9614        let mut d = doc_with("sel_word", "hello world\n");
9615        d.select_word_at(8); // inside "world"
9616        assert_eq!(d.selection(), Some((6, 11)));
9617        // Double-clicking at end-of-word still grabs the word to its left.
9618        d.select_word_at(5); // the space between the words
9619        assert_eq!(d.selection(), Some((5, 6)));
9620    }
9621
9622    #[test]
9623    fn word_helpers_respect_utf8_boundaries() {
9624        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
9625        assert_eq!(
9626            golden("utf8", "|café ok", |d| d.move_word_right(false)),
9627            "café| ok"
9628        );
9629        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
9630    }
9631
9632    #[test]
9633    fn typing_inserts_at_the_caret_and_advances_it() {
9634        let mut d = doc_with("type", "hello\n");
9635        d.insert("Hi ");
9636        assert_eq!(d.source, "Hi hello\n");
9637        assert_eq!(d.caret, 3);
9638        assert!(d.dirty);
9639    }
9640
9641    #[test]
9642    fn backspace_deletes_the_char_before_the_caret() {
9643        let mut d = doc_with("bs", "hello\n");
9644        d.caret = 3; // after "hel"
9645        d.backspace();
9646        assert_eq!(d.source, "helo\n");
9647        assert_eq!(d.caret, 2);
9648    }
9649
9650    #[test]
9651    fn typing_replaces_the_selection() {
9652        let mut d = doc_with("replace", "a word b\n");
9653        d.anchor = Some(2);
9654        d.caret = 6; // "word" selected
9655        d.insert("X");
9656        assert_eq!(d.source, "a X b\n");
9657        assert_eq!(d.caret, 3);
9658        assert_eq!(d.anchor, None);
9659    }
9660
9661    #[test]
9662    fn toggle_bold_wraps_then_unwraps_the_selection() {
9663        let mut d = doc_with("bold", "a word b\n");
9664        d.anchor = Some(2);
9665        d.caret = 6;
9666        d.toggle(InlineKind::Strong);
9667        assert_eq!(d.source, "a **word** b\n");
9668        // The toggled region stays selected, so a second toggle reverses it.
9669        d.toggle(InlineKind::Strong);
9670        assert_eq!(d.source, "a word b\n");
9671        d.toggle(InlineKind::Strong);
9672        assert_eq!(d.source, "a **word** b\n");
9673    }
9674
9675    #[test]
9676    fn toggle_code_wraps_then_unwraps_the_selection() {
9677        let mut d = doc_with("code_rt", "a word b\n");
9678        d.anchor = Some(2);
9679        d.caret = 6;
9680        d.toggle(InlineKind::Verbatim);
9681        assert_eq!(d.source, "a `word` b\n");
9682        d.toggle(InlineKind::Verbatim);
9683        assert_eq!(d.source, "a word b\n");
9684    }
9685
9686    #[test]
9687    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
9688        // ⌘b at a bare caret, then type: the text comes out bold with no
9689        // selection ever made — the word-processor "start bold here" gesture.
9690        let mut d = doc_with("sticky_wrap", "xy\n");
9691        d.caret = 1; // between x and y
9692        d.toggle(InlineKind::Strong);
9693        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
9694        d.insert("A");
9695        assert_eq!(d.source, "x**A**y\n");
9696    }
9697
9698    #[test]
9699    fn sticky_bold_lights_the_toolbar_before_any_typing() {
9700        // The button must light the instant ⌘b is pressed, or the mode is
9701        // invisible until the first character lands.
9702        let mut d = doc_with("sticky_light", "xy\n");
9703        d.caret = 1;
9704        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9705        d.toggle(InlineKind::Strong);
9706        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9707    }
9708
9709    #[test]
9710    fn sticky_bold_toggled_off_types_normally_again() {
9711        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
9712        // in the flow of typing, the exact sequence the user described.
9713        let mut d = doc_with("sticky_off", "\n");
9714        d.caret = 0;
9715        d.toggle(InlineKind::Strong);
9716        d.insert("a");
9717        d.insert("b"); // continues inside the run, no re-arming
9718        assert_eq!(d.source, "**ab**\n");
9719        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
9720        d.insert("c");
9721        assert_eq!(d.source, "**ab**c\n");
9722    }
9723
9724    #[test]
9725    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
9726        // Once a mark is realised the caret sits inside the run, so plain typing
9727        // extends it rather than starting a second, adjacent bold span.
9728        let mut d = doc_with("sticky_cont", "\n");
9729        d.caret = 0;
9730        d.toggle(InlineKind::Emph);
9731        d.insert("h");
9732        d.insert("i");
9733        assert_eq!(d.source, "*hi*\n");
9734    }
9735
9736    #[test]
9737    fn moving_the_caret_disarms_a_sticky_mark() {
9738        // Arming a mark and then moving away must not style text elsewhere.
9739        let mut d = doc_with("sticky_disarm", "xy\n");
9740        d.caret = 0;
9741        d.toggle(InlineKind::Strong);
9742        d.move_right(false); // caret 0 → 1, disarms
9743        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9744        d.insert("A");
9745        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
9746    }
9747
9748    #[test]
9749    fn stacked_sticky_marks_apply_together() {
9750        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
9751        let mut d = doc_with("sticky_stack", "\n");
9752        d.caret = 0;
9753        d.toggle(InlineKind::Strong);
9754        d.toggle(InlineKind::Emph);
9755        d.insert("x");
9756        // Land the caret on the styled character and confirm both marks are live.
9757        d.anchor = Some(d.source.find('x').unwrap());
9758        d.caret = d.anchor.unwrap() + 1;
9759        let marks = d.active_inline_marks();
9760        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
9761        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
9762    }
9763
9764    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
9765
9766    #[test]
9767    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
9768        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
9769        // The space inside the run made `**bold **`, which is *not* bold — four
9770        // literal asterisks — so the rich view drew them, correctly and
9771        // uselessly, until the next character happened to close the run again.
9772        let mut d = wysiwyg_doc("edge_typing", "a \n");
9773        d.caret = 2;
9774        d.toggle(InlineKind::Strong);
9775        for c in "bold".chars() {
9776            d.insert(&c.to_string());
9777        }
9778        assert_eq!(d.source, "a **bold**\n");
9779        d.insert(" ");
9780        assert_eq!(
9781            d.source, "a **bold** \n",
9782            "the space belongs outside the run"
9783        );
9784        assert!(
9785            d.active_inline_marks().contains(InlineKind::Strong),
9786            "bold is still what's being typed, so the button stays lit"
9787        );
9788        // What the writer is looking at while all this happens: their words.
9789        d.build_visual(80);
9790        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9791        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
9792        for c in "hey".chars() {
9793            d.insert(&c.to_string());
9794        }
9795        assert_eq!(
9796            d.source, "a **bold hey**\n",
9797            "one bold phrase, not two runs"
9798        );
9799    }
9800
9801    #[test]
9802    fn typing_past_a_space_can_still_leave_the_bold_behind() {
9803        // The other half: the marks stay armed across the space, so ⌘b turns
9804        // them off again there and the next word is plain — the run isn't
9805        // rejoined by a caret that was told not to.
9806        let mut d = wysiwyg_doc("edge_shed", "\n");
9807        d.caret = 0;
9808        d.toggle(InlineKind::Strong);
9809        for c in "bold ".chars() {
9810            d.insert(&c.to_string());
9811        }
9812        assert_eq!(d.source, "**bold** \n");
9813        d.toggle(InlineKind::Strong);
9814        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9815        d.insert("x");
9816        assert_eq!(d.source, "**bold** x\n");
9817    }
9818
9819    #[test]
9820    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
9821        // ⌘b and then a space before any word: the space is not marked (nothing
9822        // is), and the word after it is.
9823        let mut d = wysiwyg_doc("edge_space_first", "a\n");
9824        d.caret = 1;
9825        d.toggle(InlineKind::Strong);
9826        d.insert(" ");
9827        assert_eq!(d.source, "a \n");
9828        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9829        d.insert("b");
9830        assert_eq!(d.source, "a **b**\n");
9831    }
9832
9833    #[test]
9834    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
9835        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
9836        d.caret = 8; // the caret's home at the end of the run's text
9837        d.insert(" ");
9838        assert_eq!(
9839            d.source, "x **bold** \n",
9840            "the space lands past the delimiters"
9841        );
9842        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
9843
9844        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
9845        d.caret = 4; // in front of the "b"
9846        d.insert(" ");
9847        assert_eq!(d.source, "x  **bold** y\n");
9848        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
9849    }
9850
9851    #[test]
9852    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
9853        // Backspace over the last letter of a bold phrase.
9854        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
9855        d.caret = 10; // past the "h"
9856        d.backspace();
9857        assert_eq!(d.source, "a **bold** \n");
9858        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
9859        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9860        d.insert("x");
9861        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
9862    }
9863
9864    #[test]
9865    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
9866        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
9867        // mark, which is only text. The marks live on in the caret instead.
9868        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
9869        d.caret = 5;
9870        d.backspace();
9871        assert_eq!(d.source, "a  c\n");
9872        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9873        d.insert("x");
9874        assert_eq!(d.source, "a **x** c\n");
9875    }
9876
9877    #[test]
9878    fn typing_over_a_whole_bold_word_keeps_it_bold() {
9879        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
9880        d.anchor = Some(4);
9881        d.caret = 8; // the word, not its delimiters
9882        d.insert("x");
9883        assert_eq!(d.source, "a **x** c\n");
9884    }
9885
9886    #[test]
9887    fn a_code_span_keeps_the_space_it_is_given() {
9888        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
9889        // is still verbatim, so nothing is re-spelt. The repair asks the parser
9890        // rather than a table of kinds, and this is the answer it gets.
9891        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
9892        d.caret = 7;
9893        d.insert(" ");
9894        assert_eq!(d.source, "a `code ` c\n");
9895    }
9896
9897    #[test]
9898    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
9899        // A run's closing delimiter has a caret home on each side of it, one
9900        // column apart on screen — and a plain ← off the space after a bold word
9901        // lands on the outer one. The character drawn behind the caret there is
9902        // still the last letter of the phrase, so that is what Backspace takes;
9903        // the byte behind it is a `*` nobody can see.
9904        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
9905        d.caret = 9;
9906        d.move_left(false);
9907        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
9908        d.backspace();
9909        assert_eq!(
9910            d.source, "**bol** x\n",
9911            "a letter of the phrase, not its `*`"
9912        );
9913        assert_eq!(d.caret, 5);
9914
9915        // And the mirror in front of the opening delimiter, where Delete's
9916        // character is the first letter of the run.
9917        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
9918        d.caret = 1;
9919        d.delete_forward();
9920        assert_eq!(d.source, "x**old**\n");
9921        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
9922    }
9923
9924    #[test]
9925    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
9926        // The byte beside the caret at either edge of a bold word is a `*` the
9927        // rich view draws nothing for. Taking it is not the character delete the
9928        // key was pressed for — it unspells the run and puts a literal asterisk
9929        // on screen (`a *bold** c`). The visible character is the one that goes.
9930        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
9931        d.caret = 4; // in front of the "b"
9932        d.backspace();
9933        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
9934
9935        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
9936        d.caret = 8; // past the "d"
9937        d.delete_forward();
9938        assert_eq!(d.source, "a **bold**c\n");
9939        assert_eq!(d.caret, 8, "and the caret stays inside the run");
9940        d.insert("x");
9941        assert_eq!(d.source, "a **boldx**c\n");
9942
9943        // A code span's backticks are hidden the same way, so they are covered
9944        // by the same rule and not by a list of kinds.
9945        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
9946        d.caret = 3;
9947        d.backspace();
9948        assert_eq!(d.source, "a`code` c\n");
9949    }
9950
9951    #[test]
9952    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
9953        // The asterisks are on the screen there and the caret can stand between
9954        // them, so a delete takes exactly the byte it is aimed at.
9955        let mut d = doc_with("edge_open_src", "a **bold** c\n");
9956        d.caret = 4;
9957        d.backspace();
9958        assert_eq!(d.source, "a *bold** c\n");
9959
9960        let mut d = doc_with("edge_close_src", "a **bold** c\n");
9961        d.caret = 8;
9962        d.delete_forward();
9963        assert_eq!(d.source, "a **bold* c\n");
9964    }
9965
9966    #[test]
9967    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
9968        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
9969        // The space had stepped outside the run (the mark-edge rule), taking the
9970        // caret with it, so the delete put it back down on the far side of the
9971        // closing `**` — one place on screen, and the wrong side of it. Typing
9972        // came out plain and the toolbar went dark, with nothing to see.
9973        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
9974        d.caret = 0;
9975        d.toggle(InlineKind::Strong);
9976        for c in "bold".chars() {
9977            d.insert(&c.to_string());
9978        }
9979        d.insert(" ");
9980        assert_eq!(d.source, "**bold** \n");
9981        d.backspace();
9982        assert_eq!(
9983            d.source, "**bold**\n",
9984            "the space goes, the delimiters stay"
9985        );
9986        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
9987        assert!(
9988            d.active_inline_marks().contains(InlineKind::Strong),
9989            "so the button is still lit"
9990        );
9991        d.insert("x");
9992        assert_eq!(
9993            d.source, "**boldx**\n",
9994            "and the next character is still bold"
9995        );
9996    }
9997
9998    #[test]
9999    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
10000        // What the stranded caret did next: the byte behind it was the closing
10001        // `*`, so a second press took that instead of a letter — `**bold*`, the
10002        // styling gone and an asterisk on the screen where the word had been.
10003        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
10004        d.caret = 0;
10005        d.toggle(InlineKind::Strong);
10006        for c in "bold ".chars() {
10007            d.insert(&c.to_string());
10008        }
10009        assert_eq!(d.source, "**bold** \n");
10010        d.backspace();
10011        d.backspace();
10012        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
10013        assert_eq!(d.caret, 5);
10014    }
10015
10016    #[test]
10017    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
10018        // `***both***` closes two runs with one stack of asterisks: the caret has
10019        // to walk in through all of them, or it lands between the emph and the
10020        // strong and types half-marked.
10021        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
10022        d.caret = 11;
10023        d.backspace();
10024        assert_eq!(d.source, "***both***\n");
10025        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
10026        d.insert("x");
10027        assert_eq!(d.source, "***bothx***\n");
10028    }
10029
10030    #[test]
10031    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
10032        // The settle only moves a caret a run actually closed over. Ordinary
10033        // deletes — inside a run, or in plain prose — are untouched.
10034        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
10035        d.caret = 8;
10036        d.backspace();
10037        assert_eq!(d.source, "a **bol** c\n");
10038        assert_eq!(d.caret, 7);
10039
10040        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
10041        d.caret = 5;
10042        d.backspace();
10043        assert_eq!(d.source, "plai\n");
10044        assert_eq!(d.caret, 4);
10045    }
10046
10047    #[test]
10048    fn the_source_view_leaves_a_delete_where_it_landed() {
10049        // The delimiters are on the screen there, so the offset past them is a
10050        // place the caret can be seen to be — nothing to settle.
10051        let mut d = doc_with("edge_bksp_src", "**bold** \n");
10052        d.caret = 9;
10053        d.backspace();
10054        assert_eq!(d.source, "**bold**\n");
10055        assert_eq!(d.caret, 8);
10056    }
10057
10058    #[test]
10059    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
10060        // `***both***` closes two runs with one stack of asterisks; a space that
10061        // clears only the inner one lands against the outer's and breaks that
10062        // instead.
10063        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
10064        d.caret = 9;
10065        d.insert(" ");
10066        assert_eq!(d.source, "a ***both*** \n");
10067        assert_eq!(d.caret, 13);
10068        d.insert("x");
10069        assert_eq!(d.source, "a ***both x***\n");
10070    }
10071
10072    #[test]
10073    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
10074        // The delimiter shuffle is not an edit the writer made, so it is not a
10075        // step they have to undo past.
10076        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
10077        d.caret = 8;
10078        d.insert(" ");
10079        assert_eq!(d.source, "a **bold** \n");
10080        d.undo();
10081        assert_eq!(d.source, "a **bold**\n");
10082    }
10083
10084    #[test]
10085    fn the_source_view_types_the_space_where_it_was_asked_to() {
10086        // The rule is a rich-view courtesy. In the source view the delimiters are
10087        // on the screen and the user is editing the bytes they can see.
10088        let mut d = doc_with("edge_src", "a **bold** c\n");
10089        d.caret = 8;
10090        d.insert(" ");
10091        assert_eq!(d.source, "a **bold ** c\n");
10092    }
10093
10094    #[test]
10095    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
10096        // Double-clicking a word takes the space after it; bolding that must not
10097        // spell `**word **`, which is not bold at all.
10098        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
10099        d.anchor = Some(2);
10100        d.caret = 7; // "word "
10101        d.toggle(InlineKind::Strong);
10102        assert_eq!(d.source, "a **word** b\n");
10103        d.toggle(InlineKind::Strong);
10104        assert_eq!(d.source, "a word b\n");
10105        d.toggle(InlineKind::Strong);
10106        assert_eq!(
10107            d.source, "a **word** b\n",
10108            "reapplying the mark must not wrap stale delimiter offsets"
10109        );
10110        // And a selection of nothing but whitespace has no word to mark.
10111        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
10112        d.anchor = Some(6);
10113        d.caret = 7;
10114        d.toggle(InlineKind::Strong);
10115        assert_eq!(d.source, "a word b\n");
10116        assert!(d.status.is_some());
10117    }
10118
10119    #[test]
10120    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
10121        let mut d = doc_with("head_set", "hello\n");
10122        d.caret = 2; // caret inside the paragraph, no selection
10123        d.set_block(BlockKind::Heading(1));
10124        assert_eq!(d.source, "# hello\n");
10125    }
10126
10127    #[test]
10128    fn set_block_heading_works_in_wysiwyg_view() {
10129        // The app defaults to WYSIWYG; the caret is a source offset either way.
10130        let mut d = wysiwyg_doc("head_wys", "hello\n");
10131        d.caret = 2;
10132        d.set_block(BlockKind::Heading(1));
10133        assert_eq!(d.source, "# hello\n");
10134    }
10135
10136    #[test]
10137    fn toggle_heading_applies_switches_and_reverts() {
10138        let mut d = doc_with("head_toggle", "hello\n");
10139        d.caret = 2;
10140        d.toggle_heading(1);
10141        assert_eq!(d.source, "# hello\n"); // paragraph → H1
10142        d.toggle_heading(2);
10143        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
10144        d.toggle_heading(2);
10145        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
10146    }
10147
10148    #[test]
10149    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
10150        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
10151        // the blank line but the caret rendered on the *next* line, because the
10152        // separator was a non-navigable decoration row. In Preserve flow that
10153        // blank line is a real caret home — the caret must resolve onto it, and
10154        // typing there makes the soft break that continues the paragraph.
10155        let src = "line one:\nsecond line\n";
10156        let mut d = wysiwyg_doc("pre_enter_lineend", src);
10157        d.set_line_flow(LineFlow::Preserve);
10158        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
10159        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
10160        d.newline();
10161        d.build_visual_unwrapped();
10162        assert_eq!(d.source, "line one:\n\nsecond line\n");
10163        assert_eq!(
10164            d.caret, 10,
10165            "caret sits on the new blank line, not the next line"
10166        );
10167        // The blank line is row 1, and the caret resolves onto it — not row 2.
10168        assert_eq!(
10169            d.vmap.pos_of_offset(10),
10170            (1, 0),
10171            "caret renders on the blank row"
10172        );
10173        assert!(
10174            !d.vmap.rows[1].decoration,
10175            "the blank line is navigable in Preserve"
10176        );
10177        // Typing there makes a soft break: one paragraph, three lines.
10178        d.insert("new clause,");
10179        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
10180    }
10181
10182    #[test]
10183    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
10184        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
10185        // that keeps it one paragraph — where Fold would open a second paragraph.
10186        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
10187        d.set_line_flow(LineFlow::Preserve);
10188        d.caret = 3;
10189        d.newline();
10190        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
10191
10192        // End-of-paragraph: Enter then typing continues the same paragraph on a
10193        // new line (a soft break), not a fresh paragraph.
10194        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
10195        d.set_line_flow(LineFlow::Preserve);
10196        d.caret = 3;
10197        d.newline();
10198        d.insert("def");
10199        assert_eq!(
10200            d.source, "abc\ndef\n",
10201            "end-of-line Enter + typing is a soft break"
10202        );
10203    }
10204
10205    #[test]
10206    fn preserve_double_enter_still_makes_a_paragraph() {
10207        // Two Enters in a row promote to a real paragraph break: the second lands
10208        // on the blank line the first opened and takes the empty-line branch.
10209        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
10210        d.set_line_flow(LineFlow::Preserve);
10211        d.caret = 3;
10212        d.newline();
10213        d.newline();
10214        d.insert("def");
10215        assert_eq!(
10216            d.source, "abc\n\ndef\n",
10217            "double Enter is a paragraph break"
10218        );
10219    }
10220
10221    #[test]
10222    fn preserve_backspace_joins_across_a_soft_break() {
10223        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
10224        // soft break it deletes the single newline and joins the two lines.
10225        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
10226        d.set_line_flow(LineFlow::Preserve);
10227        d.build_visual(80);
10228        d.caret = 4; // start of "def", just past the soft break
10229        d.backspace();
10230        assert_eq!(
10231            d.source, "abcdef\n",
10232            "Backspace joins across the soft break"
10233        );
10234        assert_eq!(d.caret, 3, "caret lands where the lines meet");
10235    }
10236
10237    #[test]
10238    fn fold_enter_still_starts_a_new_paragraph() {
10239        // The default flow is unchanged: a lone `\n` would render as an invisible
10240        // space, so Enter keeps opening the paragraph break that actually shows.
10241        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
10242        d.caret = 3;
10243        d.newline();
10244        assert_eq!(
10245            d.source, "abc\n\ndef\n",
10246            "Fold mid-line Enter is a paragraph break"
10247        );
10248    }
10249
10250    #[test]
10251    fn wysiwyg_one_enter_starts_a_new_paragraph() {
10252        // Regression: one Enter left the caret between the two newlines, so typing
10253        // made a soft break (one paragraph) and you needed a second Enter.
10254        let mut d = wysiwyg_doc("wys_enter", "abc\n");
10255        d.caret = 3;
10256        d.newline();
10257        d.insert("def");
10258        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
10259    }
10260
10261    #[test]
10262    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
10263        // Regression: Enter at the caret's natural End-of-line resting place
10264        // after a bold run with nothing following it (on screen: right after
10265        // "bold", before the hidden closing "**") spliced the paragraph break
10266        // at that very byte offset — which sits *before* the closing "**" in
10267        // the source, since the delimiter is hidden and emits no glyph of its
10268        // own for `push_row`'s "end of row" fallback to count. That severed the
10269        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
10270        // "**" alone on the new line instead of leaving "**bold**" intact with
10271        // a fresh empty paragraph after it.
10272        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
10273        d.move_end(false); // the WYSIWYG End key, from caret 0
10274        assert_eq!(
10275            d.caret, 6,
10276            "caret rests right after \"bold\", before the hidden \"**\""
10277        );
10278        d.newline();
10279        assert!(
10280            d.source.starts_with("**bold**"),
10281            "the closing ** must stay attached to \"bold\": got {:?}",
10282            d.source
10283        );
10284        assert_eq!(
10285            d.source, "**bold**\n\n\n",
10286            "a fresh empty paragraph follows the still-intact bold run"
10287        );
10288    }
10289
10290    #[test]
10291    fn source_view_enter_is_a_single_newline() {
10292        let mut d = doc_with("src_enter", "abc\n");
10293        d.caret = 3;
10294        d.newline();
10295        assert_eq!(d.source, "abc\n\n");
10296    }
10297
10298    #[test]
10299    fn heading_applies_at_the_end_of_a_paragraph() {
10300        // The caret at a line end sits at the doc level; set_block must still find
10301        // the block on that line.
10302        let mut d = doc_with("head_end", "abc\n");
10303        d.caret = 3; // end of "abc"
10304        d.toggle_heading(1);
10305        assert_eq!(d.source, "# abc\n");
10306    }
10307
10308    #[test]
10309    fn heading_on_an_empty_new_paragraph_creates_one() {
10310        let mut d = wysiwyg_doc("head_empty", "abc\n");
10311        d.caret = 3;
10312        d.newline(); // caret now on a fresh, empty paragraph
10313        d.toggle_heading(1);
10314        d.insert("Title");
10315        assert!(d.source.contains("# Title"), "got {:?}", d.source);
10316    }
10317
10318    #[test]
10319    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
10320        // The reported bug, end to end: click a blank line with another one under
10321        // it, press H1, type. The text landed in the heading and the caret's
10322        // offset was right (the source view drew it there), but the rich view
10323        // drew it two rows lower, on the trailing blank line — the empty `# `
10324        // heading had left every row below it short by the marker's two bytes,
10325        // and the blank line ended up claiming the heading's own end offset.
10326        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
10327        d.build_visual_unwrapped();
10328        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
10329        d.toggle_heading(1);
10330        for c in "title".chars() {
10331            d.insert(&c.to_string());
10332            d.build_visual_unwrapped(); // as a frontend does, one frame per key
10333        }
10334        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
10335        assert_eq!(
10336            d.caret_pos(),
10337            (4, 5),
10338            "the caret draws at the end of the heading"
10339        );
10340    }
10341
10342    #[test]
10343    fn clicking_an_empty_heading_types_after_its_marker() {
10344        // The same anchor from the other side: the empty heading's row is its own
10345        // caret home, so a click on it must land past the hidden `# `. Landing in
10346        // front of the hashes made the first keystroke un-heading the line.
10347        let mut d = wysiwyg_doc("head_click", "# \n");
10348        d.build_visual_unwrapped();
10349        d.caret = d.vmap.offset_of_pos(0, 0);
10350        d.insert("x");
10351        assert_eq!(d.source, "# x\n");
10352    }
10353
10354    #[test]
10355    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
10356        let mut d = wysiwyg_doc("head_enter", "# Title\n");
10357        d.caret = 7; // end of the heading
10358        d.newline();
10359        d.insert("body");
10360        assert_eq!(d.source, "# Title\n\nbody\n");
10361    }
10362
10363    #[test]
10364    fn wysiwyg_enter_continues_a_bullet_list() {
10365        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
10366        d.caret = 6; // end of "item"
10367        d.newline();
10368        d.insert("two");
10369        assert_eq!(d.source, "- item\n- two\n");
10370    }
10371
10372    #[test]
10373    fn wysiwyg_enter_increments_an_ordered_list() {
10374        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
10375        d.caret = 6; // end of "one"
10376        d.newline();
10377        d.insert("two");
10378        assert_eq!(d.source, "1. one\n2. two\n");
10379    }
10380
10381    #[test]
10382    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
10383        // Regression for the "extra newline" left between a list and the paragraph
10384        // below it. Enter, Enter leaves the list on a fresh empty paragraph
10385        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
10386        // Backspace should then take the caret cleanly back to the end of the list
10387        // item, `- item\n\nnext`, not delete a single newline and strand it on the
10388        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
10389        // no caret can land on. The map is rebuilt between keystrokes exactly as a
10390        // frontend does, since Backspace reads the stop table to place the delete.
10391        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
10392        d.caret = 6; // end of "item"
10393        d.newline();
10394        d.build_visual(80);
10395        d.newline(); // leave the list onto a fresh empty paragraph
10396        d.build_visual(80);
10397        assert_eq!(
10398            d.source, "- item\n\n\n\nnext\n",
10399            "double-Enter opens the empty paragraph"
10400        );
10401        d.backspace();
10402        assert_eq!(
10403            d.source, "- item\n\nnext\n",
10404            "one Backspace collapses the whole gap"
10405        );
10406        assert_eq!(
10407            d.caret, 6,
10408            "and lands the caret back at the end of the list item"
10409        );
10410    }
10411
10412    #[test]
10413    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
10414        // The stop-wise delete must not over-reach when there is no block boundary
10415        // to cross: two blank lines in a row are one caret stop apart, so pressing
10416        // Enter on an empty line and then Backspace removes exactly the one newline
10417        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
10418        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
10419        d.caret = 5; // the empty paragraph the first Enter already opened
10420        d.build_visual(80);
10421        d.newline();
10422        d.build_visual(80);
10423        assert_eq!(
10424            d.source, "abc\n\n\n\n",
10425            "Enter on the blank line adds one newline"
10426        );
10427        d.backspace();
10428        assert_eq!(
10429            d.source, "abc\n\n\n",
10430            "Backspace takes back exactly that one newline"
10431        );
10432    }
10433
10434    #[test]
10435    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
10436        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
10437        d.caret = 6; // end of the empty "- " item
10438        d.newline();
10439        d.insert("p");
10440        assert_eq!(d.source, "- a\n\np\n");
10441    }
10442
10443    #[test]
10444    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
10445        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
10446        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
10447        // not take the list-exit path (which would splice the `- ` away as if
10448        // leaving an empty item); the AST guard sends it to a normal break and
10449        // leaves the underline intact.
10450        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
10451        assert!(
10452            d.nodes().iter().any(|n| n.kind == Kind::Heading),
10453            "precondition: twig parses this as a heading, not a list",
10454        );
10455        d.caret = 7; // on the `- ` underline line
10456        d.newline();
10457        assert!(
10458            d.source.contains("- "),
10459            "the setext underline survives, not spliced away as a list item: {:?}",
10460            d.source,
10461        );
10462    }
10463
10464    #[test]
10465    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
10466        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
10467        d.caret = 7; // end of "abc" inside the fence
10468        d.newline();
10469        d.insert("def");
10470        assert_eq!(d.source, "```\nabc\ndef\n```\n");
10471    }
10472
10473    #[test]
10474    fn wysiwyg_enter_continues_a_block_quote() {
10475        // Enter opens a new *paragraph* inside the quote, not a second line of
10476        // the same one. `> quote\n> more` is a soft break, which under
10477        // `LineFlow::Fold` renders as a space — the keystroke would look like it
10478        // did nothing. The quoted blank line is what makes the break visible, and
10479        // it's the same thing Enter does in running prose.
10480        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
10481        d.caret = 7; // end of "quote"
10482        d.newline();
10483        d.insert("more");
10484        assert_eq!(d.source, "> quote\n>\n> more\n");
10485        // Still one quote, now holding two paragraphs — not a quote and a stray
10486        // line that fell out of it.
10487        let quotes = d
10488            .nodes()
10489            .iter()
10490            .filter(|n| n.kind == Kind::BlockQuote)
10491            .count();
10492        assert_eq!(quotes, 1);
10493    }
10494
10495    #[test]
10496    fn set_block_makes_a_heading_at_the_caret() {
10497        let mut d = doc_with("head", "Title\n\nbody\n");
10498        d.caret = 0;
10499        d.set_block(BlockKind::Heading(2));
10500        assert_eq!(d.source, "## Title\n\nbody\n");
10501        d.set_block(BlockKind::Paragraph);
10502        assert_eq!(d.source, "Title\n\nbody\n");
10503    }
10504
10505    // ── block containers (quote / list) ──────────────────────────────────────
10506
10507    #[test]
10508    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
10509        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
10510        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
10511        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
10512        // A caret at a line end sits at the doc level; the block is still found.
10513        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
10514    }
10515
10516    #[test]
10517    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
10518        // Every source line of the paragraph gets its own `> `, so a caret left
10519        // on its old byte offset falls one prefix per line above it too far
10520        // back — inside the markup it just asked for rather than in its word.
10521        assert_eq!(
10522            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
10523            "> aaa\n> b|bb\n> ccc\n"
10524        );
10525    }
10526
10527    #[test]
10528    fn toggle_blockquote_works_in_wysiwyg_view() {
10529        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10530        assert_eq!(
10531            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
10532            "> hel|lo\n"
10533        );
10534        assert_eq!(
10535            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
10536            "hel|lo\n"
10537        );
10538    }
10539
10540    #[test]
10541    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
10542        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
10543        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10544        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10545        // The *other* kind converts in place instead of nesting, which is what
10546        // makes the two buttons one three-state control.
10547        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10548        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10549        // Its own kind, over the only item the list holds, takes it off.
10550        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
10551    }
10552
10553    #[test]
10554    fn toggle_list_works_in_wysiwyg_view() {
10555        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10556        assert_eq!(
10557            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
10558            "1. hel|lo\n"
10559        );
10560        assert_eq!(
10561            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
10562            "- hel|lo\n"
10563        );
10564        assert_eq!(
10565            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
10566            "hel|lo\n"
10567        );
10568    }
10569
10570    #[test]
10571    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
10572        // The selection has to grow with the markup: twig takes a container off
10573        // only a range covering every block it holds, so the second press can
10574        // reverse the first only if the result is what's selected.
10575        let mut d = doc_with("list_sel", "abc\n\ndef\n");
10576        d.select_all();
10577        d.toggle_list(true);
10578        assert_eq!(d.source, "1. abc\n\n2. def\n");
10579        assert_eq!(d.selection(), Some((0, d.source.len())));
10580        d.toggle_list(true);
10581        assert_eq!(d.source, "abc\n\ndef\n");
10582    }
10583
10584    #[test]
10585    fn toggle_blockquote_nests_a_partly_covered_quote() {
10586        // twig's rule: covering only some of a container's blocks nests, because
10587        // taking the quote off would drag its uncovered siblings out with it.
10588        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
10589        d.caret = 2; // in the first quoted paragraph only
10590        d.toggle_blockquote();
10591        assert_eq!(d.source, "> > a\n>\n> b\n");
10592    }
10593
10594    #[test]
10595    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
10596        // A blank line used to be no block for twig to wrap —
10597        // `toggle_block_container` answered `NotFound` — so Quote and the list
10598        // buttons did nothing on the very line the H1 button works on, and leaf
10599        // lent twig a scratch paragraph to wrap and took it back out again.
10600        // twig 3.2.0 opens an empty container there itself, so what is left here
10601        // is where the caret lands: inside the marker that was just written.
10602        let mut d = doc_with("quote_blank", "\nabc\n");
10603        d.caret = 0;
10604        d.toggle_blockquote();
10605        assert_eq!(d.source, "> \nabc\n");
10606        assert_eq!(
10607            d.caret, 2,
10608            "the caret belongs inside the quote it just opened"
10609        );
10610        assert!(d.status.is_none(), "{:?}", d.status);
10611        assert!(d.dirty);
10612
10613        // And the paragraph below is still its own block: an empty container one
10614        // soft break from `abc` would take that paragraph into the quote with it.
10615        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
10616        d.caret = 0;
10617        d.toggle_blockquote();
10618        d.build_visual(80);
10619        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
10620
10621        // The same from the other side: a blank line directly under a paragraph
10622        // earns the blank line an empty block needs, rather than being read as a
10623        // soft break inside that paragraph.
10624        let mut d = doc_with("list_blank_below", "abc\n");
10625        d.caret = 4;
10626        d.toggle_list(false);
10627        assert_eq!(d.source, "abc\n\n- ");
10628        assert_eq!(d.caret, 7);
10629    }
10630
10631    #[test]
10632    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
10633        // The gesture the rendering fix is for. `newline` inside a quote already
10634        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
10635        // spelling — but the two marker lines it adds belonged to no node until
10636        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
10637        // just made drew as plain prose under the quote.
10638        let mut d = wysiwyg_doc("quote_enter", "> a\n");
10639        d.caret = 3; // past `a`, at the end of the quoted line
10640        d.newline();
10641        assert_eq!(d.source, "> a\n>\n> \n");
10642        d.build_visual(80);
10643        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
10644        // And the caret is on the new line, not stranded on the old one.
10645        assert_eq!(d.caret, 8);
10646    }
10647
10648    #[test]
10649    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
10650        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
10651        // now it is twig's single edit. Either way one ⌘z has to put the blank
10652        // line back rather than undoing into a half-built document.
10653        for open in [
10654            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
10655            &|d: &mut Doc| d.toggle_list(false),
10656            &|d: &mut Doc| d.toggle_list(true),
10657        ] {
10658            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
10659            d.caret = 3;
10660            open(&mut d);
10661            assert_ne!(d.source, "a\n\n\n\nb\n");
10662            d.undo();
10663            assert_eq!(d.source, "a\n\n\n\nb\n");
10664        }
10665    }
10666
10667    #[test]
10668    fn a_container_toggle_is_one_undo_step() {
10669        let mut d = doc_with("quote_undo", "hello\n");
10670        d.caret = 3;
10671        d.insert("X"); // a typing run the structural edit must not fold into
10672        d.toggle_blockquote();
10673        assert_eq!(d.source, "> helXlo\n");
10674        d.undo();
10675        assert_eq!(d.source, "helXlo\n");
10676    }
10677
10678    // ── links ────────────────────────────────────────────────────────────────
10679
10680    #[test]
10681    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
10682        let mut d = doc_with("link_sel", "word here\n");
10683        d.anchor = Some(0);
10684        d.caret = 4;
10685        d.insert_link("http://x.dev");
10686        assert_eq!(d.source, "[word](http://x.dev) here\n");
10687        // The text, not the destination — so a second press re-points the link
10688        // the first one made rather than nesting one inside it.
10689        assert_eq!(d.selected_text(), Some("word"));
10690        d.insert_link("http://y.dev");
10691        assert_eq!(d.source, "[word](http://y.dev) here\n");
10692        assert_eq!(d.selected_text(), Some("word"));
10693    }
10694
10695    #[test]
10696    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
10697        let mut d = doc_with("img_caret", "before after\n");
10698        d.caret = 7; // between "before " and "after"
10699        d.insert_image("cat.png", "a cat");
10700        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
10701        // The caret sits just past the inserted image, nothing selected.
10702        assert_eq!(d.selection(), None);
10703        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
10704    }
10705
10706    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
10707    /// destination at the first space, so the `format!` this used to be wrote
10708    /// something that was not an image at all — and the reader saw the markup as
10709    /// text. twig owns the spelling now, and moves it into the angle form.
10710    #[test]
10711    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
10712        let mut d = doc_with("img_space", "x\n");
10713        d.caret = 0;
10714        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
10715        assert_eq!(
10716            d.source,
10717            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
10718        );
10719        // And it reads back as an image pointing at the unescaped path — the angle
10720        // brackets are spelling, not part of the destination.
10721        d.caret = 2;
10722        assert_eq!(
10723            d.image_destination_at_caret(),
10724            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
10725        );
10726    }
10727
10728    /// A `)` in a caption or a filename must not close the image early.
10729    #[test]
10730    fn insert_image_escapes_a_paren_in_either_half() {
10731        let mut d = doc_with("img_paren", "x\n");
10732        d.caret = 0;
10733        d.insert_image("a)b.png", "");
10734        assert_eq!(d.source, "![](a\\)b.png)x\n");
10735        d.caret = 2;
10736        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
10737    }
10738
10739    #[test]
10740    fn insert_image_uses_the_selection_as_alt_text() {
10741        let mut d = doc_with("img_sel", "caption here\n");
10742        d.anchor = Some(0);
10743        d.caret = 7; // "caption"
10744        d.insert_image("p.png", "ignored fallback");
10745        assert_eq!(d.source, "![caption](p.png) here\n");
10746    }
10747
10748    #[test]
10749    fn insert_image_with_no_alt_leaves_empty_brackets() {
10750        let mut d = doc_with("img_noalt", "\n");
10751        d.caret = 0;
10752        d.insert_image("logo.svg", "");
10753        assert_eq!(d.source, "![](logo.svg)\n");
10754    }
10755
10756    // ── move_block ─────────────────────────────────────────────────────────────
10757
10758    /// A move, then its undo: one step takes the whole thing back.
10759    fn moved(name: &str, body: &str, from: usize, to: usize) -> (String, Doc) {
10760        let mut d = doc_with(name, body);
10761        d.caret = from;
10762        let steps = d.undo_steps;
10763        d.move_block(from, to);
10764        let after = d.source.clone();
10765        assert_eq!(d.undo_steps, steps + 1, "one undo step");
10766        assert!(d.dirty);
10767        d.undo();
10768        assert_eq!(d.source, body, "one undo restores the original");
10769        (after, d)
10770    }
10771
10772    #[test]
10773    fn move_block_carries_a_paragraph_between_two_paragraphs_and_to_the_end() {
10774        let body = "a\n\nb\n\nc\n";
10775        assert_eq!(moved("mv_p1", body, 6, 3).0, "a\n\nc\n\nb\n");
10776        assert_eq!(moved("mv_p2", body, 0, body.len()).0, "b\n\nc\n\na\n");
10777        assert_eq!(moved("mv_p3", body, 3, 0).0, "b\n\na\n\nc\n");
10778    }
10779
10780    #[test]
10781    fn move_block_carries_an_image_block() {
10782        let body = "a\n\n![p](x.png)\n\nc\n";
10783        assert_eq!(moved("mv_img1", body, 4, 0).0, "![p](x.png)\n\na\n\nc\n");
10784        assert_eq!(
10785            moved("mv_img2", body, 4, body.len()).0,
10786            "a\n\nc\n\n![p](x.png)\n"
10787        );
10788    }
10789
10790    #[test]
10791    fn move_block_carries_a_table() {
10792        let body = "a\n\n| h |\n|---|\n| c |\n\nc\n";
10793        assert_eq!(
10794            moved("mv_tbl1", body, 5, 0).0,
10795            "| h |\n|---|\n| c |\n\na\n\nc\n"
10796        );
10797        assert_eq!(
10798            moved("mv_tbl2", body, 5, body.len()).0,
10799            "a\n\nc\n\n| h |\n|---|\n| c |\n"
10800        );
10801    }
10802
10803    #[test]
10804    fn move_block_carries_a_code_block() {
10805        let body = "a\n\n```rs\nx\n```\n\nc\n";
10806        assert_eq!(moved("mv_code1", body, 8, 0).0, "```rs\nx\n```\n\na\n\nc\n");
10807        assert_eq!(
10808            moved("mv_code2", body, 8, body.len()).0,
10809            "a\n\nc\n\n```rs\nx\n```\n"
10810        );
10811    }
10812
10813    #[test]
10814    fn move_block_onto_its_own_boundary_is_a_quiet_no_op() {
10815        let mut d = doc_with("mv_noop", "a\n\nb\n");
10816        let steps = d.undo_steps;
10817        d.move_block(0, 0);
10818        d.move_block(0, 3);
10819        assert_eq!(d.source, "a\n\nb\n");
10820        assert_eq!(d.undo_steps, steps);
10821        assert_eq!(d.status, None);
10822        assert!(!d.dirty);
10823    }
10824
10825    #[test]
10826    fn move_block_into_a_fence_is_refused_with_a_status() {
10827        let mut d = doc_with("mv_fence", "a\n\n```\nx\ny\n```\n");
10828        d.move_block(0, 7);
10829        assert_eq!(d.source, "a\n\n```\nx\ny\n```\n");
10830        assert!(
10831            d.status
10832                .as_deref()
10833                .is_some_and(|s| s.starts_with("move block"))
10834        );
10835    }
10836
10837    #[test]
10838    fn move_block_rides_the_caret_with_the_block() {
10839        // Down: "second" is line 0 of its block, caret 3 bytes from its end.
10840        let mut d = doc_with("mv_caret1", "first\n\nsecond\n\nthird\n");
10841        d.caret = 10; // "sec|ond"
10842        d.move_block(10, d.source.len());
10843        assert_eq!(d.source, "first\n\nthird\n\nsecond\n");
10844        assert_eq!(&d.source[d.caret..], "ond\n");
10845        // Up, across a wrapped paragraph's second line.
10846        let mut d = doc_with("mv_caret2", "first\n\nsecond\nline two\n");
10847        d.caret = 16; // "li|ne two"
10848        d.move_block(16, 0);
10849        assert_eq!(d.source, "second\nline two\n\nfirst\n");
10850        assert_eq!(&d.source[d.caret..], "ne two\n\nfirst\n");
10851        assert_eq!(d.selection(), None);
10852    }
10853
10854    #[test]
10855    fn move_block_keeps_the_caret_on_its_line_through_a_quotes_prefix() {
10856        let mut d = doc_with("mv_quote_caret", "para\n\n> a\n");
10857        d.caret = 2; // "pa|ra"
10858        d.move_block(2, 9); // after a, inside the quote
10859        assert_eq!(d.source, "> a\n>\n> para\n");
10860        assert_eq!(&d.source[d.caret..], "ra\n");
10861    }
10862
10863    #[test]
10864    fn move_block_up_and_down_step_over_siblings() {
10865        let mut d = doc_with("mv_updown", "a\n\nb\n\nc\n");
10866        d.caret = 3;
10867        d.move_block_down();
10868        assert_eq!(d.source, "a\n\nc\n\nb\n");
10869        assert_eq!(&d.source[d.caret..], "b\n");
10870        d.move_block_down();
10871        assert_eq!(d.source, "a\n\nc\n\nb\n", "nothing below the last block");
10872        assert_eq!(d.status.as_deref(), Some("move block: nothing below"));
10873        d.move_block_up();
10874        d.move_block_up();
10875        assert_eq!(d.source, "b\n\na\n\nc\n");
10876        assert_eq!(&d.source[d.caret..], "b\n\na\n\nc\n");
10877        d.move_block_up();
10878        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10879    }
10880
10881    #[test]
10882    fn move_block_up_and_down_reorder_list_items_with_their_children() {
10883        let mut d = doc_with("mv_items", "- a\n  - x\n- b\n- c\n");
10884        d.caret = 12; // in "b"
10885        d.move_block_up();
10886        assert_eq!(d.source, "- b\n- a\n  - x\n- c\n");
10887        assert_eq!(&d.source[d.caret..], "b\n- a\n  - x\n- c\n");
10888        d.caret = 6; // in "a"
10889        d.move_block_down();
10890        assert_eq!(d.source, "- b\n- c\n- a\n  - x\n");
10891        assert_eq!(&d.source[d.caret..], "a\n  - x\n");
10892        // A nested item leaves its list upward, as an item of the outer one.
10893        d.caret = 16; // in "x"
10894        d.move_block_up();
10895        assert_eq!(d.source, "- b\n- c\n- x\n- a\n");
10896    }
10897
10898    #[test]
10899    fn move_block_on_a_lone_item_that_stays_a_bullet_is_no_step() {
10900        let mut d = doc_with("mv_lone_item", "x\n\n- b\n\ny\n");
10901        d.caret = 5;
10902        let steps = d.undo_steps;
10903        d.move_block_up();
10904        assert_eq!(d.source, "x\n\n- b\n\ny\n");
10905        assert_eq!(
10906            d.undo_steps, steps,
10907            "a move that rewrote nothing is no undo step"
10908        );
10909        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10910    }
10911
10912    #[test]
10913    fn move_block_up_leaves_a_container_at_its_first_block_and_down_at_its_last() {
10914        let mut d = doc_with("mv_leave", "x\n\n> a\n>\n> b\n\ny\n");
10915        d.caret = 5; // "a"
10916        d.move_block_up();
10917        assert_eq!(d.source, "x\n\na\n\n> b\n\ny\n");
10918        d.caret = 8; // "b"
10919        d.move_block_down();
10920        assert_eq!(d.source, "x\n\na\n\nb\n\ny\n");
10921        // A tail block leaves its item into the next item's tail, then the list.
10922        let mut d = doc_with("mv_tail", "- a\n\n  t\n- b\n");
10923        d.caret = 7;
10924        d.move_block_down();
10925        assert_eq!(d.source, "- a\n- b\n\n  t\n");
10926        d.move_block_down();
10927        assert_eq!(d.source, "- a\n- b\n\nt\n");
10928        // And a paragraph after a quote steps over the whole quote, not into it.
10929        let mut d = doc_with("mv_over", "x\n\n> a\n>\n> b\n\ny\n");
10930        d.caret = 14;
10931        d.move_block_up();
10932        assert_eq!(d.source, "x\n\ny\n\n> a\n>\n> b\n");
10933        assert_eq!(d.caret, 3);
10934        d.move_block_down();
10935        assert_eq!(d.status, None);
10936        assert_eq!(d.source, "x\n\n> a\n>\n> b\n\ny\n");
10937        // Above a quote that opens the document is offset 0 — before the
10938        // quote, not inside it.
10939        let mut d = doc_with("mv_over_top", "> a\n\ny\n");
10940        d.caret = 5;
10941        d.move_block_up();
10942        assert_eq!(d.source, "y\n\n> a\n");
10943        assert_eq!(d.caret, 0);
10944    }
10945
10946    #[test]
10947    fn move_block_up_from_the_first_block_of_a_quote_that_opens_the_document_leaves_it() {
10948        let mut d = doc_with("mv_top_quote", "> a\n>\n> b\n");
10949        d.caret = 2;
10950        d.move_block_up();
10951        assert_eq!(d.source, "a\n\n> b\n");
10952        assert_eq!(d.caret, 0);
10953        assert_eq!(d.status, None);
10954    }
10955
10956    #[test]
10957    fn move_block_on_a_blank_line_or_read_only_does_nothing() {
10958        let mut d = doc_with("mv_blank", "a\n\nb\n");
10959        d.caret = 2;
10960        d.move_block_down();
10961        assert_eq!(d.source, "a\n\nb\n");
10962        assert_eq!(d.status.as_deref(), Some("move block: no block here"));
10963        d.read_only = true;
10964        d.caret = 0;
10965        d.move_block_down();
10966        d.move_block(0, 5);
10967        assert_eq!(d.source, "a\n\nb\n");
10968    }
10969
10970    #[test]
10971    fn move_block_never_lands_above_hidden_frontmatter() {
10972        let mut d = wysiwyg_doc("mv_fm", "---\nt: x\n---\n\na\n\nb\n");
10973        let b = d.source.find('b').unwrap();
10974        d.move_block(b, 0);
10975        assert_eq!(d.source, "---\nt: x\n---\n\nb\n\na\n");
10976    }
10977
10978    #[test]
10979    fn block_range_at_is_the_block_a_move_picks_up() {
10980        let mut d = doc_with("mv_range", "a\n\n- b\n  - c\n\nd\n");
10981        assert_eq!(d.block_range_at(0), Some(0..1));
10982        assert_eq!(d.block_range_at(5), Some(3..12), "the item with its child");
10983        assert_eq!(d.block_range_at(10), Some(7..12), "the nested item alone");
10984        assert_eq!(d.block_range_at(2), None, "a blank line");
10985    }
10986
10987    #[test]
10988    fn drop_target_at_splits_a_block_at_its_middle_row_and_ends_below_everything() {
10989        let long = "two ".repeat(40).trim_end().to_string(); // wraps to three rows at 80
10990        let mut d = wysiwyg_doc("drop", &format!("one\n\n{long} four\n\nfive\n"));
10991        let rows = d.vmap.rows.len();
10992        assert_eq!(rows, 7, "one, gap, three wrapped rows, gap, five");
10993        assert_eq!(d.drop_target_at(0), Some(DropTarget { offset: 0, row: 0 }));
10994        let two = d.source.find("two").unwrap();
10995        assert_eq!(
10996            d.drop_target_at(1),
10997            Some(DropTarget {
10998                offset: two,
10999                row: 2
11000            }),
11001            "the gap resolves to the block under it"
11002        );
11003        assert_eq!(
11004            d.drop_target_at(2),
11005            Some(DropTarget {
11006                offset: two,
11007                row: 2
11008            })
11009        );
11010        assert_eq!(
11011            d.drop_target_at(3),
11012            Some(DropTarget {
11013                offset: two,
11014                row: 2
11015            })
11016        );
11017        let four_end = d.source.find("four").unwrap() + 4;
11018        assert_eq!(
11019            d.drop_target_at(4),
11020            Some(DropTarget {
11021                offset: four_end,
11022                row: 5
11023            })
11024        );
11025        assert_eq!(
11026            d.drop_target_at(rows + 3),
11027            Some(DropTarget {
11028                offset: d.source.len(),
11029                row: rows
11030            })
11031        );
11032        // And the offsets are ones move_block takes.
11033        let five = d.source.find("five").unwrap();
11034        let t = d.drop_target_at(2).unwrap();
11035        d.move_block(five, t.offset);
11036        assert_eq!(d.source, format!("one\n\nfive\n\n{long} four\n"));
11037    }
11038
11039    #[test]
11040    fn append_media_lands_at_the_end_as_its_own_block() {
11041        let mut d = doc_with("append_mid", "first word and more\n");
11042        d.caret = 0; // an editor nobody has tapped: the caret is at the start
11043        d.append_media(MediaKind::Image, "cat.png", "");
11044        assert_eq!(d.source, "first word and more\n\n![](cat.png)");
11045        assert_eq!(d.selection(), None);
11046        assert_eq!(d.caret, "first word and more\n\n![](cat.png)".len());
11047    }
11048
11049    #[test]
11050    fn append_media_needs_no_separator_after_a_blank_line_or_in_an_empty_document() {
11051        let mut d = doc_with("append_blank", "para\n\n");
11052        d.append_media(MediaKind::Image, "a.png", "");
11053        assert_eq!(d.source, "para\n\n![](a.png)");
11054
11055        let mut e = doc_with("append_empty", "");
11056        e.append_media(MediaKind::Image, "b.png", "");
11057        assert_eq!(e.source, "![](b.png)");
11058
11059        let mut f = doc_with("append_noeol", "no newline at end");
11060        f.anchor = Some(0);
11061        f.caret = 2; // a selection, which the verb ignores
11062        f.append_media(MediaKind::Video, "clip.mp4", "");
11063        assert_eq!(
11064            f.source,
11065            "no newline at end\n\n<video src=\"clip.mp4\" controls></video>"
11066        );
11067    }
11068
11069    #[test]
11070    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
11071        // The round trip is the point: it's no use writing markup the reader
11072        // can't pick up again. This is the pair that only holds from twig 2.5.1
11073        // on — before it, the one-line form went in fine and came back as a
11074        // paragraph of raw tags, publishing no media at all.
11075        let mut d = doc_with("vid_rt", "\n");
11076        d.caret = 0;
11077        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
11078        assert_eq!(
11079            d.source,
11080            "<video src=\"clip.mp4\" controls>a clip</video>\n"
11081        );
11082
11083        d.build_visual(80);
11084        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
11085        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
11086        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
11087        assert_eq!(d.vmap.media[0].alt, "a clip");
11088    }
11089
11090    #[test]
11091    fn insert_media_spells_audio_with_its_own_tag() {
11092        let mut d = doc_with("aud_rt", "\n");
11093        d.caret = 0;
11094        d.insert_media(MediaKind::Audio, "take.mp3", "");
11095        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
11096        d.build_visual(80);
11097        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
11098    }
11099
11100    #[test]
11101    fn insert_media_uses_the_selection_as_fallback_text() {
11102        // The same courtesy `insert_image` does with alt: select a caption,
11103        // insert, and the caption labels the thing rather than being replaced.
11104        let mut d = doc_with("vid_sel", "the talk here\n");
11105        d.anchor = Some(0);
11106        d.caret = 8; // "the talk"
11107        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
11108        assert_eq!(
11109            d.source,
11110            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
11111        );
11112    }
11113
11114    #[test]
11115    fn insert_media_with_an_image_kind_is_just_insert_image() {
11116        let mut d = doc_with("img_via_media", "\n");
11117        d.caret = 0;
11118        d.insert_media(MediaKind::Image, "logo.svg", "x");
11119        assert_eq!(d.source, "![x](logo.svg)\n");
11120    }
11121
11122    // ── thematic breaks ─────────────────────────────────────────────────────
11123
11124    /// The node the source parses as at `caret` — what confirms an inserted
11125    /// `---` actually reads back as a rule, not stray text or a setext heading.
11126    ///
11127    /// The *narrowest* node covering the offset. Every ancestor covers it too,
11128    /// and since twig 2.8 that includes the `doc` root, which now carries a real
11129    /// span (it reported none before, so taking the first match used to land on
11130    /// the block by luck and now always answers `"doc"`).
11131    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
11132        d.nodes()
11133            .into_iter()
11134            .filter(|n| n.span.start <= caret && caret < n.span.end)
11135            .min_by_key(|n| n.span.end - n.span.start)
11136            .map(|n| n.kind)
11137    }
11138
11139    #[test]
11140    fn a_task_box_toggles_at_the_caret_and_reads_back() {
11141        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
11142        d.caret = 8; // inside "todo"
11143        assert_eq!(d.task_checked_at_caret(), Some(false));
11144        d.toggle_task_checked();
11145        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
11146        assert_eq!(d.task_checked_at_caret(), Some(true));
11147        d.toggle_task_checked();
11148        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
11149    }
11150
11151    #[test]
11152    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
11153        // The whole reason `toggle_task_at` exists apart from the caret form:
11154        // ticking a box elsewhere must not move the cursor out of what's being
11155        // typed.
11156        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
11157        d.caret = 8; // inside "first"
11158        let second = d.source.find("second").unwrap();
11159        d.toggle_task_at(second);
11160        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
11161        assert_eq!(d.caret, 8, "the caret stayed in the first item");
11162    }
11163
11164    #[test]
11165    fn a_plain_item_gains_and_loses_a_box() {
11166        let mut d = doc_with("task_mint", "- plain\n");
11167        d.caret = 4;
11168        assert_eq!(d.task_checked_at_caret(), None);
11169        d.toggle_task_item();
11170        assert_eq!(d.source, "- [ ] plain\n");
11171        assert_eq!(
11172            d.task_checked_at_caret(),
11173            Some(false),
11174            "a new box arrives unticked"
11175        );
11176        d.toggle_task_item();
11177        assert_eq!(d.source, "- plain\n");
11178    }
11179
11180    #[test]
11181    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
11182        // `set checked` must not silently convert a bullet into a task — that is
11183        // `toggle_task_item`'s job, and twig refuses it here.
11184        let mut d = doc_with("task_none", "- plain\n");
11185        d.caret = 4;
11186        d.toggle_task_checked();
11187        assert_eq!(d.source, "- plain\n", "nothing written");
11188        assert!(
11189            d.status.is_some(),
11190            "the refusal should reach the status line"
11191        );
11192    }
11193
11194    #[test]
11195    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
11196        let mut d = doc_with("task_quote", "> - [ ] nested\n");
11197        d.caret = d.source.find("nested").unwrap();
11198        assert_eq!(d.task_checked_at_caret(), Some(false));
11199        d.toggle_task_checked();
11200        assert_eq!(d.source, "> - [x] nested\n");
11201    }
11202
11203    #[test]
11204    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
11205        // A rule is a block, so twig's `insert_thematic_break` alone lands it
11206        // after the whole paragraph. `split_block` parts the paragraph first and
11207        // the rule is aimed at the *first* half, which is what a rule button is
11208        // understood to do — and what leaf spelled by hand until twig grew both
11209        // halves of the gesture.
11210        let mut d = doc_with("hr_mid", "before after\n");
11211        d.caret = 7; // between "before " and "after"
11212        d.insert_thematic_break();
11213        assert_eq!(d.source, "before \n\n---\n\nafter\n");
11214        assert_eq!(d.selection(), None);
11215        assert_eq!(
11216            kind_at(&mut d, "before \n\n".len()),
11217            Some(Kind::ThematicBreak)
11218        );
11219    }
11220
11221    #[test]
11222    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
11223        // At the end there is nothing to part, and a split there writes the
11224        // separator anyway — a blank line and the empty slot the next paragraph
11225        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
11226        // two blank lines nothing fills. Now the rule lands after the paragraph,
11227        // where the split-and-aim was sending it regardless. Both formats, and
11228        // both shapes of a last line — terminated, and still being typed —
11229        // because the two reach the split through different doors: Markdown's
11230        // paragraph span stops before its newline, so `para\n` at 4 never split
11231        // there, but `para` at 4 did.
11232        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
11233            for src in ["para\n", "para"] {
11234                let mut d = Doc::from_source(src.into(), fmt).unwrap();
11235                d.caret = 4;
11236                d.insert_thematic_break();
11237                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
11238                assert_eq!(d.caret, d.source.len());
11239            }
11240            // Mid-document the slot sat between the rule and the next block.
11241            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
11242            d.caret = 4;
11243            d.insert_thematic_break();
11244            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
11245            // Trailing whitespace is nothing to part either.
11246            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
11247            d.caret = 4;
11248            d.insert_thematic_break();
11249            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
11250        }
11251    }
11252
11253    #[test]
11254    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
11255        // The split at the start parts nothing, but it is kept on purpose:
11256        // `|para` becomes `\npara` with the caret on a blank line, and twig
11257        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
11258        // way "before the paragraph" is reachable through a gesture that only
11259        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
11260        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
11261            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
11262            d.caret = 0;
11263            d.insert_thematic_break();
11264            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
11265            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
11266            d.caret = 6;
11267            d.insert_thematic_break();
11268            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
11269        }
11270    }
11271
11272    #[test]
11273    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
11274        // The gap between two blocks is where a click lands the caret; the
11275        // rule goes on the blank, one blank each side.
11276        let mut d = doc_with("hr_gap", "a\n\nb\n");
11277        d.caret = 2;
11278        d.insert_thematic_break();
11279        assert_eq!(d.source, "a\n\n---\n\nb\n");
11280    }
11281
11282    #[test]
11283    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
11284        // The same door as the rule's, through the placement they share.
11285        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
11286        d.caret = 4;
11287        d.insert_table(1, 1);
11288        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
11289        let mut d = doc_with("table_end_typed", "para");
11290        d.caret = 4;
11291        d.insert_table(1, 1);
11292        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
11293        assert!(d.caret_in_table());
11294    }
11295
11296    #[test]
11297    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
11298        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
11299        // and leaf wrote the first into both until twig started spelling it.
11300        let mut md = doc_with("hr_md", "para\n");
11301        md.caret = 2;
11302        md.insert_thematic_break();
11303        assert_eq!(md.source, "pa\n\n---\n\nra\n");
11304
11305        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
11306        dj.caret = 2;
11307        dj.insert_thematic_break();
11308        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
11309    }
11310
11311    #[test]
11312    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
11313        // The table goes *at* the caret the way the rule does: the paragraph is
11314        // parted first, and twig writes the grid after its first half. The
11315        // caret then sits in the first header cell — selected, as Tab would
11316        // leave it — so the next keystroke is the heading.
11317        let mut d = doc_with("table_mid", "before after\n");
11318        d.caret = 7;
11319        d.insert_table(2, 3);
11320        assert_eq!(
11321            d.source,
11322            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
11323        );
11324        assert!(d.caret_in_table());
11325        let first_bar = d.source.find('|').unwrap();
11326        assert!(
11327            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
11328            "caret {} is not in the header row",
11329            d.caret
11330        );
11331        d.insert("Name");
11332        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
11333        // And the grid the table was written into is one the table keys walk
11334        // (over the map a frontend rebuilds after every edit).
11335        d.build_visual(80);
11336        assert!(d.cell_tab(true));
11337        d.insert("Qty");
11338        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
11339    }
11340
11341    #[test]
11342    fn insert_table_spells_the_grid_the_format_s_own_way() {
11343        // Djot's delimiter row is unpadded, and leaf never has to know that.
11344        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
11345        dj.caret = 2;
11346        dj.insert_table(1, 2);
11347        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
11348        assert!(dj.caret_in_table());
11349    }
11350
11351    #[test]
11352    fn insert_table_refuses_where_the_format_spells_no_table() {
11353        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
11354        d.caret = 4;
11355        d.insert_table(1, 1);
11356        assert_eq!(d.source, "<p>ab</p>\n");
11357        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
11358        assert!(!d.capabilities().table);
11359    }
11360
11361    #[test]
11362    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
11363        let mut d = doc_with("table_zero", "para\n");
11364        d.caret = 2;
11365        d.insert_table(0, 2);
11366        assert_eq!(d.source, "para\n");
11367        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
11368    }
11369
11370    #[test]
11371    fn clicking_below_a_final_thematic_break_can_type_after_it() {
11372        let mut d = wysiwyg_doc("hr_final_click", "---\n");
11373        d.build_visual(80);
11374        d.click(d.vmap.num_rows() + 2, 0, false);
11375        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
11376        d.insert("after");
11377        assert_eq!(d.source, "---\nafter");
11378    }
11379
11380    #[test]
11381    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
11382        // The same bytes are two documents. In Markdown `  - b` is a nested item
11383        // and the next one belongs beside it, at its indent. In Djot a list
11384        // marker can't interrupt a paragraph, so those bytes are literal text in
11385        // item `a` and there is only one item — writing `  - ` under it would add
11386        // no item at all, just more text, and the new sibling has to go to
11387        // column zero. Both spellings come out of the *enclosing item's* line.
11388        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
11389        md.caret = "- a\n  - b".len();
11390        md.newline();
11391        assert_eq!(md.source, "- a\n  - b\n  - \n");
11392        assert_eq!(list_items(&mut md), 3);
11393
11394        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
11395        dj.view = View::Wysiwyg;
11396        dj.build_visual(80);
11397        dj.caret = "- a\n  - b".len();
11398        dj.newline();
11399        assert_eq!(dj.source, "- a\n  - b\n- \n");
11400        assert_eq!(list_items(&mut dj), 2);
11401
11402        // Where Djot's nesting is real — opened by a blank line — the indent is
11403        // reproduced there too, and the two formats agree again.
11404        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
11405        dj.view = View::Wysiwyg;
11406        dj.build_visual(80);
11407        dj.caret = "- a\n\n  - b".len();
11408        dj.newline();
11409        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
11410        assert_eq!(list_items(&mut dj), 3);
11411    }
11412
11413    #[test]
11414    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
11415        // Tab replaces the line's whole prefix with the one twig spells, so the
11416        // quote markers, the parent's indent and an ordered marker's extra
11417        // column are all its answer rather than leaf's arithmetic.
11418        for (name, body, caret, want) in [
11419            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
11420            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
11421            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
11422            // A checkbox is markup the item's own text wraps past, but a nested
11423            // list may only open at the *list* marker's column — four in from
11424            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
11425            // parses as one item, not two.
11426            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
11427            (
11428                "quoted task",
11429                "> - [ ] a\n> - [ ] b\n",
11430                18,
11431                "> - [ ] a\n>   - [ ] b\n",
11432            ),
11433        ] {
11434            let mut doc = wysiwyg_doc(name, body);
11435            doc.caret = caret;
11436            doc.indent();
11437            assert_eq!(doc.source, want, "{name}");
11438            // The nesting is real, not just indented text.
11439            assert_eq!(list_items(&mut doc), 2, "{name}");
11440        }
11441    }
11442
11443    #[test]
11444    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
11445        // The same bytes, the two formats disagreeing, and a gesture that used
11446        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
11447        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
11448        // so those bytes are literal text inside item `a` — there is nothing to
11449        // outdent, and treating them as a marker turned one item into two, a
11450        // structural edit from a keystroke that should delete one character.
11451        //
11452        // twig's `line_prefix` is what tells them apart: it reports the marker
11453        // on the Markdown line and nothing on the Djot one, which is a
11454        // continuation. No byte scan can reach that answer.
11455        let src = "- a\n  - b\n";
11456        let at = "- a\n  - ".len();
11457
11458        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
11459        md.view = View::Wysiwyg;
11460        md.build_visual(80);
11461        md.caret = at;
11462        md.backspace();
11463        assert_eq!(md.source, "- a\n- b\n");
11464        assert_eq!(list_items(&mut md), 2);
11465
11466        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
11467        dj.view = View::Wysiwyg;
11468        dj.build_visual(80);
11469        dj.caret = at;
11470        dj.backspace();
11471        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
11472        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
11473    }
11474
11475    #[test]
11476    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
11477        // Leaf used to spell the next item from the marker bytes it scanned, and
11478        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
11479        // and dropped out of the checklist. twig reproduces the whole
11480        // continuation, and a fresh item is always unticked however the one above
11481        // it stands.
11482        for (name, body, want) in [
11483            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
11484            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
11485        ] {
11486            let mut doc = wysiwyg_doc(name, body);
11487            doc.caret = body.trim_end_matches('\n').len();
11488            doc.newline();
11489            assert_eq!(doc.source, want, "{name}");
11490            // Both items are checklist items — the new one is a box, not the
11491            // plain bullet the old marker scan left behind — and it is unticked
11492            // whichever way the one above it faces.
11493            let boxes: Vec<Option<bool>> = doc
11494                .nodes()
11495                .iter()
11496                .filter(|n| n.kind == Kind::TaskListItem)
11497                .map(|n| n.checked)
11498                .collect();
11499            assert_eq!(boxes.len(), 2, "{name}");
11500            assert_eq!(boxes[1], Some(false), "{name}");
11501        }
11502    }
11503
11504    #[test]
11505    fn a_split_takes_the_space_the_caret_was_in_front_of() {
11506        // Splicing a break at the caret strands the space the words were parted
11507        // at on the head of the second block, where it reads as an indent nobody
11508        // typed. twig's split consumes it.
11509        for (name, body, caret, want) in [
11510            ("para", "one two\n", 3, "one\n\ntwo\n"),
11511            ("item", "- one two\n", 5, "- one\n- two\n"),
11512            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
11513            // A heading takes leaf's own path, which has to match.
11514            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
11515        ] {
11516            let mut doc = wysiwyg_doc(name, body);
11517            doc.caret = caret;
11518            doc.newline();
11519            assert_eq!(doc.source, want, "{name}");
11520        }
11521    }
11522
11523    #[test]
11524    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
11525        // The one place leaf keeps its own break: `split_block` repeats the `#`,
11526        // and Enter after a title is how the body under it is asked for.
11527        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
11528        doc.caret = "# Title".len();
11529        doc.newline();
11530        doc.insert("body");
11531        assert_eq!(doc.source, "# Title\n\nbody\n");
11532        assert_eq!(
11533            doc.nodes()
11534                .iter()
11535                .filter(|n| n.kind == Kind::Heading)
11536                .count(),
11537            1
11538        );
11539    }
11540
11541    #[test]
11542    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
11543        // A quoted item's marker doesn't open its line, so a scan that starts at
11544        // column zero finds a `>` where it wanted a bullet, calls the line "not a
11545        // list" and hands Enter to the plain-quote branch — which writes `> ` and
11546        // drops the list. The next item has to carry the whole prefix.
11547        for (name, body, want) in [
11548            ("flat", "> - a\n", "> - a\n> - \n"),
11549            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
11550            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
11551            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
11552            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
11553        ] {
11554            let mut doc = wysiwyg_doc(name, body);
11555            doc.caret = body.trim_end_matches('\n').len();
11556            doc.newline();
11557            assert_eq!(doc.source, want, "{name}");
11558            // The marker isn't just spelled right, it parses as an item.
11559            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
11560        }
11561    }
11562
11563    #[test]
11564    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
11565        // Double-Enter exits the list. Unquoted that means a blank line, but a
11566        // *bare* blank line would end the quote too and drop the caret out of it,
11567        // so the separator keeps its `>` and the caret's line keeps its `> `.
11568        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
11569        doc.caret = "> - a\n> - ".len();
11570        doc.newline();
11571        assert_eq!(doc.source, "> - a\n>\n> \n");
11572        assert_eq!(list_items(&mut doc), 1);
11573        // What "still in the quote" means for the next keystroke: the caret sits
11574        // behind the prefix, and what's typed there lands inside the quote as a
11575        // paragraph of its own — not as more of item `a`.
11576        doc.insert("x");
11577        assert_eq!(doc.source, "> - a\n>\n> x\n");
11578        assert!(
11579            doc.editor
11580                .ancestors_at(doc.caret - 1)
11581                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
11582        );
11583    }
11584
11585    #[test]
11586    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
11587        // The marker is hidden block markup, so Backspace over it is structural —
11588        // but only the marker is the list's. Splicing from the line start would
11589        // take the `>` with it and silently unquote the line.
11590        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
11591        doc.caret = "> - ".len();
11592        doc.backspace();
11593        assert_eq!(doc.source, "> a\n");
11594        assert_eq!(list_items(&mut doc), 0);
11595
11596        // A nested one outdents instead, moving the bullet within the quote
11597        // rather than moving the quote.
11598        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
11599        doc.caret = "> - a\n>   - ".len();
11600        doc.backspace();
11601        assert_eq!(doc.source, "> - a\n> - b\n");
11602        assert_eq!(list_items(&mut doc), 2);
11603    }
11604
11605    #[test]
11606    fn only_a_bare_paragraph_is_parted_around_the_caret() {
11607        // The split is deliberately narrow. Parting a fenced block would leave
11608        // two fences with a rule between them, and parting a list item would
11609        // mint an item nobody asked for on the way to a rule that lands after
11610        // the list either way — so both keep the whole block intact and take the
11611        // rule after it. A caret in a quote is likewise left alone.
11612        for (name, body, caret, want) in [
11613            (
11614                "code",
11615                "```\nfn x() {}\n```\n",
11616                8,
11617                "```\nfn x() {}\n```\n\n---\n",
11618            ),
11619            ("list", "- one two\n", 6, "- one two\n\n---\n"),
11620            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
11621        ] {
11622            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
11623            d.caret = caret;
11624            d.insert_thematic_break();
11625            assert_eq!(d.source, want, "{name}: the block should stay whole");
11626        }
11627    }
11628
11629    #[test]
11630    fn insert_thematic_break_replaces_the_selection() {
11631        // Now that the rule lands *at* the caret again, replacing the selection
11632        // is coherent once more: the text goes, and the rule takes its place.
11633        // The space the deletion left leading the second half is consumed by the
11634        // split rather than opening the new paragraph with it.
11635        let mut d = doc_with("hr_sel", "one two three\n");
11636        d.anchor = Some(4);
11637        d.caret = 7; // "two"
11638        d.insert_thematic_break();
11639        assert_eq!(d.source, "one \n\n---\n\nthree\n");
11640        assert_eq!(d.selection(), None);
11641    }
11642
11643    #[test]
11644    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
11645        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
11646        // because writing `---` into one is code, not a rule — twig now walks out
11647        // to the block that owns the caret's line, so there is nothing to refuse.
11648        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
11649        code.caret = 5; // inside the fenced code
11650        code.insert_thematic_break();
11651        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
11652        assert_eq!(code.status, None, "no refusal to report any more");
11653
11654        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
11655        table.caret = 3; // in the header row
11656        table.insert_thematic_break();
11657        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
11658    }
11659
11660    #[test]
11661    fn insert_thematic_break_in_a_list_item_ends_the_list() {
11662        // The un-indented rule cannot continue the list, so it closes the list
11663        // and lands at the top level rather than nested inside it.
11664        let mut d = doc_with("hr_list", "- one\n- two\n");
11665        d.caret = "- one\n- tw".len(); // mid "two"
11666        d.insert_thematic_break();
11667        d.build_visual(80);
11668        let rule_at = d.source.find("---").unwrap();
11669        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11670        assert!(
11671            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
11672                && n.span.start <= rule_at
11673                && rule_at < n.span.end),
11674            "the rule must not be nested inside the list"
11675        );
11676    }
11677
11678    #[test]
11679    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
11680        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
11681        // which is the document the gesture was actually asked for.
11682        let mut d = doc_with("hr_quote", "> hello\n");
11683        d.caret = 4; // inside the quoted text
11684        d.insert_thematic_break();
11685        assert_eq!(d.source, "> hello\n>\n> ---\n");
11686        d.build_visual(80);
11687        let rule_at = d.source.find("---").unwrap();
11688        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11689        assert!(
11690            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
11691                && n.span.start <= rule_at
11692                && rule_at < n.span.end),
11693            "the rule belongs to the quote it was asked for"
11694        );
11695    }
11696
11697    // ── typing against a block picture ────────────────────────────────────────
11698
11699    /// A rendered-view document with the caret parked on one of the picture's two
11700    /// stops, and the map already built — the state a frontend is in between
11701    /// drawing a frame and the next keystroke.
11702    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
11703        let mut d = doc_in(View::Wysiwyg, name, src);
11704        d.build_visual_unwrapped();
11705        let start = src.find("![").unwrap();
11706        d.caret = match side {
11707            MediaStop::Before => start,
11708            MediaStop::After => start + "![](p.png)".len(),
11709        };
11710        d
11711    }
11712
11713    /// The block media the map publishes, after rebuilding it — "is this still a
11714    /// picture, or has it become a line of text with an image in it?"
11715    fn media_count(d: &mut Doc) -> usize {
11716        d.build_visual_unwrapped();
11717        d.vmap.media.len()
11718    }
11719
11720    #[test]
11721    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
11722        // The accident this prevents: tap the blank page under a photo (which
11723        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
11724        // paragraph with an *inline* image — the photo stops being drawn.
11725        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
11726        d.insert("xy");
11727        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
11728        assert_eq!(media_count(&mut d), 1, "still a picture");
11729    }
11730
11731    #[test]
11732    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
11733        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
11734        d.insert("xy");
11735        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
11736        assert_eq!(media_count(&mut d), 1);
11737    }
11738
11739    #[test]
11740    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
11741        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
11742        d.insert("x");
11743        assert_eq!(d.source, "x\n\n![](p.png)\n");
11744        assert_eq!(media_count(&mut d), 1);
11745    }
11746
11747    #[test]
11748    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
11749        // The opened paragraph is part of the keystroke, not an edit the writer
11750        // made — so it undoes with the character, not a step later.
11751        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
11752        d.insert("x");
11753        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
11754        d.undo();
11755        assert_eq!(d.source, "hi\n\n![](p.png)\n");
11756    }
11757
11758    #[test]
11759    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
11760        // ⌘V dissolves the picture exactly as a keystroke does.
11761        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
11762        d.paste("pasted");
11763        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
11764        assert_eq!(media_count(&mut d), 1);
11765    }
11766
11767    #[test]
11768    fn typing_beside_an_inline_image_is_ordinary_editing() {
11769        // An inline image has no placeholder row and no stops of its own. Opening
11770        // a paragraph mid-sentence would be the bug, not the fix.
11771        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
11772        d.build_visual_unwrapped();
11773        d.caret = "see ![](p.png)".len();
11774        d.insert("!");
11775        assert_eq!(d.source, "see ![](p.png)! here\n");
11776    }
11777
11778    #[test]
11779    fn source_view_types_raw_markup_against_an_image_untouched() {
11780        // Source view is for writing the markup itself; a break inserted behind
11781        // the writer's back there would be the editor arguing with them.
11782        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
11783        d.caret = "![](p.png)".len();
11784        d.insert("x");
11785        assert_eq!(d.source, "![](p.png)x\n");
11786    }
11787
11788    #[test]
11789    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
11790        // A selection is replaced, not joined into, so there is nothing to
11791        // protect: the range takes the picture with it.
11792        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
11793        d.anchor = Some(d.caret);
11794        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
11795        d.insert("x");
11796        assert_eq!(d.source, "hi\n\nx\n");
11797    }
11798
11799    #[test]
11800    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
11801        // What this actually cost: a real vault's photo, to one stray Backspace.
11802        // The caret past `![](p.png)` was deleting the closing paren — invisible
11803        // in the rendered view — and the photo became the text `![](p.png`.
11804        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
11805        d.backspace();
11806        assert_eq!(d.source, "hi\n");
11807        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
11808        d.undo();
11809        assert_eq!(
11810            d.source, "hi\n\n![](p.png)\n",
11811            "and comes back in one piece"
11812        );
11813    }
11814
11815    #[test]
11816    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
11817        // Deleting the break here would join the picture to the paragraph above,
11818        // where it is an *inline* image and stops being drawn. Step over the
11819        // boundary; the next press deletes in the paragraph the caret reached.
11820        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
11821        d.backspace();
11822        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
11823        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
11824        d.backspace();
11825        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
11826        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
11827    }
11828
11829    #[test]
11830    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
11831        // The mirror. A byte-step here eats the `!` and leaves a link.
11832        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
11833        d.delete_forward();
11834        assert_eq!(d.source, "hi\n\nbye\n");
11835        assert_eq!(media_count(&mut d), 0);
11836    }
11837
11838    #[test]
11839    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
11840        let mut d = doc_at_picture(
11841            "pic_del_after",
11842            "hi\n\n![](p.png)\n\nbye\n",
11843            MediaStop::After,
11844        );
11845        d.delete_forward();
11846        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
11847        assert_eq!(
11848            d.caret,
11849            d.source.find("bye").unwrap(),
11850            "the caret stepped down to `bye`"
11851        );
11852    }
11853
11854    #[test]
11855    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
11856        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
11857        d.backspace();
11858        assert_eq!(d.source, "\n");
11859        assert_eq!(media_count(&mut d), 0);
11860    }
11861
11862    #[test]
11863    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
11864        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
11865        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
11866        d.delete_word_back();
11867        assert_eq!(d.source, "hi there\n");
11868
11869        // And in front of one it runs *through* the paragraph break into the
11870        // prose above, which merges the picture inline — so it steps out first,
11871        // and the second press deletes the word it was aimed at.
11872        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
11873        d.delete_word_back();
11874        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
11875        d.delete_word_back();
11876        assert_eq!(
11877            d.source, "hi \n\n![](p.png)\n",
11878            "the word above went, the picture stayed"
11879        );
11880        assert_eq!(media_count(&mut d), 1);
11881    }
11882
11883    #[test]
11884    fn source_view_deletes_raw_markup_against_an_image_untouched() {
11885        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
11886        d.caret = "![](p.png)".len();
11887        d.backspace();
11888        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
11889    }
11890
11891    #[test]
11892    fn image_destination_at_caret_reads_the_image_under_the_caret() {
11893        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
11894        d.caret = 3; // inside the image markup
11895        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
11896        // Past the image, the caret is in no image.
11897        d.caret = "![a cat](cat.png)".len();
11898        assert_eq!(d.image_destination_at_caret(), None);
11899    }
11900
11901    #[test]
11902    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
11903        // The image is one placeholder row by default, and `set_media_rows` grows
11904        // it to the height the frontend measured: the label row plus blank
11905        // `decoration` fillers that hold the vertical space a raster is drawn into.
11906        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
11907        assert_eq!(d.vmap.media.len(), 1);
11908        let img_row = d.vmap.media[0].rows_span.start;
11909        assert_eq!(
11910            d.vmap.media[0].rows_span,
11911            img_row..img_row + 1,
11912            "default is one row"
11913        );
11914
11915        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
11916        d.build_visual(80);
11917        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
11918        let span = d.vmap.media[0].rows_span.clone();
11919        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
11920        // The label row carries the mark and its glyphs; the three below are blank
11921        // decoration — drawn, but no caret and no text.
11922        assert!(
11923            d.vmap.rows[span.start].media.is_some(),
11924            "mark rides the first row"
11925        );
11926        for r in (span.start + 1)..span.end {
11927            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
11928            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
11929            assert!(
11930                d.vmap.rows[r].media.is_none(),
11931                "only the first row is marked"
11932            );
11933        }
11934    }
11935
11936    #[test]
11937    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
11938        // The extra rows are pure spacers: the caret's only homes stay the stop in
11939        // front of the image and the one just past it, so walking the document top
11940        // to bottom visits the same offsets whether the image is 1 row or 5.
11941        let body = "ab\n\n![x](p.png)\n\ncd\n";
11942        let stops_at = |rows: usize| -> Vec<usize> {
11943            let mut d = wysiwyg_doc("img_stops", body);
11944            if rows > 1 {
11945                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
11946                d.build_visual(80);
11947            }
11948            d.caret = 0;
11949            let mut seen = vec![d.caret];
11950            loop {
11951                d.move_right(false);
11952                if *seen.last().unwrap() == d.caret {
11953                    break;
11954                }
11955                seen.push(d.caret);
11956            }
11957            seen
11958        };
11959        assert_eq!(
11960            stops_at(1),
11961            stops_at(5),
11962            "reserving rows must not add stops"
11963        );
11964    }
11965
11966    #[test]
11967    fn insert_link_repoints_the_link_at_a_bare_caret() {
11968        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
11969        d.caret = 3; // in the link's text, nothing selected
11970        d.insert_link("http://y.dev");
11971        assert_eq!(d.source, "[word](http://y.dev)\n");
11972        assert_eq!(d.selected_text(), Some("word"));
11973    }
11974
11975    #[test]
11976    fn insert_link_on_an_empty_range_autolinks_a_url() {
11977        // A link with no text of its own is an autolink, and twig spells it —
11978        // `<…>` is the canonical form and needs no text typed into it, so the
11979        // caret lands after it rather than selecting a finished link.
11980        let mut d = doc_with("link_empty", "\n");
11981        d.caret = 0;
11982        d.insert_link("http://x.dev");
11983        assert_eq!(d.source, "<http://x.dev>\n");
11984        assert_eq!(d.selection(), None);
11985        assert_eq!(d.caret, 14);
11986    }
11987
11988    #[test]
11989    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
11990        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
11991        // in Markdown, so a destination that can't autolink doubles as the text
11992        // instead — which is then selected, ready to be typed over.
11993        let mut d = doc_with("link_rel", "\n");
11994        d.caret = 0;
11995        d.insert_link("./notes.md");
11996        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
11997        assert_eq!(d.selection(), Some((1, 11)));
11998        d.insert("Notes");
11999        assert_eq!(d.source, "[Notes](./notes.md)\n");
12000    }
12001
12002    #[test]
12003    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
12004        // The autolink's text is its URL, so re-pointing replaces the whole
12005        // node — the caret must not splice a second link inside the first.
12006        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
12007        d.caret = 10;
12008        d.insert_link("https://y.dev");
12009        assert_eq!(d.source, "see <https://y.dev> ok\n");
12010    }
12011
12012    #[test]
12013    fn code_language_reads_and_edits_through_the_fence() {
12014        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
12015        d.caret = 10; // inside the code body
12016        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
12017        assert!(d.caret_in_fenced_code());
12018
12019        d.set_code_language("python");
12020        assert!(
12021            d.source.starts_with("```python\n"),
12022            "source: {:?}",
12023            d.source
12024        );
12025        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
12026
12027        // Clearing it leaves a bare fence and no label.
12028        d.set_code_language("");
12029        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
12030        assert_eq!(d.code_language_at_caret(), None);
12031
12032        // A caret outside any code block edits nothing.
12033        let mut p = doc_with("code_lang_none", "just prose\n");
12034        assert!(!p.caret_in_fenced_code());
12035        p.set_code_language("rust");
12036        assert_eq!(p.source, "just prose\n");
12037    }
12038
12039    #[test]
12040    fn code_language_reads_and_edits_through_a_quoted_fence() {
12041        let mut d = doc_with("code_lang_quote", "> ```rust\n> let x = 1;\n> ```\n");
12042        d.caret = d.source.find("let").unwrap();
12043        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
12044        assert!(d.caret_in_fenced_code());
12045        d.set_code_language("python");
12046        assert!(
12047            d.source.starts_with("> ```python\n"),
12048            "source: {:?}",
12049            d.source
12050        );
12051        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
12052    }
12053
12054    #[test]
12055    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
12056        // Markdown's info string ends at whitespace, so `two words` would write
12057        // a fence that reads back with a different language than the one asked
12058        // for. twig refuses it; leaf reports that and leaves the source alone.
12059        // The old splice trimmed the ends and wrote whatever was left.
12060        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
12061        d.caret = 10;
12062        d.set_code_language("two words");
12063        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
12064        assert!(d.status.is_some(), "the refusal should be reported");
12065        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
12066    }
12067
12068    #[test]
12069    fn link_destination_at_caret_reads_both_spellings() {
12070        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
12071        d.caret = 5;
12072        assert_eq!(
12073            d.link_destination_at_caret().as_deref(),
12074            Some("https://x.dev")
12075        );
12076        d.caret = 0;
12077        assert_eq!(d.link_destination_at_caret(), None);
12078
12079        // An autolink has no `destination`; its text is the URL.
12080        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
12081        a.caret = 10;
12082        assert_eq!(
12083            a.link_destination_at_caret().as_deref(),
12084            Some("https://x.dev")
12085        );
12086        a.caret = 21;
12087        assert_eq!(a.link_destination_at_caret(), None);
12088    }
12089
12090    #[test]
12091    fn heading_at_caret_is_the_nearest_heading_above() {
12092        let src = "intro\n\n# Title\n\n## The *Second* Part\n\nbody here\n";
12093        let mut d = doc_with("heading_at", src);
12094        // Under no heading at all.
12095        d.caret = 2;
12096        assert_eq!(d.heading_at_caret(), None);
12097        // In a paragraph under a level-2 heading: that heading, its markup
12098        // stripped for the slug, and its own span.
12099        d.caret = src.find("body").unwrap() + 2;
12100        let h = d.heading_at_caret().expect("under `## The Second Part`");
12101        assert_eq!(h.text, "The Second Part");
12102        assert_eq!(h.level, 2);
12103        assert_eq!(&src[h.span.clone()].trim_end(), &"## The *Second* Part");
12104        // Standing in a heading answers with that heading.
12105        let h = d.heading_at(src.find("Title").unwrap()).unwrap();
12106        assert_eq!((h.text.as_str(), h.level), ("Title", 1));
12107        // And the text is what `locate` lands a slug of.
12108        let landing = d.locate("the-second-part").unwrap();
12109        assert_eq!(landing.start, d.heading_at_caret().unwrap().span.start);
12110    }
12111
12112    #[test]
12113    fn locate_finds_the_block_a_declared_id_names() {
12114        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
12115        // verse. The locator has to land on the *verse*, which is the whole
12116        // reason a link carries one.
12117        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
12118                   {#v2}\nYea, I make a record in the language of my father.\n";
12119        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12120        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
12121        assert_eq!(
12122            d.source[v2.start..v2.end].trim_end(),
12123            "Yea, I make a record in the language of my father."
12124        );
12125        // The attribute line is not part of it: `start` is a place to put a
12126        // caret, and `{#v2}` is markup the caret has no business landing in.
12127        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
12128        assert_eq!(d.locate("v99"), None);
12129    }
12130
12131    #[test]
12132    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
12133        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
12134        // Markdown heading is literal text. So `#the-second-part` can only be
12135        // the heading's own words, which is the rule every Markdown renderer
12136        // already follows and therefore the one a link was authored against.
12137        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
12138        let mut d = doc_with("locate_md", src);
12139        let hit = d.locate("the-second-part").expect("the heading's slug");
12140        assert!(d.source[hit.start..].starts_with("## The Second Part"));
12141        // Bounded by the next heading that isn't under it, so a peek shows the
12142        // section rather than only its title.
12143        assert_eq!(
12144            &d.source[hit.start..hit.end],
12145            "## The Second Part\n\nbody\n\n"
12146        );
12147
12148        // A subsection does not end its parent: `# Title` runs to `## Third`'s
12149        // sibling only because there is no other `#`, so it covers the lot.
12150        let title = d.locate("title").expect("the top heading");
12151        assert_eq!(title.end, d.source.len());
12152    }
12153
12154    #[test]
12155    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
12156        // djot mints `Some-Heading-Here`; a link to it is written
12157        // `#some-heading-here` by nearly everything that writes links. Both
12158        // spellings are one question.
12159        let src = "## Some Heading Here\n\nbody\n";
12160        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12161        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
12162        let slugged = d.locate("some-heading-here").expect("the link's spelling");
12163        assert_eq!(exact, slugged);
12164        // The section, not the heading line — there is more to show than a title.
12165        assert_eq!(&d.source[exact.start..exact.end], src);
12166    }
12167
12168    #[test]
12169    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
12170        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
12171        assert_eq!(d.locate(""), None);
12172        assert_eq!(d.locate("   "), None);
12173        // All punctuation: it names nothing, and must not be read as "match the
12174        // first heading whose slug is also empty".
12175        assert_eq!(d.locate("!!!"), None);
12176    }
12177
12178    #[test]
12179    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
12180        // The document's mistake, and the answer every other anchor
12181        // implementation gives — the alternative is for a link to mean whichever
12182        // of the two a walk happened to reach first.
12183        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
12184        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12185        let hit = d.locate("dup").expect("the first `{#dup}`");
12186        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
12187    }
12188
12189    #[test]
12190    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
12191        // The button's whole job: a reference where the caret was, a definition
12192        // to give it meaning, and the caret waiting in the empty note so the
12193        // next keystroke is the note's first word.
12194        let mut d = doc_with("fn_insert", "A claim and more.\n");
12195        d.caret = 7; // just past "A claim"
12196        d.insert_footnote();
12197        assert!(
12198            d.source.starts_with("A claim[^1] and more."),
12199            "{:?}",
12200            d.source
12201        );
12202        assert!(
12203            d.source.contains("[^1]:"),
12204            "the definition too: {:?}",
12205            d.source
12206        );
12207        assert_eq!(d.status, None);
12208
12209        let reference = d.source.find("[^1]").unwrap();
12210        let note = d
12211            .footnote_at(reference + 2)
12212            .expect("the reference just written");
12213        assert_eq!(note.label, "1");
12214        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
12215        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
12216        // …and typing there is typing into the note, not near it.
12217        d.insert("the note");
12218        assert_eq!(
12219            d.footnote_at(reference + 2).and_then(|f| f.text),
12220            Some("the note".to_string())
12221        );
12222    }
12223
12224    #[test]
12225    fn insert_footnote_numbers_past_the_notes_already_written() {
12226        // A second press must not hand back a label somebody else is using: twig
12227        // reuses a defined label rather than appending a rival definition, so a
12228        // repeat of `1` would quietly point the new reference at the old note.
12229        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
12230        d.caret = 7; // past `[^1]`, before " two."
12231        d.insert_footnote();
12232        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
12233        assert_eq!(d.source.matches("[^2]:").count(), 1);
12234    }
12235
12236    #[test]
12237    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
12238        // `[^2]` with no definition is still a 2 that means something to whoever
12239        // wrote it — stepping over it would mint a note for their reference. A
12240        // word label takes no number, so it blocks none.
12241        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
12242        d.caret = d.source.find(" c").unwrap();
12243        d.insert_footnote();
12244        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
12245        assert!(
12246            d.source.starts_with("a[^2] b[^why][^1] c"),
12247            "{:?}",
12248            d.source
12249        );
12250    }
12251
12252    #[test]
12253    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
12254        // A reference annotates the words before it. Consuming the selection —
12255        // which is what an insert normally does — would delete the very claim
12256        // the author selected in order to footnote.
12257        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
12258        d.anchor = Some(2);
12259        d.caret = 7; // "claim" selected
12260        d.insert_footnote();
12261        assert!(
12262            d.source.starts_with("A claim[^1] and more."),
12263            "{:?}",
12264            d.source
12265        );
12266    }
12267
12268    #[test]
12269    fn a_note_just_written_still_knows_where_its_reference_is() {
12270        // The authoring loop in one test: press the button, type the note, ask to
12271        // go back. The caret ends at the note's last byte — which is the *end* of
12272        // the definition's span, the one offset the query used to exclude — so
12273        // this is where the round trip either works or doesn't.
12274        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
12275        d.caret = 7;
12276        d.insert_footnote();
12277        d.insert("the note");
12278        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
12279        let back = d
12280            .footnote_definition_at_caret()
12281            .expect("still in the note we just typed");
12282        assert_eq!(back.label, "1");
12283        // …and following it lands on the reference's label, where a reader's
12284        // return leg lands.
12285        assert_eq!(back.offset, Some(9));
12286        assert_eq!(&d.source[9..10], "1");
12287    }
12288
12289    #[test]
12290    fn insert_footnote_takes_one_undo_for_both_halves() {
12291        // twig writes the pair as a single edit; the point of that is here.
12292        let before = "A claim and more.\n";
12293        let mut d = doc_with("fn_insert_undo", before);
12294        d.caret = 7;
12295        d.insert_footnote();
12296        assert_ne!(d.source, before);
12297        d.undo();
12298        assert_eq!(d.source, before, "one undo takes back both halves");
12299    }
12300
12301    #[test]
12302    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
12303        // HTML is authorable — it spells the inline marks — and has no footnote.
12304        // The refusal says so rather than writing brackets that would render as
12305        // brackets.
12306        let src = "<p>A claim.</p>\n";
12307        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
12308        assert!(!Capabilities::of(Format::Html).footnote);
12309        d.caret = 5;
12310        d.insert_footnote();
12311        assert_eq!(d.source, src, "nothing written");
12312        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
12313    }
12314
12315    #[test]
12316    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
12317        // The empty body is the one place this could go wrong: the definition
12318        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
12319        // byte early would draw up in the paragraph above the note it belongs to.
12320        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
12321        d.place_caret(7, false);
12322        d.insert_footnote();
12323        d.build_visual(80); // the frame a frontend draws after the edit
12324        assert_eq!(
12325            d.vmap.snap_to_stop(d.caret),
12326            d.caret,
12327            "the caret sits on a stop"
12328        );
12329        let (row, _) = d.caret_pos();
12330        assert!(
12331            drawn_rows(&d)[row].contains("[1]"),
12332            "the caret is on the note's row, not above it: {:?}",
12333            drawn_rows(&d)
12334        );
12335    }
12336
12337    #[test]
12338    fn footnote_at_caret_resolves_a_reference_to_its_note() {
12339        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
12340        // blank line, as one has to.
12341        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
12342        d.caret = 9;
12343        let f = d
12344            .footnote_at_caret()
12345            .expect("the caret stands in a reference");
12346        assert_eq!(f.label, "1");
12347        assert_eq!(f.text.as_deref(), Some("the note"));
12348        // The offset points at the note's first word, not at the definition's
12349        // `[` — the marker is decoration with no caret stop on it.
12350        assert_eq!(f.offset, Some(29));
12351        assert_eq!(&d.source[29..37], "the note");
12352        // …and `end` closes the range, so a frontend can ask which rendered rows
12353        // the note occupies rather than re-deriving them from the text.
12354        assert_eq!(f.end, Some(37));
12355        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
12356    }
12357
12358    /// Two definitions in a row: each is its own note, and neither reaches into
12359    /// the other.
12360    ///
12361    /// A djot definition's span used to run past the blank line into the first
12362    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
12363    /// offsets named the *next* note's rows too, showing a reader two footnotes
12364    /// when they had asked about one. twig 3.1 ends the span after the block's
12365    /// own last line; the test outlives the workaround leaf carried for it.
12366    #[test]
12367    fn footnote_at_stops_a_note_at_the_definition_after_it() {
12368        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
12369        for format in [Format::Markdown, Format::Djot] {
12370            let mut d = Doc::from_source(src.to_string(), format).unwrap();
12371            d.caret = 7;
12372            let f = d.footnote_at_caret().expect("a reference");
12373            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
12374            assert_eq!(
12375                &src[f.offset.unwrap()..f.end.unwrap()],
12376                "first note.",
12377                "in {format:?}"
12378            );
12379        }
12380    }
12381
12382    /// The other side of that boundary: a blank line *inside* a definition is
12383    /// interior to it, and the note keeps its second paragraph.
12384    ///
12385    /// This is what the old body scan cost. It stopped at the first line not
12386    /// indented under the note — a blank line is not — so a two-paragraph note
12387    /// came back as its first paragraph, and "go to note" framed half of it.
12388    /// Reading the span twig gives is both simpler and right.
12389    #[test]
12390    fn footnote_at_keeps_a_notes_second_paragraph() {
12391        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
12392        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12393        d.caret = 7;
12394        let f = d.footnote_at_caret().expect("a reference");
12395        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
12396        // And it stops there — `After.` is the next block, not more note.
12397        assert_eq!(
12398            &src[f.offset.unwrap()..f.end.unwrap()],
12399            f.text.as_deref().unwrap()
12400        );
12401        assert!(!f.text.as_deref().unwrap().contains("After"));
12402    }
12403
12404    #[test]
12405    fn footnote_at_bounds_a_note_whose_body_is_empty() {
12406        // `[^1]:` with nothing after it. The range is empty rather than
12407        // inverted, and still points inside the definition — which is what keeps
12408        // a frontend's row lookup from walking off into the block above.
12409        let src = "A claim[^1].\n\n[^1]:\n";
12410        let mut d = doc_with("fn_empty_body", src);
12411        d.caret = 9;
12412        let f = d.footnote_at_caret().expect("a reference");
12413        assert_eq!(f.text.as_deref(), Some(""));
12414        assert_eq!(f.offset, f.end, "an empty note is an empty range");
12415        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
12416    }
12417
12418    #[test]
12419    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
12420        let mut d = doc_with(
12421            "fn_at_caret_none",
12422            "A claim[^1] and more.\n\n[^1]: the note\n",
12423        );
12424        d.caret = 2; // in the prose
12425        assert_eq!(d.footnote_at_caret(), None);
12426    }
12427
12428    #[test]
12429    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
12430        // The two are deliberately separate: a reference names a note in this
12431        // document, a link names somewhere to leave for, and answering one with
12432        // the other is what made a reference click do nothing at all.
12433        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
12434        d.caret = 3; // the `1` of `[^1]`
12435        assert!(d.footnote_at_caret().is_some());
12436        assert_eq!(
12437            d.link_destination_at_caret(),
12438            None,
12439            "a reference is not a link"
12440        );
12441
12442        d.caret = 10; // inside the link's label
12443        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
12444        assert_eq!(
12445            d.link_destination_at_caret().as_deref(),
12446            Some("https://x.dev")
12447        );
12448    }
12449
12450    #[test]
12451    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
12452        // A `[^99]` the document never defines is a real state — a note deleted
12453        // out from under its reference — and the label is what lets a frontend
12454        // say so. `None` here would be indistinguishable from "not on a
12455        // reference", which is the wrong thing to tell a reader.
12456        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
12457        d.caret = 9;
12458        let f = d
12459            .footnote_at_caret()
12460            .expect("the reference is still a reference");
12461        assert_eq!(f.label, "99");
12462        assert_eq!(f.text, None);
12463        assert_eq!(f.offset, None);
12464    }
12465
12466    #[test]
12467    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
12468        // Labels are not always numbers, and a note's body runs past its first
12469        // line — the indented continuation belongs to the note, so it comes back
12470        // with it (source bytes, verbatim, as documented).
12471        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
12472        let mut d = doc_with("fn_word_label", src);
12473        d.caret = 6;
12474        let f = d
12475            .footnote_at_caret()
12476            .expect("the caret stands in a reference");
12477        assert_eq!(f.label, "note");
12478        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
12479    }
12480
12481    #[test]
12482    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
12483        // The point of the offset form: a pointer hovering a reference asks what
12484        // note it names, and must not drag the caret along to ask.
12485        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
12486        d.caret = 0;
12487        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
12488        assert_eq!(f.label, "1");
12489        assert_eq!(f.text.as_deref(), Some("the note"));
12490        assert_eq!(d.caret, 0, "asking must not move the caret");
12491        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
12492    }
12493
12494    #[test]
12495    fn footnote_definition_at_caret_points_back_at_the_reference() {
12496        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
12497        // the caret can rest on — is at 9.
12498        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
12499        d.caret = 30; // inside the note's body
12500        let f = d
12501            .footnote_definition_at_caret()
12502            .expect("the caret stands in a definition");
12503        assert_eq!(f.label, "1");
12504        assert_eq!(f.offset, Some(9));
12505        assert_eq!(&d.source[7..11], "[^1]");
12506    }
12507
12508    #[test]
12509    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
12510        // The two legs have to meet: wherever `footnote_at` sends the caret, the
12511        // definition query must answer for — otherwise arriving at a note leaves
12512        // the reader somewhere the way back isn't offered.
12513        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
12514        let mut d = doc_with("fn_def_marker", src);
12515        let landed = d.footnote_at(9).unwrap().offset.unwrap();
12516        assert_eq!(
12517            d.footnote_definition_at(landed).and_then(|f| f.offset),
12518            Some(9),
12519            "the note a reference sends you to offers the way back"
12520        );
12521    }
12522
12523    #[test]
12524    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
12525        // The two queries answer for disjoint places, which is what lets one
12526        // gesture mean "down to the note" in one and "back up" in the other
12527        // without either having to remember which way the reader is going.
12528        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
12529        d.caret = 2; // prose
12530        assert_eq!(d.footnote_definition_at_caret(), None);
12531        d.caret = 9; // the reference
12532        assert_eq!(d.footnote_definition_at_caret(), None);
12533        assert!(
12534            d.footnote_at_caret().is_some(),
12535            "which is the reference's own query"
12536        );
12537    }
12538
12539    #[test]
12540    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
12541        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
12542        // note", which is false and leaves a frontend unable to explain why the
12543        // way back is missing.
12544        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
12545        let mut d = doc_with("fn_def_orphan", src);
12546        d.caret = src.find("orphan").unwrap();
12547        let f = d
12548            .footnote_definition_at_caret()
12549            .expect("an orphan is still a definition");
12550        assert_eq!(f.label, "2");
12551        assert_eq!(f.offset, None);
12552    }
12553
12554    #[test]
12555    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
12556        // One label, cited twice. The first is where the reader most likely came
12557        // from, and the only answer that doesn't depend on how they got here.
12558        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
12559        let mut d = doc_with("fn_def_repeat", src);
12560        d.caret = src.find("the note").unwrap();
12561        let f = d.footnote_definition_at_caret().expect("a definition");
12562        assert_eq!(
12563            f.offset,
12564            Some(5),
12565            "the first `[^a]`'s label, not the second's"
12566        );
12567        assert_eq!(&src[3..7], "[^a]");
12568    }
12569
12570    #[test]
12571    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
12572        // Down and back up, each leg found from the document rather than from a
12573        // memory of the other — so it still works for a reader who scrolled to
12574        // the notes instead of jumping there.
12575        //
12576        // `place_caret` rather than assigning `caret`, because that is what a
12577        // frontend calls: it snaps to a real caret stop, and a jump that lands
12578        // on a byte the caret can't rest on would arrive somewhere the return
12579        // leg no longer answers for. `build_map` first, since snapping is a
12580        // no-op until the map exists — which is exactly how this went unnoticed
12581        // when the offsets pointed at the `[^` markers.
12582        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
12583        d.build_map(None);
12584        d.place_caret(9, false);
12585        let down = d
12586            .footnote_at_caret()
12587            .expect("a reference")
12588            .offset
12589            .expect("a note");
12590        d.place_caret(down, false);
12591        let up = d
12592            .footnote_definition_at_caret()
12593            .expect("a definition")
12594            .offset
12595            .expect("a reference");
12596        d.place_caret(up, false);
12597        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
12598        assert_eq!(
12599            d.footnote_at_caret().expect("back on the reference").label,
12600            "1"
12601        );
12602    }
12603
12604    #[test]
12605    fn insert_link_hands_the_destination_to_twig_raw() {
12606        // Escaping is twig's, and format-specific: Markdown ends a destination
12607        // at the first space and needs the `<…>` form, where djot would read
12608        // those angle brackets as part of the URL.
12609        let mut d = doc_with("link_space", "word\n");
12610        d.anchor = Some(0);
12611        d.caret = 4;
12612        d.insert_link("a b");
12613        assert_eq!(d.source, "[word](<a b>)\n");
12614    }
12615
12616    #[test]
12617    fn insert_link_reports_a_destination_no_format_can_carry() {
12618        let mut d = doc_with("link_bad", "word\n");
12619        d.anchor = Some(0);
12620        d.caret = 4;
12621        d.insert_link("a\nb");
12622        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
12623        assert!(
12624            d.status.is_some(),
12625            "InvalidArgument should reach the status line"
12626        );
12627        assert!(!d.dirty);
12628    }
12629
12630    #[test]
12631    fn insert_link_works_in_wysiwyg_view() {
12632        let mut d = wysiwyg_doc("link_wys", "word here\n");
12633        d.anchor = Some(0);
12634        d.caret = 4;
12635        d.insert_link("http://x.dev");
12636        assert_eq!(d.source, "[word](http://x.dev) here\n");
12637        assert_eq!(d.selected_text(), Some("word"));
12638        // The map the caret has to keep riding is rebuilt each frame; motion
12639        // over the fresh one must still land on a real stop (the debug_assert).
12640        d.build_visual(80);
12641        d.move_right(false);
12642        d.move_left(false);
12643    }
12644
12645    #[test]
12646    fn click_maps_a_row_col_to_a_byte_offset() {
12647        let mut d = doc_with("click", "ab\ncd\n");
12648        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
12649        assert_eq!(d.caret, 4);
12650    }
12651
12652    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
12653    // stop just as the `(row, col)` click path does, so the caret can never come
12654    // to rest in the blank gap between two paragraphs — where it would draw in one
12655    // place and type in another.
12656    #[test]
12657    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
12658        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
12659        // caret stop (stops are 0,1,3,4).
12660        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
12661        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
12662        d.place_caret(2, false);
12663        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12664        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
12665    }
12666
12667    #[test]
12668    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
12669        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
12670        d.place_caret(0, false); // anchor at the start of "A"
12671        d.place_caret(2, true); // drag into the gap
12672        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12673        let (s, e) = d.selection().expect("a selection");
12674        assert!(
12675            d.vmap.is_stop(s) && d.vmap.is_stop(e),
12676            "selection {s}..{e} off a stop"
12677        );
12678    }
12679
12680    #[test]
12681    fn place_caret_on_a_real_stop_is_left_untouched() {
12682        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
12683        d.place_caret(3, false); // the start of "B" — a genuine stop
12684        assert_eq!(d.caret, 3);
12685    }
12686
12687    // An *empty paragraph* (two blank lines, an intentional blank line the user
12688    // opened) is a real caret stop, unlike the gap — a click into it must stay.
12689    #[test]
12690    fn place_caret_rests_in_an_empty_paragraph() {
12691        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
12692        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
12693        assert!(d.vmap.is_stop(empty));
12694        d.place_caret(empty, false);
12695        assert_eq!(d.caret, empty);
12696    }
12697
12698    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
12699    // a drag over the word `bold` ends there, and a caret placed there stays.
12700    #[test]
12701    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
12702        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
12703        let mut d = wysiwyg_doc("place_mark_end", src);
12704        let start = src.find("bold").unwrap();
12705        d.place_caret(start, false);
12706        d.place_caret(start + 4, true);
12707        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
12708        d.toggle(InlineKind::Strong);
12709        assert_eq!(d.source, src.replace("**bold**", "bold"));
12710    }
12711
12712    #[test]
12713    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
12714        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
12715        d.caret = 7; // before the `d`
12716        d.move_right(false);
12717        assert_eq!(d.caret, 8, "onto the end of the bold");
12718        assert!(d.active_inline_marks().contains(InlineKind::Strong));
12719        d.move_right(false);
12720        assert_eq!(d.caret, 10, "past the closing `**`");
12721        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
12722        d.move_left(false);
12723        assert_eq!(d.caret, 8);
12724        d.move_left(false);
12725        assert_eq!(d.caret, 7);
12726        // Typing at the inner home extends the bold.
12727        d.caret = 8;
12728        d.insert("!");
12729        assert_eq!(d.source, "a **bold!** b");
12730    }
12731
12732    #[test]
12733    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
12734        // The splice path shifts the home with the block it is in, and the
12735        // re-rendered block finds its own again.
12736        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
12737        d.build_visual_unwrapped();
12738        d.edit(0, 0, "zz");
12739        d.build_visual_unwrapped();
12740        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
12741        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12742        let at = d.source.find("bold").unwrap();
12743        d.edit(at, at, "very ");
12744        d.build_visual_unwrapped();
12745        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
12746        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12747    }
12748
12749    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
12750        doc_in(View::Wysiwyg, name, body)
12751    }
12752
12753    /// How many list items the source actually parses into — the check that a
12754    /// marker Leaf wrote is a marker the format agrees is one.
12755    fn list_items(doc: &mut Doc) -> usize {
12756        doc.editor
12757            .nodes()
12758            .unwrap()
12759            .iter()
12760            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
12761            .count()
12762    }
12763
12764    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
12765    /// incremental (`build_spliced` / `build_cached`) path must always match.
12766    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
12767        reference_map_revealing(source, None)
12768    }
12769
12770    /// [`reference_map`] with a reveal line — the ground truth for the
12771    /// `MarkupMode::Full` builds, where the map is a function of the caret's
12772    /// line as well as the text.
12773    fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
12774        // The same parse `Doc` uses. With twig's plain defaults instead, the two
12775        // sides disagree on what the *document* is before the renderer is even
12776        // reached — a bare `:word` is a text directive to one and prose to the
12777        // other — and the mismatch reads as a splice bug that isn't one.
12778        let mut ed =
12779            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
12780        let nodes = ed.nodes().unwrap();
12781        crate::wysiwyg::build(
12782            &nodes,
12783            source,
12784            None,
12785            false,
12786            &wysiwyg::Surface::default(),
12787            reveal,
12788        )
12789    }
12790
12791    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
12792        if a.rows.len() != b.rows.len() {
12793            return true;
12794        }
12795        for (ra, rb) in a.rows.iter().zip(&b.rows) {
12796            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
12797                return true;
12798            }
12799            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
12800                if ga.ch != gb.ch || ga.src != gb.src {
12801                    return true;
12802                }
12803            }
12804        }
12805        false
12806    }
12807
12808    #[test]
12809    fn incremental_build_matches_a_fresh_build_across_edits() {
12810        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
12811        // fast path, gated on twig's `dirty_range`) or falls back to
12812        // `build_cached`. After each edit the map must be byte-identical to a
12813        // from-scratch build — this is the correctness net under the splice.
12814        let docs = [
12815            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
12816            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
12817            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
12818            // A footnote definition is a root beside `doc`, merged back into the
12819            // top-level list by `wysiwyg::top_blocks`. The random edits below
12820            // make and unmake definitions as they go (a deleted `:` turns one
12821            // back into a paragraph, and vice versa), which is exactly the
12822            // structural churn the splice path has to notice and bail out of.
12823            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
12824            // A comment is a top-level block that draws no rows — a layout entry
12825            // at zero rows either side of blocks that do. The edits below type
12826            // into the blocks around it (a splice past a hidden block), and
12827            // break the comment open into prose and back (a structural change).
12828            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
12829            // Link reference definitions: a hidden block that an edit can turn
12830            // into a paragraph (a deleted `:`) and back, and whose own bytes an
12831            // edit can land in.
12832            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
12833        ];
12834        // A deterministic mix: mostly single characters (which stay inside one
12835        // block → splice), plus edits that reshape structure (a paragraph break,
12836        // a heading marker, a code fence → fallback), so both paths are exercised.
12837        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
12838        for src in docs {
12839            let mut d = wysiwyg_doc("diff", src);
12840            d.build_visual_unwrapped();
12841            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
12842
12843            for step in 0..60usize {
12844                let len = d.source.len();
12845                let raw = (step * 13 + 5) % (len + 1);
12846                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
12847                let pre = d.source.clone();
12848                let action;
12849                if step % 3 == 0 && pos < len {
12850                    let end = (pos + 1..=len)
12851                        .find(|&i| d.source.is_char_boundary(i))
12852                        .unwrap();
12853                    action = format!("delete [{pos},{end})");
12854                    d.edit(pos, end, "");
12855                } else {
12856                    let ins = inserts[step % inserts.len()];
12857                    action = format!("insert {ins:?} @ {pos}");
12858                    d.edit(pos, pos, ins);
12859                }
12860                d.build_visual_unwrapped();
12861                if maps_differ(&d.vmap, &reference_map(&d.source)) {
12862                    panic!(
12863                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
12864                        d.source
12865                    );
12866                }
12867            }
12868        }
12869    }
12870
12871    /// A frontend is handed [`Doc::vmap`] and may present it differently:
12872    /// leaf-ratatui splices blank filler rows under an oversized heading so the
12873    /// raster it paints there has somewhere to stand, and leaves them in the map
12874    /// because the caret and the mouse both read it between frames. The splice
12875    /// path addresses that map by *row index*, against the block layout the last
12876    /// build recorded — so handed a map with rows in it that no block owns, it
12877    /// laid the re-rendered block over one of the fillers and carried the rows
12878    /// the block really occupied into the suffix. One stranded copy of the
12879    /// edited line, and everything below it a row further down, per keystroke.
12880    ///
12881    /// A map that isn't the one the layout describes is a map this path can't
12882    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
12883    #[test]
12884    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
12885        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
12886        d.build_visual_unwrapped();
12887
12888        // Stand in for the heading filler rows: two blank rows past the heading
12889        // that no block accounts for. Cloning a real row keeps every field
12890        // plausible — it is the row *count* the splice can't survive.
12891        let filler = d.vmap.rows[0].clone();
12892        d.vmap.rows.insert(1, filler.clone());
12893        d.vmap.rows.insert(1, filler);
12894
12895        // An edit inside the last block: the single-block case the splice path
12896        // is for, and the one the frontend hits on every keystroke.
12897        let at = d.source.len() - 1;
12898        d.edit(at, at, "!");
12899        d.build_visual_unwrapped();
12900
12901        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
12902    }
12903
12904    /// A glyph's [`FaceId`] has to mean the same thing however its row was
12905    /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
12906    /// cloned at a shifted offset, and a previous map's rows a splice kept
12907    /// untouched — and only the first of those walks a `data-font` at all. An
12908    /// index into a per-build table would have had the same glyph naming two
12909    /// families the moment a second one appeared; the id is the name's own
12910    /// hash, so nothing is remapped and the table is merged rather than rebuilt.
12911    ///
12912    /// Two families, because one cannot tell a wrong id from a right one.
12913    ///
12914    /// [`FaceId`]: crate::style::FaceId
12915    /// [`BlockCache`]: crate::wysiwyg::BlockCache
12916    #[test]
12917    fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
12918        use crate::style::{FaceId, FaceRef};
12919        let garamond = FaceId::of("Garamond");
12920        let futura = FaceId::of("Futura");
12921        let mut d = wysiwyg_doc(
12922            "two_faces",
12923            "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
12924        );
12925        d.build_visual_unwrapped();
12926
12927        // What the map has to keep saying, whichever path built it.
12928        let check = |d: &Doc, ctx: &str| {
12929            let face_of = |ch: char| {
12930                d.vmap
12931                    .rows
12932                    .iter()
12933                    .flat_map(|r| r.glyphs.iter())
12934                    .find(|g| g.ch == ch)
12935                    .map(|g| g.style.font)
12936            };
12937            assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
12938            assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
12939            assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
12940            assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
12941            assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
12942        };
12943        check(&d, "fresh");
12944
12945        // An edit inside the second block: the single-block case the splice
12946        // path is for. The first block's rows are carried over untouched, so
12947        // its glyphs' ids are the previous build's and the table has to be too.
12948        let at = d.source.find("beta").unwrap();
12949        d.edit(at, at, "z");
12950        d.build_visual_unwrapped();
12951        check(&d, "after an edit in the second block");
12952        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
12953
12954        // And the other way round, so the block that was kept is the one that
12955        // is now re-rendered.
12956        let at = d.source.find("alpha").unwrap();
12957        d.edit(at, at, "z");
12958        d.build_visual_unwrapped();
12959        check(&d, "after an edit in the first block");
12960        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
12961
12962        // A structural edit is one the splice bails out of, so the map is
12963        // reassembled by `build_cached` — where an untouched block is a *cache
12964        // hit* and its rows are cloned without a `data-font` being walked
12965        // again. The names the entry stored are what keeps the table honest
12966        // there.
12967        let at = d.source.find("\n\ny ").unwrap();
12968        d.edit(at, at, "\n\nmiddle");
12969        d.build_visual_unwrapped();
12970        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
12971        // Twice, because that first `build_cached` is what stores the entries:
12972        // this one is the build where the Garamond block is a *hit*, its rows
12973        // cloned with their ids and no attribute walked to explain them.
12974        let at = d.source.len() - 1;
12975        d.edit(at, at, "\n\ntail");
12976        d.build_visual_unwrapped();
12977        check(&d, "after a structural edit, through the block cache");
12978        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
12979
12980        // A family the edit took the last glyph of leaves the glyphs with no
12981        // face and the table with a name nothing asks for — harmless, and the
12982        // price of not walking the rows the splice exists to avoid walking.
12983        let span = d.source.find("<span data-font=\"Futura\">").unwrap();
12984        let end = d.source.rfind("</span>").unwrap() + "</span>".len();
12985        d.edit(span, end, "beta");
12986        d.build_visual_unwrapped();
12987        assert!(!d.source.contains("Futura"), "{:?}", d.source);
12988        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
12989    }
12990
12991    #[test]
12992    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
12993        // The same correctness net as `incremental_build_matches_a_fresh_build_
12994        // across_edits`, under `MarkupMode::Full` — where the map depends on
12995        // the caret's *line* as well as the text, so the two caches have a new
12996        // way to be wrong. Both are exercised: the block cache can hand back
12997        // rows built for a line that is no longer the revealed one, and the
12998        // splice path can reuse a suffix that still has yesterday's line raw.
12999        //
13000        // Caret motion is interleaved with the edits deliberately, because a
13001        // caret that only ever moved with the edit would never cross a line
13002        // without also dirtying it — the case where a stale reveal survives.
13003        let docs = [
13004            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
13005            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
13006        ];
13007        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
13008        for src in docs {
13009            let mut d = wysiwyg_doc("reveal_diff", src);
13010            d.set_markup_mode(MarkupMode::Full);
13011
13012            for step in 0..60usize {
13013                let len = d.source.len();
13014                let raw = (step * 13 + 5) % (len + 1);
13015                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
13016                let pre = d.source.clone();
13017                let action;
13018                if step % 3 == 0 && pos < len {
13019                    let end = (pos + 1..=len)
13020                        .find(|&i| d.source.is_char_boundary(i))
13021                        .unwrap();
13022                    action = format!("delete [{pos},{end})");
13023                    d.edit(pos, end, "");
13024                } else {
13025                    let ins = inserts[step % inserts.len()];
13026                    action = format!("insert {ins:?} @ {pos}");
13027                    d.edit(pos, pos, ins);
13028                }
13029                // Walk the caret somewhere else in the document, independently
13030                // of where the edit landed.
13031                let want = (step * 29 + 11) % (d.source.len() + 1);
13032                d.caret = (want..=d.source.len())
13033                    .find(|&i| d.source.is_char_boundary(i))
13034                    .unwrap();
13035                d.build_visual_unwrapped();
13036
13037                let want = reference_map_revealing(&d.source, d.reveal_line());
13038                if maps_differ(&d.vmap, &want) {
13039                    panic!(
13040                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
13041                        d.caret, d.source
13042                    );
13043                }
13044            }
13045        }
13046    }
13047
13048    #[test]
13049    fn caret_motion_across_lines_rebuilds_only_under_full() {
13050        // The cache-key change has to earn its keep in both directions: `Full`
13051        // must rebuild when the caret changes line (or the reveal would never
13052        // move), and the hidden modes must *not* (or every arrow key would pay
13053        // for a feature they don't use). The existing `cache_motion` test pins
13054        // the second for the default mode; this pins the pair against a mode
13055        // change alone.
13056        let body = "*one* here\n\n*two* there\n";
13057
13058        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
13059        full.set_markup_mode(MarkupMode::Full);
13060        caret_at(&mut full, "one");
13061        let before = full.revision();
13062        caret_at(&mut full, "two");
13063        assert_eq!(full.revision(), before, "motion is not an edit");
13064        assert!(
13065            drawn_rows(&full).iter().any(|r| r == "*two* there"),
13066            "the map followed the caret: {:?}",
13067            drawn_rows(&full)
13068        );
13069
13070        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
13071        caret_at(&mut hidden, "one");
13072        let key = hidden.vmap_key.clone();
13073        caret_at(&mut hidden, "two");
13074        assert_eq!(
13075            hidden.vmap_key, key,
13076            "a hidden mode rebuilds nothing on motion"
13077        );
13078    }
13079
13080    #[test]
13081    fn wysiwyg_down_crosses_a_paragraph_boundary() {
13082        // Regression: the blank separator row used to share the previous
13083        // paragraph's end offset, so Down got pinned at the boundary (while Up
13084        // still crossed). Both directions must step through it symmetrically.
13085        //
13086        // It's now stepped *over* rather than onto: the blank line between two
13087        // paragraphs is the boundary being drawn, not a line of the document, so
13088        // one press of Down crosses it. The goal column survives the crossing —
13089        // col 3 at the end of "abc" is col 3 at the end of "def".
13090        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
13091        d.caret = 3; // end of "abc" (row 0)
13092        d.move_down(false);
13093        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
13094        assert_eq!(d.caret, 8); // end of "def", col 3 kept
13095        d.move_up(false);
13096        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
13097        assert_eq!(d.caret, 3);
13098    }
13099
13100    #[test]
13101    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
13102        // The second Up and the second Down here run off the ends of the
13103        // document, which is no longer a place a press is swallowed: they carry
13104        // the caret to the start and the end of the text. The claim in the
13105        // middle — that a Down retraces the Up that crossed the paragraph gap —
13106        // is the one this test is for, and it is asserted where it is made.
13107        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
13108        d.caret = 5; // start of "def"
13109        let start = d.caret_pos();
13110        d.move_up(false);
13111        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
13112        d.move_up(false);
13113        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
13114        d.move_down(false);
13115        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
13116        d.move_down(false);
13117        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
13118    }
13119
13120    #[test]
13121    fn wysiwyg_new_paragraph_shows_before_typing() {
13122        // Regression: two Enters at the end of a paragraph produced trailing
13123        // newlines with no AST node, so the caret appeared stuck on the old line
13124        // until a character was typed. It must ride down onto the new line now.
13125        let mut d = doc_with("wys_newpara", "abc\n");
13126        d.view = View::Wysiwyg;
13127        d.caret = 3;
13128        d.insert("\n");
13129        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
13130        assert_eq!(d.source, "abc\n\n\n");
13131        d.build_visual(80);
13132        let (row, _) = d.caret_pos();
13133        assert!(
13134            row >= 2,
13135            "caret should have moved down to the new line, got row {row}"
13136        );
13137        assert!(
13138            d.vmap.num_rows() >= 3,
13139            "the blank lines should render as rows"
13140        );
13141    }
13142
13143    #[test]
13144    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
13145        // The reported bug: Enter at the end of a paragraph that has another
13146        // paragraph below put the caret at the *start of the next paragraph* —
13147        // the empty paragraph it opened had no row, so the caret snapped onto
13148        // "World". It must now sit on its own empty line, with a blank spacer
13149        // above it (the paragraph gap).
13150        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
13151        d.caret = 5; // end of "Hello"
13152        d.newline();
13153        d.build_visual(80);
13154        let (row, col) = d.caret_pos();
13155        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
13156        assert_eq!(
13157            d.vmap.row_width(row),
13158            0,
13159            "caret's row must be empty, not 'World'"
13160        );
13161        assert!(
13162            row >= 2,
13163            "a blank spacer row should sit above the caret, got row {row}"
13164        );
13165        // The row above the caret is a real (empty) gap, and "Hello" stays put.
13166        assert_eq!(
13167            d.vmap.row_width(row - 1),
13168            0,
13169            "the row above the caret is a gap"
13170        );
13171        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
13172        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
13173    }
13174
13175    #[test]
13176    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
13177        // At the document end a single Enter must also show the paragraph gap —
13178        // a blank spacer row above the caret — so the layout already matches how
13179        // it will look once the new paragraph has text.
13180        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
13181        d.caret = 5; // end of "Hello", no trailing newline
13182        d.newline(); // source becomes "Hello\n\n"
13183        d.build_visual(80);
13184        let (row, col) = d.caret_pos();
13185        assert_eq!(col, 0);
13186        assert!(
13187            row >= 2,
13188            "caret should sit below a blank spacer, got row {row}"
13189        );
13190        assert_eq!(
13191            d.vmap.row_width(row - 1),
13192            0,
13193            "the row above the caret is a gap"
13194        );
13195    }
13196
13197    #[test]
13198    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
13199        // The spacer is view-only: typing the new paragraph must not reflow the
13200        // caret onto a different row — the transient view already matched the
13201        // settled one.
13202        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
13203        d.caret = 5;
13204        d.newline();
13205        d.build_visual(80);
13206        let before = d.caret_pos();
13207        d.insert("New");
13208        d.build_visual(80);
13209        let after = d.caret_pos();
13210        assert_eq!(
13211            after.0, before.0,
13212            "typing must not move the caret to another row ({before:?} -> {after:?})"
13213        );
13214    }
13215
13216    #[test]
13217    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
13218        // Return at the end of the block's last line writes an empty line the
13219        // map used to drop, so the caret landed on `after` and the next
13220        // keystroke went into the paragraph below instead of into the code.
13221        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
13222        d.caret = d.source.find("beta").unwrap() + "beta".len();
13223        d.build_visual(80);
13224        let before = d.caret_pos().0;
13225
13226        d.newline();
13227        d.build_visual(80);
13228        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
13229
13230        let (row, col) = d.caret_pos();
13231        assert_eq!(row, before + 1, "the caret moves down one row");
13232        assert_eq!(col, 0, "onto the head of the empty line");
13233        let span = d.vmap.code_blocks[0].rows_span.clone();
13234        assert!(
13235            span.contains(&row),
13236            "caret row {row} is outside the block's rows {span:?}"
13237        );
13238
13239        // The whole point: what is typed next is code.
13240        d.insert("gamma");
13241        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
13242    }
13243
13244    #[test]
13245    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
13246        let fm = "---\ntitle: hi\n---\n";
13247        let body = format!("{fm}# leaf\n\nbody\n");
13248        let mut d = wysiwyg_doc("wys_fm", &body);
13249        // Opening lifts the caret out of the now-hidden frontmatter.
13250        assert_eq!(
13251            d.caret,
13252            fm.len(),
13253            "caret should start at the first real block"
13254        );
13255        // Left at the content start can't step back into frontmatter.
13256        d.move_left(false);
13257        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
13258        // Doc-start lands on the content floor, not offset 0.
13259        d.move_doc_start(false);
13260        assert_eq!(d.caret, fm.len());
13261        // Select-all + copy never include the frontmatter bytes.
13262        d.select_all();
13263        let sel = d.selected_text().unwrap().to_string();
13264        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
13265        assert!(
13266            sel.starts_with("# leaf"),
13267            "selection should begin at content: {sel:?}"
13268        );
13269    }
13270
13271    #[test]
13272    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
13273        // A fresh note is frontmatter and nothing else. With no rendered block
13274        // to floor the caret it opened at offset 0 — before the opening `---` —
13275        // so the first keystroke wrote itself in front of the metadata and the
13276        // file came out as `This---\ntitle: …`.
13277        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
13278        let mut d = wysiwyg_doc("wys_fm_only", fm);
13279        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
13280        // Nothing is rendered, so the caret draws at the origin of an empty view
13281        // — the same place an empty document puts it.
13282        assert_eq!(d.caret_pos(), (0, 0));
13283        d.insert("This");
13284        assert_eq!(d.source, format!("{fm}This"));
13285    }
13286
13287    /// `select_range` is the verb for a range a host already knows the bytes of,
13288    /// so it must not snap — and must still hold every invariant `place_caret`
13289    /// holds, the frontmatter floor above all.
13290    #[test]
13291    fn select_range_takes_the_range_as_given_but_still_floors_it() {
13292        let fm = "---\ntitle: foo\n---\n\n";
13293        let body = format!("{fm}body foo here\n");
13294        let mut d = wysiwyg_doc("wys_select_range", &body);
13295
13296        // The `foo` in the body: taken exactly, not snapped to a caret stop.
13297        let at = body.rfind("foo").unwrap();
13298        d.select_range(at, at + 3);
13299        assert_eq!(d.selection(), Some((at, at + 3)));
13300        assert_eq!(d.selected_text(), Some("foo"));
13301
13302        // The `foo` in the hidden frontmatter: below the floor, so both ends
13303        // come up to it rather than parking the caret in the metadata, where a
13304        // later keystroke would rewrite `title:`.
13305        let hidden = body.find("foo").unwrap();
13306        assert!(hidden < d.vmap.content_start);
13307        d.select_range(hidden, hidden + 3);
13308        assert!(
13309            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
13310            "a range under the floor must not leave the caret in the frontmatter"
13311        );
13312
13313        // Past the end, and mid-character, are both brought back to something
13314        // sliceable rather than panicking the next reader of the range.
13315        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
13316        let mut d = multi;
13317        d.select_range(2, 9_999);
13318        assert_eq!(d.caret, d.source.len());
13319        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
13320        assert!(d.source.is_char_boundary(d.caret));
13321    }
13322
13323    /// The bug `select_range` exists for: a match butting up against a hidden
13324    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
13325    /// the one before the `**`.
13326    #[test]
13327    fn select_range_does_not_snap_off_a_hidden_delimiter() {
13328        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
13329        let at = d.source.find("needle").unwrap();
13330        d.select_range(at, at + 6);
13331        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
13332    }
13333
13334    #[test]
13335    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
13336        // Backspace deletes `prev_boundary..caret` directly; at the first real
13337        // block that boundary is inside the hidden frontmatter, so it must be a
13338        // no-op rather than eating the closing `---`.
13339        let fm = "---\ntitle: hi\n---\n";
13340        let body = format!("{fm}leaf\n");
13341        let mut d = wysiwyg_doc("wys_fm_bs", &body);
13342        assert_eq!(d.caret, fm.len());
13343        d.backspace();
13344        assert_eq!(d.source, body, "backspace must not touch frontmatter");
13345        d.delete_word_back();
13346        assert_eq!(
13347            d.source, body,
13348            "word-delete must not touch frontmatter either"
13349        );
13350    }
13351
13352    #[test]
13353    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
13354        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
13355        // fenced div, really, since core parses these on for every document
13356        // now (`parse_extensions`). The container is a `directive` node, an
13357        // `is_block_container` kind like `block_quote`, so the caret works
13358        // inside its child paragraph exactly as it would inside a quote: typing
13359        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
13360        // untouched.
13361        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
13362        let mut d = wysiwyg_doc("wys_vis", body);
13363        d.caret = body.find("hello").unwrap() + "hello".len();
13364        d.insert("!");
13365        assert_eq!(
13366            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
13367            "typing inside the block edits its content in place"
13368        );
13369        assert!(
13370            d.source.contains(":::vis{.public .family}"),
13371            "opening fence survives"
13372        );
13373        assert!(d.source.contains(":::\nafter"), "closing fence survives");
13374    }
13375
13376    #[test]
13377    fn source_view_still_reaches_frontmatter() {
13378        // The metadata is only *hidden*, never lost: the source view edits and
13379        // selects it in full, and it's always preserved on save.
13380        let fm = "---\ntitle: hi\n---\n";
13381        let body = format!("{fm}# leaf\n");
13382        let mut d = doc_with("src_fm", &body);
13383        d.select_all();
13384        let sel = d.selected_text().unwrap();
13385        assert!(
13386            sel.contains("title"),
13387            "source view should select everything"
13388        );
13389        d.move_doc_start(false);
13390        assert_eq!(d.caret, 0, "source view can reach offset 0");
13391    }
13392
13393    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
13394
13395    #[test]
13396    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
13397        // The border and padding between two cells all share one source offset,
13398        // so a column-stepping caret would sit on `│` and then stall there
13399        // forever. Right must step: end of "Name" -> start of "Qty".
13400        let mut d = wysiwyg_doc("tbl_right", TABLE);
13401        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
13402        d.move_right(false);
13403        assert_eq!(
13404            d.caret,
13405            TABLE.find("Qty").unwrap(),
13406            "should land in the next cell"
13407        );
13408        let (r, c) = d.caret_pos();
13409        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
13410    }
13411
13412    #[test]
13413    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
13414        let mut d = wysiwyg_doc("tbl_left", TABLE);
13415        d.caret = TABLE.find("Qty").unwrap();
13416        d.move_left(false);
13417        assert_eq!(
13418            d.caret,
13419            TABLE.find("Name").unwrap() + 4,
13420            "end of the previous cell"
13421        );
13422    }
13423
13424    #[test]
13425    fn wysiwyg_down_steps_over_a_table_rule() {
13426        // Between the header and the first body row sits a `├───┼───┤` rule.
13427        // It's drawn but holds no caret, so one Down must reach "Pear".
13428        let mut d = wysiwyg_doc("tbl_down", TABLE);
13429        d.caret = TABLE.find("Name").unwrap();
13430        d.move_down(false);
13431        assert_eq!(
13432            d.caret,
13433            TABLE.find("Pear").unwrap(),
13434            "one Down reaches the body row"
13435        );
13436        d.move_down(false);
13437        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
13438    }
13439
13440    #[test]
13441    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
13442        let mut d = wysiwyg_doc("tbl_tab", TABLE);
13443        d.caret = TABLE.find("Name").unwrap();
13444        // A hop lands with the destination cell's whole content selected, the
13445        // caret at its end — so typing replaces the cell like a form field.
13446        assert!(d.cell_hop(true));
13447        assert_eq!(
13448            d.selected_text(),
13449            Some("Qty"),
13450            "the target cell comes up selected"
13451        );
13452        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
13453        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
13454        assert_eq!(d.selected_text(), Some("Pear"));
13455        assert!(d.cell_hop(false));
13456        assert_eq!(d.selected_text(), Some("Qty"));
13457    }
13458
13459    #[test]
13460    fn tab_outside_a_table_is_not_a_cell_hop() {
13461        // `cell_hop` reports false so the frontend can indent as usual.
13462        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
13463        d.caret = 4;
13464        assert!(!d.cell_hop(true));
13465        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
13466    }
13467
13468    #[test]
13469    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
13470        let mut d = wysiwyg_doc("tbl_edge", TABLE);
13471        d.caret = TABLE.rfind("12").unwrap(); // the final cell
13472        assert!(!d.cell_hop(true), "no cell after the last one");
13473        d.caret = TABLE.find("Name").unwrap();
13474        assert!(!d.cell_hop(false), "no cell before the first one");
13475    }
13476
13477    #[test]
13478    fn wysiwyg_vertical_cell_motion_holds_the_column() {
13479        // Down/Up step to the cell above/below in the *same column*, not back to
13480        // the top-left the way a naive row/col motion over the picture would.
13481        let mut d = wysiwyg_doc("tbl_vert", TABLE);
13482        d.caret = TABLE.find("Qty").unwrap();
13483        // Each vertical hop selects the destination cell, holding the column.
13484        assert!(d.cell_move_vertical(true));
13485        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
13486        assert!(d.cell_move_vertical(true));
13487        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
13488        assert!(!d.cell_move_vertical(true), "no row below the last");
13489        assert!(d.cell_move_vertical(false));
13490        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
13491        assert!(d.cell_move_vertical(false));
13492        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
13493        assert!(!d.cell_move_vertical(false), "no row above the header");
13494    }
13495
13496    #[test]
13497    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
13498        let mut d = wysiwyg_doc("tbl_grow", TABLE);
13499        d.caret = TABLE.rfind("12").unwrap();
13500        let rows_before = d.source.matches('\n').count();
13501        assert!(d.cell_tab(true), "acts as a table key");
13502        assert_eq!(
13503            d.source.matches('\n').count(),
13504            rows_before + 1,
13505            "a fresh row was appended"
13506        );
13507        assert!(d.caret_in_table(), "the caret entered the new row");
13508        // The caret sits in the new row's first cell — past the old last cell.
13509        assert!(d.caret > TABLE.rfind("12").unwrap());
13510    }
13511
13512    #[test]
13513    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
13514        let mut d = wysiwyg_doc("tbl_ret", TABLE);
13515        d.caret = TABLE.find("Name").unwrap();
13516        assert!(d.cell_return(), "acts as a table key");
13517        assert_eq!(
13518            d.selected_text(),
13519            Some("Pear"),
13520            "Return drops one cell, selecting it"
13521        );
13522        // From the last row, Return appends a row and enters it.
13523        d.caret = TABLE.rfind("Fig").unwrap();
13524        let rows_before = d.source.matches('\n').count();
13525        assert!(d.cell_return());
13526        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
13527        assert!(d.caret_in_table());
13528    }
13529
13530    #[test]
13531    fn return_and_tab_outside_a_table_decline() {
13532        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
13533        d.caret = 4;
13534        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
13535        assert!(!d.cell_tab(true), "no table: the frontend indents");
13536        assert!(
13537            !d.cell_line_break(),
13538            "no table: the frontend breaks the line"
13539        );
13540    }
13541
13542    #[test]
13543    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
13544        // A document that ends in a table used to end *inside* it: nothing
13545        // past the last cell was a caret stop, so a click in the blank space
13546        // under the grid snapped back into the table and there was no way to
13547        // write a line after it. The bottom border's end is that stop now.
13548        let mut d = wysiwyg_doc("tbl_trail", TABLE);
13549        let rows = d.vmap.num_rows();
13550        d.click(rows + 3, 0, false);
13551        let end = TABLE.trim_end_matches('\n').len();
13552        assert_eq!(d.caret, end, "the caret stands just past the table");
13553        assert!(!d.caret_in_table(), "past the table is outside it");
13554        assert!(!d.cell_return(), "Return there is the frontend's newline");
13555        d.newline();
13556        d.insert("after");
13557        assert_eq!(
13558            d.source,
13559            format!("{TABLE}\nafter\n"),
13560            "Enter opens a paragraph under the table"
13561        );
13562    }
13563
13564    #[test]
13565    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
13566        // The stop sits at the end of the table's last source line, and a
13567        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
13568        // three-cell row. So the text gets a paragraph of its own, as it does
13569        // beside a block picture.
13570        let mut d = wysiwyg_doc("tbl_type", TABLE);
13571        d.caret = TABLE.trim_end_matches('\n').len();
13572        d.insert("x");
13573        assert_eq!(d.source, format!("{TABLE}\nx\n"));
13574        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
13575        // And a paste, which joins the block exactly as typing would.
13576        let mut d = wysiwyg_doc("tbl_paste", TABLE);
13577        d.caret = TABLE.trim_end_matches('\n').len();
13578        d.paste("pasted");
13579        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
13580    }
13581
13582    #[test]
13583    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
13584        let mut d = wysiwyg_doc("tbl_edge", TABLE);
13585        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
13586        let end = TABLE.trim_end_matches('\n').len();
13587        d.caret = last_cell_end;
13588        d.move_right(false);
13589        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
13590        // Backspace there takes no byte: the one behind the caret is the row's
13591        // closing `|`, which the rich view never drew. It steps back instead.
13592        d.backspace();
13593        assert_eq!(d.source, TABLE, "nothing deleted");
13594        assert_eq!(d.caret, last_cell_end, "back into the last cell");
13595        // Down from the last row lands on the same stop, and Up returns.
13596        d.move_down(false);
13597        assert_eq!(d.caret, end, "Down from the last row leaves the table");
13598        d.move_up(false);
13599        assert_eq!(d.caret, last_cell_end);
13600    }
13601
13602    #[test]
13603    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
13604        // With prose under the table, the stop is one hop between the last
13605        // cell and the paragraph — the shape a block picture's second stop has.
13606        let src = format!("{TABLE}\nafter\n");
13607        let mut d = wysiwyg_doc("tbl_mid", &src);
13608        d.caret = TABLE.rfind("12").unwrap() + 2;
13609        d.move_right(false);
13610        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
13611        d.move_right(false);
13612        assert_eq!(d.caret, src.find("after").unwrap());
13613        // Typing at the stop still opens a paragraph, and the text below keeps
13614        // its own.
13615        d.move_left(false);
13616        d.insert("x");
13617        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
13618    }
13619
13620    #[test]
13621    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
13622        let mut d = wysiwyg_doc("tbl_break", TABLE);
13623        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
13624        assert!(d.cell_line_break(), "acts as a table key");
13625        assert!(
13626            d.source.contains("Pear<br>"),
13627            "spelled as an inline <br>: {}",
13628            d.source
13629        );
13630        assert!(d.caret_in_table(), "still in the cell, past the break");
13631        // The break renders as a real line: the "Pear" cell now draws two lines,
13632        // so the table's picture is one row taller than a single-line table.
13633        d.build_visual(80);
13634        let table = &d.vmap.tables[0];
13635        let cell = &table.grid[1].cells[0]; // first body row, first column
13636        assert!(
13637            cell.glyphs.iter().any(|g| g.ch == '\n'),
13638            "the cell carries the break as a newline glyph for the frontend to split"
13639        );
13640    }
13641
13642    #[test]
13643    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
13644        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
13645        // back as structure — the whole point of routing through insert_line_break
13646        // instead of splicing raw `<br>` bytes.
13647        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
13648        d.caret = TABLE.find("Pear").unwrap() + 4;
13649        assert!(d.cell_line_break());
13650        let kinds: Vec<Kind> = d
13651            .editor
13652            .nodes()
13653            .unwrap()
13654            .iter()
13655            .map(|n| n.kind.clone())
13656            .collect();
13657        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
13658        assert!(
13659            !kinds.contains(&Kind::RawInline),
13660            "still raw HTML: {kinds:?}"
13661        );
13662    }
13663
13664    #[test]
13665    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
13666        // The `<br>` draws as one newline glyph, so Backspace over it must take
13667        // all four bytes — a one-byte delete would strand a visible `<br` in the
13668        // cell (the reported bug).
13669        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
13670        d.caret = TABLE.find("Pear").unwrap() + 4;
13671        assert!(d.cell_line_break());
13672        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
13673        d.backspace(); // caret sits just past the break
13674        assert!(
13675            !d.source.contains("<br"),
13676            "no half-deleted <br left: {}",
13677            d.source
13678        );
13679        assert!(
13680            d.source.contains("| Pear |"),
13681            "the cell is back to one line: {}",
13682            d.source
13683        );
13684    }
13685
13686    #[test]
13687    fn backspace_at_a_cell_start_is_a_wall() {
13688        // The task's repro: two presses used to take the padding and then the
13689        // `|`, merging `d` into `c`'s cell and leaving the row a column short.
13690        let src = "| a | b |\n| - | - |\n| c | d |\n";
13691        let mut d = wysiwyg_doc("tbl_wall_bs", src);
13692        d.place_caret(src.find("d |").unwrap(), false);
13693        for _ in 0..3 {
13694            d.backspace();
13695        }
13696        assert_eq!(d.source, src);
13697        // Inside the padding too — right after the `|`, before the space. Set
13698        // directly: `place_caret` would snap it onto the stop past the space.
13699        d.caret = src.find("| d").unwrap() + 1;
13700        d.backspace();
13701        assert_eq!(d.source, src);
13702        // The row's first cell, and an empty one, are walls the same.
13703        d.place_caret(src.find("c |").unwrap(), false);
13704        d.backspace();
13705        assert_eq!(d.source, src);
13706        // A cell that opens on hidden markup: the wall is the first letter
13707        // drawn, not the `**` in front of it.
13708        let bold = "| a | b |\n| - | - |\n| c | **d** |\n";
13709        let mut d = wysiwyg_doc("tbl_wall_bs_bold", bold);
13710        d.place_caret(bold.find("d**").unwrap(), false);
13711        d.backspace();
13712        assert_eq!(d.source, bold);
13713        let empty = "| a | b |\n| - | - |\n| c |   |\n";
13714        let mut d = wysiwyg_doc("tbl_wall_bs_empty", empty);
13715        d.place_caret(empty.find("|   |").unwrap() + 2, false);
13716        d.backspace();
13717        assert_eq!(d.source, empty);
13718    }
13719
13720    #[test]
13721    fn backspace_inside_a_cell_still_deletes_a_character() {
13722        let src = "| a | b |\n| - | - |\n| c | de |\n";
13723        let mut d = wysiwyg_doc("tbl_wall_bs_mid", src);
13724        d.place_caret(src.find("e |").unwrap(), false);
13725        d.backspace();
13726        assert_eq!(d.source, "| a | b |\n| - | - |\n| c | e |\n");
13727    }
13728
13729    #[test]
13730    fn delete_at_a_cell_end_is_a_wall() {
13731        // The mirror: Delete at `c`'s end would take the padding, then the `|`.
13732        let src = "| a | b |\n| - | - |\n| c | d |\n";
13733        let mut d = wysiwyg_doc("tbl_wall_del", src);
13734        d.place_caret(src.find("c |").unwrap() + 1, false);
13735        for _ in 0..3 {
13736            d.delete_forward();
13737        }
13738        assert_eq!(d.source, src);
13739        // And inside the trailing padding, set directly past the snap.
13740        d.caret = src.find("c |").unwrap() + 2;
13741        d.delete_forward();
13742        assert_eq!(d.source, src);
13743        // The row's last cell is a wall on its closing `|` as well.
13744        d.place_caret(src.find("d |").unwrap() + 1, false);
13745        d.delete_forward();
13746        assert_eq!(d.source, src);
13747        // Mid-cell Delete is untouched.
13748        d.place_caret(src.find("c |").unwrap(), false);
13749        d.delete_forward();
13750        assert_eq!(d.source, "| a | b |\n| - | - |\n|  | d |\n");
13751    }
13752
13753    #[test]
13754    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
13755        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
13756        d.caret = TABLE.find("Pear").unwrap() + 4;
13757        assert!(d.cell_line_break());
13758        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
13759        d.delete_forward();
13760        assert!(
13761            !d.source.contains("<br"),
13762            "no half-deleted <br: {}",
13763            d.source
13764        );
13765        assert!(
13766            d.source.contains("| Pear |"),
13767            "cell back to one line: {}",
13768            d.source
13769        );
13770    }
13771
13772    #[test]
13773    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
13774        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
13775        // still consumed (a real newline would split the one-line row), but the
13776        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
13777        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
13778        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13779        d.caret = src.find("Pear").unwrap() + 4;
13780        assert!(d.caret_in_table(), "caret should be inside the djot table");
13781        assert!(
13782            d.cell_line_break(),
13783            "the key is consumed, not passed to the frontend"
13784        );
13785        assert_eq!(d.source, src, "the djot cell is left untouched");
13786        assert!(
13787            !d.source.contains("<br>"),
13788            "no non-idiomatic <br> spliced into djot"
13789        );
13790        assert!(
13791            d.status.is_some(),
13792            "the refusal is surfaced on the status line"
13793        );
13794    }
13795
13796    #[test]
13797    fn typing_in_a_cell_edits_that_cell() {
13798        // Editing comes free once offsets map correctly: the caret is a source
13799        // offset, so a normal splice lands inside the pipe table.
13800        let mut d = wysiwyg_doc("tbl_type", TABLE);
13801        d.caret = TABLE.find("Pear").unwrap() + 4;
13802        d.insert("s");
13803        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
13804    }
13805
13806    #[test]
13807    fn motion_and_delete_treat_an_emoji_as_one_character() {
13808        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
13809        // bytes, several codepoints. Right-arrow must clear it in one step, and
13810        // backspace must remove the whole cluster, not a stray joiner.
13811        let family = "👨‍👩‍👧";
13812        let mut d = doc_with("emoji", &format!("a{family}b\n"));
13813        d.caret = 1; // just after 'a', before the emoji
13814        d.move_right(false);
13815        assert_eq!(
13816            d.caret,
13817            1 + family.len(),
13818            "one step clears the whole cluster"
13819        );
13820        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
13821
13822        d.backspace(); // delete the emoji as a unit
13823        assert_eq!(d.source, "ab\n");
13824        assert_eq!(d.caret, 1);
13825    }
13826
13827    #[test]
13828    fn motion_handles_a_combining_accent_as_one_character() {
13829        // "e" + U+0301 (combining acute) renders as one é.
13830        let mut d = doc_with("combining", "e\u{0301}x\n");
13831        d.caret = 0;
13832        d.move_right(false);
13833        assert_eq!(
13834            d.caret,
13835            "e\u{0301}".len(),
13836            "steps past base + combining mark"
13837        );
13838    }
13839
13840    #[test]
13841    fn undo_then_redo_round_trips_an_edit() {
13842        let mut d = doc_with("undo", "hello\n");
13843        d.caret = 5;
13844        d.insert("!");
13845        assert_eq!(d.source, "hello!\n");
13846        d.undo();
13847        assert_eq!(d.source, "hello\n");
13848        assert_eq!(d.caret, 5, "undo restores the caret");
13849        d.redo();
13850        assert_eq!(d.source, "hello!\n");
13851    }
13852
13853    #[test]
13854    fn a_run_of_typing_undoes_as_one_step() {
13855        let mut d = doc_with("coalesce", "\n");
13856        d.caret = 0;
13857        d.insert("a");
13858        d.insert("b");
13859        d.insert("c");
13860        assert_eq!(d.source, "abc\n");
13861        d.undo(); // the whole typed run, not just "c"
13862        assert_eq!(d.source, "\n");
13863        d.undo(); // nothing left — the run was one step
13864        assert_eq!(d.source, "\n");
13865        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
13866    }
13867
13868    // ── IME composition ──────────────────────────────────────────────────────
13869
13870    #[test]
13871    fn a_composition_run_undoes_as_one_step() {
13872        let mut d = doc_with("compose", "\n");
13873        d.caret = 0;
13874        // What an IME does: each step replaces the last one's provisional bytes.
13875        d.edit_composing(0, 0, "k");
13876        d.edit_composing(0, 1, "か");
13877        d.edit_composing(0, 3, "かん");
13878        d.edit_composing(0, 6, "感"); // the commit
13879        d.end_composition();
13880        assert_eq!(d.source, "感\n");
13881        d.undo(); // the whole composition, not its last keystroke
13882        assert_eq!(d.source, "\n");
13883        assert_eq!(d.status.as_deref(), None, "the run was a single step");
13884    }
13885
13886    /// A Replace All the way a host does one: every match, last to first so
13887    /// the offsets still to go stay put, each a selection replaced by an
13888    /// insert, inside one undo group. `select_range` rather than the host's
13889    /// snapping `place_caret`, which would snap against a map these tests do
13890    /// not rebuild between edits.
13891    fn replace_all_in(d: &mut Doc, needle: &str, with: &str) {
13892        let hits: Vec<usize> = d.source.match_indices(needle).map(|(i, _)| i).collect();
13893        d.begin_undo_group();
13894        for at in hits.into_iter().rev() {
13895            d.select_range(at, at + needle.len());
13896            d.insert(with);
13897        }
13898        d.end_undo_group();
13899    }
13900
13901    #[test]
13902    fn a_replace_all_undoes_and_redoes_as_one_step() {
13903        let mut d = wysiwyg_doc("group", "a cat, a **cat**, a cat\n");
13904        d.caret = 0;
13905        d.insert("x");
13906        replace_all_in(&mut d, "cat", "dog");
13907        assert_eq!(d.source, "xa dog, a **dog**, a dog\n");
13908        assert!(d.undo(), "one undo");
13909        assert_eq!(
13910            d.source, "xa cat, a **cat**, a cat\n",
13911            "puts back every match"
13912        );
13913        assert!(d.redo(), "one redo");
13914        assert_eq!(
13915            d.source, "xa dog, a **dog**, a dog\n",
13916            "replaces them all again"
13917        );
13918        assert!(d.undo());
13919        assert!(d.undo(), "the typing before is its own step");
13920        assert_eq!(d.source, "a cat, a **cat**, a cat\n");
13921        assert!(!d.can_undo());
13922    }
13923
13924    #[test]
13925    fn typing_after_a_group_is_a_step_of_its_own() {
13926        // A one-character replacement is an insert like a keystroke, and a
13927        // keystroke after it would coalesce with it outside a group.
13928        let mut d = wysiwyg_doc("group", "a b a\n");
13929        replace_all_in(&mut d, "a", "c");
13930        d.caret = d.source.len() - 1;
13931        d.insert("d");
13932        assert_eq!(d.source, "c b cd\n");
13933        assert!(d.undo());
13934        assert_eq!(d.source, "c b c\n", "the typing alone");
13935        assert!(d.undo());
13936        assert_eq!(d.source, "a b a\n", "then the replacement, whole");
13937    }
13938
13939    #[test]
13940    fn a_group_of_more_edits_than_the_history_holds_is_still_one_step() {
13941        // twig keeps 200 steps; a group folds as it goes, so it never holds
13942        // more than one of them.
13943        let src = format!("start\n{}\n", "x ".repeat(300));
13944        let mut d = wysiwyg_doc("group", &src);
13945        d.caret = 5;
13946        d.insert("!");
13947        replace_all_in(&mut d, "x", "yy");
13948        assert_eq!(d.undo_steps, 2);
13949        assert!(d.undo());
13950        assert_eq!(d.source, src.replacen("start", "start!", 1));
13951        assert!(d.undo());
13952        assert_eq!(d.source, src);
13953        assert!(!d.can_undo());
13954    }
13955
13956    #[test]
13957    fn an_undo_closes_an_open_group() {
13958        let mut d = wysiwyg_doc("group", "one\n");
13959        d.caret = 3;
13960        d.begin_undo_group();
13961        d.begin_undo_group();
13962        d.insert("x");
13963        assert!(d.in_undo_group());
13964        assert!(d.undo());
13965        assert!(
13966            !d.in_undo_group(),
13967            "the history moved, so the group is over"
13968        );
13969        d.end_undo_group();
13970        d.end_undo_group();
13971        assert_eq!(d.source, "one\n");
13972        assert!(d.redo());
13973        assert_eq!(d.source, "onex\n");
13974    }
13975
13976    #[test]
13977    fn a_substitution_behind_the_caret_leaves_the_caret_and_is_its_own_step() {
13978        let mut d = wysiwyg_doc("substitute", "\n");
13979        d.caret = 0;
13980        for c in ["I", " ", "s", "a", "w", " ", "t", "e", "h", " "] {
13981            d.insert(c);
13982        }
13983        assert_eq!(d.source, "I saw teh \n");
13984        let at = d.source.find("teh").unwrap();
13985        assert!(d.substitute(at, at + 3, "the"));
13986        assert_eq!(d.source, "I saw the \n");
13987        assert_eq!(d.caret, 10, "still after the space");
13988        assert!(d.selection().is_none());
13989        d.insert("c");
13990        assert_eq!(d.source, "I saw the c\n");
13991        assert!(d.undo(), "the typing after it is a step of its own");
13992        assert_eq!(d.source, "I saw the \n");
13993        assert!(d.undo(), "the correction is one step");
13994        assert_eq!(d.source, "I saw teh \n", "what was typed");
13995        assert_eq!(d.caret, 10, "with the caret where it stood");
13996        assert!(d.redo());
13997        assert_eq!(d.source, "I saw the \n");
13998        assert!(d.undo());
13999        assert!(d.undo(), "and the typing before it is its own");
14000        assert_eq!(d.source, "\n");
14001        assert!(!d.can_undo());
14002    }
14003
14004    #[test]
14005    fn a_substitution_keeps_the_markup_it_stands_beside() {
14006        // A quote typed just past a bold run: only its own byte changes.
14007        let mut d = wysiwyg_doc("substitute", "A **bold** word\n");
14008        let at = d.source.find(" word").unwrap();
14009        d.caret = at;
14010        d.insert("\"");
14011        assert_eq!(d.source, "A **bold**\" word\n");
14012        assert!(d.substitute(at, at + 1, "\u{201d}"));
14013        assert_eq!(d.source, "A **bold**\u{201d} word\n");
14014        assert_eq!(d.caret, at + "\u{201d}".len());
14015        assert!(
14016            d.marks_at(d.source.find("bold").unwrap())
14017                .iter()
14018                .any(|(k, _)| *k == InlineKind::Strong)
14019        );
14020        // A range that is not one is refused, and changes nothing.
14021        let src = d.source.clone();
14022        assert!(!d.substitute(3, 2, "x"));
14023        assert!(!d.substitute(0, src.len() + 1, "x"));
14024        assert!(!d.substitute(d.source.find('\u{201d}').unwrap() + 1, d.source.len(), "x"));
14025        assert_eq!(d.source, src);
14026    }
14027
14028    #[test]
14029    fn a_substitution_that_half_lands_takes_back_only_its_own_deletion() {
14030        // The deletion lands and twig refuses the literal text: the deleted
14031        // bytes go back, and nothing else moves — not an earlier substitution
14032        // of the same keystroke, not the group it is in, and no redo is left
14033        // to delete them again.
14034        let mut d = wysiwyg_doc("substitute", "\n");
14035        d.set_markup_mode(MarkupMode::None);
14036        d.caret = 0;
14037        for c in ["a", "\"", "b", " ", "\""] {
14038            d.insert(c);
14039        }
14040        assert_eq!(d.source, "a\"b \"\n");
14041
14042        d.refuse_next_literal = true;
14043        assert!(!d.substitute(1, 2, "\u{201c}"));
14044        assert_eq!(d.source, "a\"b \"\n");
14045        assert_eq!(d.caret, 5, "the caret where it stood");
14046        assert!(!d.can_redo(), "no redo step left behind");
14047
14048        d.begin_undo_group();
14049        assert!(d.substitute(4, 5, "\u{201d}"));
14050        d.refuse_next_literal = true;
14051        assert!(!d.substitute(1, 2, "\u{201c}"));
14052        assert!(d.in_undo_group(), "the group is still open");
14053        assert_eq!(d.source, "a\"b \u{201d}\n", "the first substitution stands");
14054        d.end_undo_group();
14055        assert!(!d.can_redo());
14056        assert!(d.undo());
14057        assert_eq!(d.source, "a\"b \"\n", "one step takes the group back");
14058        assert!(d.undo());
14059        assert_eq!(d.source, "\n", "and the typing is still one step");
14060        assert!(!d.can_undo());
14061
14062        // A group whose first edit is the one that half lands has no step.
14063        let mut d = wysiwyg_doc("substitute", "\n");
14064        d.set_markup_mode(MarkupMode::None);
14065        d.caret = 0;
14066        d.insert("\"");
14067        d.begin_undo_group();
14068        d.refuse_next_literal = true;
14069        assert!(!d.substitute(0, 1, "\u{201c}"));
14070        assert!(d.substitute(0, 1, "\u{201c}"));
14071        d.end_undo_group();
14072        assert!(d.undo());
14073        assert_eq!(
14074            d.source, "\"\n",
14075            "the substitution that landed is the group's step"
14076        );
14077        assert!(d.undo());
14078        assert_eq!(d.source, "\n");
14079    }
14080
14081    #[test]
14082    fn a_substitution_is_written_the_way_typing_writes() {
14083        // Where typing is literal, so is a replacement's text.
14084        let mut d = wysiwyg_doc("substitute", "say hi\n");
14085        d.set_markup_mode(MarkupMode::None);
14086        assert!(d.substitute(4, 6, "*hi*"));
14087        assert_eq!(d.source, "say \\*hi\\*\n");
14088        assert!(d.undo());
14089        assert_eq!(d.source, "say hi\n", "one step, escapes and all");
14090    }
14091
14092    #[test]
14093    fn can_undo_is_false_once_a_coalesced_run_is_undone() {
14094        // The task's repro: five characters typed are one twig step, and the
14095        // counter used to say five.
14096        let mut d = wysiwyg_doc("undo_truth", "\n");
14097        d.caret = 0;
14098        for c in ["h", "e", "l", "l", "o"] {
14099            d.insert(c);
14100        }
14101        assert!(d.can_undo());
14102        assert!(d.undo(), "the run undoes");
14103        assert_eq!(d.source, "\n");
14104        assert!(!d.can_undo(), "and nothing is left behind it");
14105        let rev = d.revision();
14106        assert!(!d.undo(), "an undo that does not move says so");
14107        assert_eq!(d.revision(), rev);
14108        assert!(d.can_redo());
14109        assert!(d.redo());
14110        assert_eq!(d.source, "hello\n");
14111        assert!(!d.can_redo());
14112        assert!(!d.redo());
14113    }
14114
14115    #[test]
14116    fn can_undo_is_exact_across_the_gestures_that_coalesce() {
14117        // Each gesture below folds, or may fold, one twig step into another.
14118        // After it, the mirror must name exactly the number of undos twig
14119        // takes — and the same number of redos back.
14120        type Gesture = fn(&mut Doc);
14121        let cases: &[(&str, &str, Gesture)] = &[
14122            ("typing", "\n", |d| {
14123                d.caret = 0;
14124                d.insert("ab");
14125                d.insert("c");
14126                d.move_left(false);
14127                d.insert("x");
14128            }),
14129            ("backspaces", "abcdef\n", |d| {
14130                d.caret = 6;
14131                d.backspace();
14132                d.backspace();
14133                d.insert("z");
14134                d.backspace();
14135            }),
14136            ("ordered list item", "1. one\n2. two\n", |d| {
14137                d.caret = 6;
14138                d.newline();
14139                d.insert("x");
14140            }),
14141            ("list indent", "- one\n- two\n", |d| {
14142                d.caret = d.source.find("two").unwrap();
14143                d.indent();
14144                d.outdent();
14145            }),
14146            ("bold then type", "one two\n", |d| {
14147                d.select_range(0, 3);
14148                d.toggle(InlineKind::Strong);
14149                d.caret = d.source.len() - 1;
14150                d.insert("!");
14151            }),
14152            ("highlight", "one two\n", |d| {
14153                d.select_range(0, 3);
14154                d.highlight(None);
14155                d.highlight(Some(MarkColor::Green));
14156            }),
14157            ("heading and quote", "one\n", |d| {
14158                d.caret = 1;
14159                d.toggle_heading(2);
14160                d.toggle_blockquote();
14161            }),
14162            ("footnote", "one\n", |d| {
14163                d.caret = 3;
14164                d.insert_footnote();
14165                d.insert("note");
14166            }),
14167            ("blocks", "one\n\ntwo\n\nthree\n", |d| {
14168                d.caret = 1;
14169                d.move_block_down();
14170                d.toggle_list(true);
14171                d.set_alignment(Some(Align::Center));
14172                d.insert_page_break();
14173                d.insert_table(2, 2);
14174            }),
14175            ("paste and compose", "\n", |d| {
14176                d.caret = 0;
14177                d.paste("one two");
14178                d.delete_word_back();
14179                d.edit_composing(d.caret, d.caret, "か");
14180                d.edit_composing(d.caret - 3, d.caret, "蚊");
14181                d.end_composition();
14182                d.insert("z");
14183            }),
14184            ("table", "| a | b |\n| - | - |\n| c | d |\n", |d| {
14185                d.place_caret(d.source.find("d |").unwrap(), false);
14186                d.cell_tab(true);
14187                d.insert("e");
14188                d.cell_return();
14189            }),
14190            ("replace all", "cat cat cat\n", |d| {
14191                d.caret = 0;
14192                d.insert("a ");
14193                replace_all_in(d, "cat", "dog");
14194                d.insert("!");
14195            }),
14196            ("nested group", "one\n\ntwo\n", |d| {
14197                d.begin_undo_group();
14198                d.caret = 3;
14199                d.insert("x");
14200                d.begin_undo_group();
14201                d.toggle_heading(1);
14202                d.end_undo_group();
14203                d.insert("y");
14204                d.end_undo_group();
14205                d.backspace();
14206            }),
14207            ("group around a list", "1. one\n2. two\n", |d| {
14208                d.begin_undo_group();
14209                d.caret = 6;
14210                d.newline();
14211                d.insert("x");
14212                d.end_undo_group();
14213            }),
14214            ("substitution", "\n", |d| {
14215                d.caret = 0;
14216                d.insert("t");
14217                d.insert("e");
14218                d.insert("h");
14219                d.insert(" ");
14220                d.substitute(0, 3, "the");
14221                d.insert("c");
14222            }),
14223            ("empty group", "one\n", |d| {
14224                d.caret = 3;
14225                d.insert("x");
14226                d.begin_undo_group();
14227                d.end_undo_group();
14228                d.insert("y");
14229            }),
14230            ("undo inside a group", "one\n", |d| {
14231                d.caret = 3;
14232                d.begin_undo_group();
14233                d.insert("x");
14234                d.undo();
14235                d.insert("y");
14236                d.insert("z");
14237                d.end_undo_group();
14238            }),
14239        ];
14240        for (name, src, gesture) in cases {
14241            let mut d = wysiwyg_doc("undo_exact", src);
14242            gesture(&mut d);
14243            let (steps, after) = (d.undo_steps, d.source.clone());
14244            let mut undone = 0;
14245            while d.can_undo() {
14246                assert!(d.undo(), "{name}: can_undo said yes, undo moved nothing");
14247                undone += 1;
14248            }
14249            assert!(!d.undo(), "{name}: can_undo said no, undo moved");
14250            assert_eq!(undone, steps, "{name}");
14251            assert_eq!(d.source, *src, "{name}: back to the start");
14252            let mut redone = 0;
14253            while d.can_redo() {
14254                assert!(d.redo(), "{name}: can_redo said yes, redo moved nothing");
14255                redone += 1;
14256            }
14257            assert!(!d.redo(), "{name}: can_redo said no, redo moved");
14258            assert_eq!(redone, steps, "{name}");
14259            assert_eq!(d.source, after, "{name}: forward to the end");
14260        }
14261    }
14262
14263    #[test]
14264    fn two_compositions_are_two_undo_steps() {
14265        let mut d = doc_with("compose_two", "\n");
14266        d.caret = 0;
14267        d.edit_composing(0, 0, "か");
14268        d.edit_composing(0, 3, "蚊");
14269        d.end_composition();
14270        d.edit_composing(3, 3, "き");
14271        d.edit_composing(3, 6, "木");
14272        d.end_composition();
14273        assert_eq!(d.source, "蚊木\n");
14274        d.undo();
14275        assert_eq!(d.source, "蚊\n", "only the second composition");
14276        d.undo();
14277        assert_eq!(d.source, "\n");
14278    }
14279
14280    #[test]
14281    fn a_composition_does_not_fold_into_the_typing_around_it() {
14282        let mut d = doc_with("compose_typing", "\n");
14283        d.caret = 0;
14284        d.insert("a");
14285        d.insert("b");
14286        d.edit_composing(2, 2, "か");
14287        d.edit_composing(2, 5, "蚊");
14288        d.end_composition();
14289        d.insert("c");
14290        assert_eq!(d.source, "ab蚊c\n");
14291        d.undo();
14292        assert_eq!(d.source, "ab蚊\n");
14293        d.undo();
14294        assert_eq!(d.source, "ab\n");
14295        d.undo();
14296        assert_eq!(d.source, "\n");
14297    }
14298
14299    #[test]
14300    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
14301        let mut d = doc_with("compose_spurious", "\n");
14302        d.caret = 0;
14303        d.insert("a");
14304        d.end_composition(); // an IME unmarking unprompted
14305        d.insert("b");
14306        assert_eq!(d.source, "ab\n");
14307        d.undo();
14308        assert_eq!(d.source, "\n", "still one typed run");
14309    }
14310
14311    // ── the clipboard's rich flavor ──────────────────────────────────────────
14312
14313    #[test]
14314    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
14315        let mut d = doc_with("sel_inline", "a **bold** c\n");
14316        d.anchor = Some(2);
14317        d.caret = 10; // `**bold**`, inside the paragraph
14318        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
14319    }
14320
14321    #[test]
14322    fn a_whole_block_selection_keeps_its_paragraph() {
14323        let mut d = doc_with("sel_block", "a **bold** c\n");
14324        d.anchor = Some(0);
14325        d.caret = 12; // the entire paragraph
14326        assert_eq!(
14327            d.selection_html().as_deref(),
14328            Some("<p>a <strong>bold</strong> c</p>")
14329        );
14330    }
14331
14332    #[test]
14333    fn a_multi_block_selection_keeps_its_structure() {
14334        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
14335        d.select_all();
14336        let html = d.selection_html().expect("renders");
14337        assert!(html.contains("<p>para</p>"), "{html:?}");
14338        assert!(html.contains("<li>one</li>"), "{html:?}");
14339    }
14340
14341    #[test]
14342    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
14343        // The fragment `Head` is a paragraph standalone; the *document* says it
14344        // sits inside one block, so the wrapper is an artifact either way.
14345        let mut d = doc_with("sel_heading", "# Head line\n");
14346        d.anchor = Some(2);
14347        d.caret = 6;
14348        assert_eq!(d.selection_html().as_deref(), Some("Head"));
14349    }
14350
14351    #[test]
14352    fn no_selection_publishes_no_html() {
14353        let mut d = doc_with("sel_none", "a b\n");
14354        d.caret = 1;
14355        assert_eq!(d.selection_html(), None);
14356    }
14357
14358    #[test]
14359    fn pasting_html_converts_it_and_is_one_undo_step() {
14360        let mut d = doc_with("paste_html", "x\n");
14361        d.caret = 1;
14362        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
14363        assert_eq!(d.source, "xa **b** c\n");
14364        d.undo();
14365        assert_eq!(d.source, "x\n", "the whole paste, in one step");
14366    }
14367
14368    #[test]
14369    fn pasting_html_replaces_the_selection() {
14370        let mut d = doc_with("paste_html_sel", "keep drop\n");
14371        d.anchor = Some(5);
14372        d.caret = 9;
14373        assert!(d.paste_html("<em>new</em>"));
14374        assert_eq!(d.source, "keep *new*\n");
14375    }
14376
14377    #[test]
14378    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
14379        let mut d = doc_with("paste_html_bad", "x\n");
14380        d.caret = 1;
14381        // twig builds no table from HTML; raw `<table>` in prose is worse than
14382        // the plain flavor the caller still holds.
14383        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
14384        assert_eq!(d.source, "x\n", "declined edits nothing");
14385    }
14386
14387    #[test]
14388    fn copy_then_paste_round_trips_through_the_html_flavor() {
14389        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
14390        d.select_all();
14391        let html = d.selection_html().expect("renders");
14392        let mut into = doc_with("clip_round_dst", "\n");
14393        into.caret = 0;
14394        assert!(into.paste_html(&html));
14395        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
14396    }
14397
14398    #[test]
14399    fn moving_the_caret_starts_a_new_undo_group() {
14400        let mut d = doc_with("break", "\n");
14401        d.caret = 0;
14402        d.insert("a");
14403        d.insert("b"); // "ab\n", caret at 2
14404        d.move_left(false); // breaks the run
14405        d.insert("X"); // "aXb\n"
14406        assert_eq!(d.source, "aXb\n");
14407        d.undo();
14408        assert_eq!(
14409            d.source, "ab\n",
14410            "first undo removes only the post-move insert"
14411        );
14412        d.undo();
14413        assert_eq!(d.source, "\n", "second undo removes the earlier run");
14414    }
14415
14416    #[test]
14417    fn undo_reverses_a_format_toggle() {
14418        let mut d = doc_with("fmt_undo", "a word b\n");
14419        d.anchor = Some(2);
14420        d.caret = 6;
14421        d.toggle(InlineKind::Strong);
14422        assert_eq!(d.source, "a **word** b\n");
14423        d.undo();
14424        assert_eq!(d.source, "a word b\n");
14425    }
14426
14427    #[test]
14428    fn undo_back_to_the_saved_state_clears_dirty() {
14429        let mut d = doc_with("dirty_undo", "hello\n");
14430        assert!(!d.dirty);
14431        d.caret = 5;
14432        d.insert("!");
14433        assert!(d.dirty);
14434        d.undo();
14435        assert!(
14436            !d.dirty,
14437            "undoing to the saved source is not a modification"
14438        );
14439    }
14440
14441    #[test]
14442    fn a_new_edit_invalidates_redo() {
14443        let mut d = doc_with("redo_inv", "\n");
14444        d.caret = 0;
14445        d.insert("a");
14446        d.undo();
14447        d.insert("b"); // diverges — the redo of "a" is now gone
14448        d.redo();
14449        assert_eq!(d.source, "b\n");
14450    }
14451
14452    #[test]
14453    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
14454        let mut d = doc_with("can_undo", "hello\n");
14455        assert!(
14456            !d.can_undo() && !d.can_redo(),
14457            "a fresh document has no history"
14458        );
14459        d.caret = 5;
14460        d.insert("!");
14461        assert!(
14462            d.can_undo() && !d.can_redo(),
14463            "an edit is a step to take back"
14464        );
14465        d.undo();
14466        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
14467        d.redo();
14468        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
14469        d.undo();
14470        d.insert("?");
14471        assert!(
14472            d.can_undo() && !d.can_redo(),
14473            "a fresh edit ends the redo chain"
14474        );
14475        // A coalesced run over-counts steps — the bound is what a menu needs,
14476        // and it reconciles the moment twig reports the history empty.
14477        d.insert("a");
14478        d.insert("b");
14479        while d.can_undo() {
14480            d.undo();
14481        }
14482        assert_eq!(d.source, "hello\n");
14483        assert!(!d.can_undo());
14484        // A reading surface has nothing to undo, whatever the history holds.
14485        d.redo();
14486        d.set_read_only(true);
14487        assert!(!d.can_undo() && !d.can_redo());
14488    }
14489
14490    #[test]
14491    fn undo_on_empty_history_is_a_no_op() {
14492        let mut d = doc_with("undo_empty", "hi\n");
14493        d.undo();
14494        assert_eq!(d.source, "hi\n");
14495        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
14496    }
14497
14498    #[test]
14499    fn a_one_character_paste_is_its_own_undo_step() {
14500        for view in [View::Source, View::Wysiwyg] {
14501            let mut d = doc_in(view, "paste_step", "ab\n");
14502            d.caret = 0;
14503            d.insert("x");
14504            d.insert("y"); // a run of typing
14505            d.paste("z"); // one character, but pasted — not part of that run
14506            assert_eq!(d.source, "xyzab\n");
14507            d.undo();
14508            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
14509            assert_eq!(d.caret, 2, "and hands back the caret it found");
14510            d.undo();
14511            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
14512        }
14513    }
14514
14515    #[test]
14516    fn the_same_character_typed_still_joins_the_run() {
14517        // The other half of the pair: `z` is a keystroke here and a paste above,
14518        // and the two undo differently. Nothing about the *string* says which —
14519        // which is why provenance has to come from the door the caller uses.
14520        for view in [View::Source, View::Wysiwyg] {
14521            let mut d = doc_in(view, "typed_run", "ab\n");
14522            d.caret = 0;
14523            d.insert("x");
14524            d.insert("y");
14525            d.insert("z");
14526            d.undo();
14527            assert_eq!(d.source, "ab\n", "one run, one step");
14528        }
14529    }
14530
14531    #[test]
14532    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
14533        for view in [View::Source, View::Wysiwyg] {
14534            let mut d = doc_in(view, "undo_caret", "hello world\n");
14535            d.caret = 11; // standing at the end of "world", away from the edit
14536            d.edit(0, 5, "goodbye");
14537            assert_eq!(d.source, "goodbye world\n");
14538            d.undo();
14539            assert_eq!(d.source, "hello world\n");
14540            // The undone edit ends at offset 5; the user was at 11.
14541            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
14542        }
14543    }
14544
14545    #[test]
14546    fn undo_restores_the_selection_the_edit_replaced() {
14547        for view in [View::Source, View::Wysiwyg] {
14548            let mut d = doc_in(view, "undo_sel", "a word b\n");
14549            d.anchor = Some(2);
14550            d.caret = 6; // "word" selected
14551            d.insert("X");
14552            assert_eq!(d.source, "a X b\n");
14553            d.undo();
14554            assert_eq!(d.source, "a word b\n");
14555            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
14556        }
14557    }
14558
14559    #[test]
14560    fn redo_restores_the_caret_the_edit_left_behind() {
14561        for view in [View::Source, View::Wysiwyg] {
14562            let mut d = doc_in(view, "redo_caret", "hello world\n");
14563            d.caret = 11;
14564            d.edit(0, 5, "goodbye");
14565            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
14566            d.undo();
14567            d.redo();
14568            assert_eq!(d.source, "goodbye world\n");
14569            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
14570        }
14571    }
14572
14573    #[test]
14574    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
14575        for view in [View::Source, View::Wysiwyg] {
14576            let mut d = doc_in(view, "run_caret", "hi\n");
14577            d.caret = 2;
14578            d.insert("a");
14579            d.insert("b");
14580            d.insert("c");
14581            assert_eq!(d.source, "hiabc\n");
14582            d.undo();
14583            assert_eq!(d.source, "hi\n");
14584            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
14585            d.redo();
14586            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
14587        }
14588    }
14589
14590    #[test]
14591    fn undo_restores_the_caret_across_a_format_toggle() {
14592        // A toggle reaches twig without going through `splice`, so it has to
14593        // record its own step — miss it and every stack depth below it is off by
14594        // one, and undo starts handing back another edit's caret.
14595        for view in [View::Source, View::Wysiwyg] {
14596            let mut d = doc_in(view, "fmt_caret", "a word b\n");
14597            d.caret = 8;
14598            d.anchor = Some(2);
14599            d.caret = 6;
14600            d.toggle(InlineKind::Strong);
14601            assert_eq!(d.source, "a **word** b\n");
14602            d.undo();
14603            assert_eq!(d.source, "a word b\n");
14604            assert_eq!(
14605                d.selection(),
14606                Some((2, 6)),
14607                "the toggled selection comes back"
14608            );
14609        }
14610    }
14611
14612    #[test]
14613    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
14614        // The drift that would never announce itself: twig drops its redo stack
14615        // on any fresh edit, so a leaf redo entry that outlives it would restore
14616        // a caret from the timeline that edit abandoned.
14617        for view in [View::Source, View::Wysiwyg] {
14618            let mut d = doc_in(view, "redo_trunc", "hello world\n");
14619            d.caret = 11;
14620            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
14621            d.undo();
14622            assert_eq!(d.caret, 11);
14623            d.caret = 0;
14624            d.insert("X"); // diverges: A's redo is gone from twig
14625            assert_eq!(d.source, "Xhello world\n");
14626
14627            d.redo();
14628            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
14629            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
14630            d.undo();
14631            assert_eq!(d.source, "hello world\n");
14632            assert_eq!(
14633                d.caret, 0,
14634                "the surviving step's caret, not the dropped one"
14635            );
14636        }
14637    }
14638
14639    #[test]
14640    fn indent_and_outdent_move_the_caret_line_with_its_text() {
14641        for view in [View::Source, View::Wysiwyg] {
14642            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
14643            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
14644            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
14645            // Indentation the caret is standing *in* collapses to the line start
14646            // rather than dragging the caret into the text.
14647            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
14648            // A line with none to give back is left exactly as it was.
14649            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
14650            // Less than a full level gives back what it has.
14651            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
14652            // A tab is one level however many spaces it isn't.
14653            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
14654        }
14655    }
14656
14657    #[test]
14658    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
14659        // Why the level is two spaces and not the four both frontends type
14660        // today. Four is markdown's indented-code-block marker, so a Tab on a
14661        // paragraph would silently restyle it as code — a width that changes
14662        // what the document *means* isn't an indent. Pinned because the number
14663        // is the kind of thing a later list-aware pass would reach for.
14664        let mut d = doc_with("indent_kind", "hello\n");
14665        d.caret = 2;
14666        d.indent();
14667        assert_eq!(d.source, "  hello\n");
14668        assert!(
14669            d.nodes().iter().any(|n| n.kind == Kind::Para),
14670            "still prose after a Tab"
14671        );
14672        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
14673
14674        // The four-space level this replaces, for contrast: same text, and twig
14675        // reparses the paragraph into a code block.
14676        let mut wide = doc_with("indent_kind_4", "    hello\n");
14677        wide.build_visual(80);
14678        assert!(
14679            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
14680            "four spaces is a code block, not an indented paragraph"
14681        );
14682    }
14683
14684    #[test]
14685    fn indent_nests_a_list_item_under_its_parent() {
14686        // Tab indents a list item by its own marker width, landing its marker at
14687        // the parent's content column so twig reparses it as a nested list.
14688        for view in [View::Source, View::Wysiwyg] {
14689            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
14690            d.caret = 6; // on the second item
14691            d.indent();
14692            assert_eq!(d.source, "- a\n  - b\n");
14693            let lists = d
14694                .nodes()
14695                .iter()
14696                .filter(|n| n.kind == Kind::BulletList)
14697                .count();
14698            assert_eq!(lists, 2, "the indented item is a nested list");
14699        }
14700    }
14701
14702    #[test]
14703    fn indent_nests_an_ordered_item_at_its_marker_width() {
14704        // An ordered marker `1. ` is three columns wide, so a two-space step
14705        // (which nests a bullet) leaves it flat. Regression: Tab must use the
14706        // marker width, three, so the item actually nests — and the source
14707        // renumbers so the sub-list restarts at 1 and the outer list resumes.
14708        for view in [View::Source, View::Wysiwyg] {
14709            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
14710            d.caret = d.source.find('b').unwrap();
14711            d.indent();
14712            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
14713            let lists = d
14714                .nodes()
14715                .iter()
14716                .filter(|n| n.kind == Kind::OrderedList)
14717                .count();
14718            assert_eq!(lists, 2, "the indented item is a nested ordered list");
14719        }
14720    }
14721
14722    #[test]
14723    fn indent_leaves_a_lists_first_item_put() {
14724        // The first item of a list has no sibling above it to nest under, so Tab
14725        // is a no-op there — the marker stays at column zero rather than being
14726        // shoved into indentation twig can't read as a sub-list.
14727        for view in [View::Source, View::Wysiwyg] {
14728            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
14729            d.caret = 1; // on the FIRST item
14730            d.indent();
14731            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
14732            // The sibling below still nests, proving the guard is per-item.
14733            d.caret = d.source.find('b').unwrap();
14734            d.indent();
14735            assert_eq!(d.source, "- a\n  - b\n");
14736        }
14737    }
14738
14739    #[test]
14740    fn hidden_mode_keeps_typed_markup_literal() {
14741        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
14742        // twig escapes what would open markup, so the source is `\*hi\*` and the
14743        // AST is a plain string. Formatting is the commands' job in this mode.
14744        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
14745        d.insert("*hi*");
14746        assert_eq!(d.source, "\\*hi\\*");
14747        assert!(
14748            d.nodes()
14749                .iter()
14750                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
14751        );
14752    }
14753
14754    #[test]
14755    fn hidden_mode_escapes_a_line_start_block_marker() {
14756        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
14757        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
14758        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
14759        d.insert("# hi");
14760        assert_eq!(d.source, "\\# hi");
14761        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
14762    }
14763
14764    #[test]
14765    fn authoring_modes_keep_typed_markup_live() {
14766        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
14767        // (no escape), the same as source view — escaping is `None`'s alone, and
14768        // it's the axis, not the reveal, that decides.
14769        for (view, mode) in [
14770            (View::Wysiwyg, MarkupMode::Shortcuts),
14771            (View::Wysiwyg, MarkupMode::Full),
14772            (View::Source, MarkupMode::None),
14773        ] {
14774            let mut d = doc_in(view, "live_markup", "");
14775            d.set_markup_mode(mode);
14776            d.insert("*hi*");
14777            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
14778        }
14779    }
14780
14781    #[test]
14782    fn hidden_mode_overwrite_undoes_in_one_step() {
14783        // Typing over a selection escapes the replacement *and* stays a single
14784        // undo — the selection-delete and the literal insert fold together, so
14785        // one undo brings the whole selection back, like a plain overwrite.
14786        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
14787        d.anchor = Some(2);
14788        d.caret = 6; // "word"
14789        d.insert("*");
14790        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
14791        d.undo();
14792        assert_eq!(d.source, "a word b\n");
14793        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
14794    }
14795
14796    #[test]
14797    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
14798        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
14799        // the whole visual character, never stranding the hidden `\`.
14800        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
14801        d.insert("*");
14802        assert_eq!(d.source, "\\*");
14803        d.backspace();
14804        assert_eq!(d.source, "", "the escape backslash went with the *");
14805        // A *literal* backslash (source view, no escape) is an ordinary char.
14806        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
14807        s.caret = 3; // after `b`
14808        s.backspace();
14809        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
14810    }
14811
14812    #[test]
14813    fn hidden_mode_leaves_structural_markup_alone() {
14814        // Enter continues a bullet list by writing a real `- ` marker (an
14815        // `insert_raw`, not the typing path), so Hidden mode's escaping never
14816        // touches it — the list keeps working.
14817        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
14818        d.caret = 6;
14819        d.newline();
14820        d.insert("two");
14821        assert_eq!(d.source, "- item\n- two\n");
14822    }
14823
14824    #[test]
14825    fn markup_mode_defaults_to_none_and_round_trips() {
14826        // Diaryx's default is the clean `None` surface; a markup-fluent
14827        // frontend can climb the ladder, and the choice sticks.
14828        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
14829        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
14830        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
14831            d.set_markup_mode(mode);
14832            assert_eq!(d.markup_mode(), mode);
14833        }
14834    }
14835
14836    #[test]
14837    fn full_mode_reveals_only_the_caret_line() {
14838        // The mode's whole claim: the caret's line shows its raw delimiters and
14839        // every other line stays resolved. Two paragraphs with identical markup
14840        // so the only difference between the rows is where the caret is.
14841        let mut d = doc_in(
14842            View::Wysiwyg,
14843            "reveal_caret_line",
14844            "*one* here\n\n*two* there\n",
14845        );
14846        d.set_markup_mode(MarkupMode::Full);
14847
14848        caret_at(&mut d, "one");
14849        let rows = drawn_rows(&d);
14850        assert!(
14851            rows.iter().any(|r| r == "*one* here"),
14852            "caret's line raw: {rows:?}"
14853        );
14854        assert!(
14855            rows.iter().any(|r| r == "two there"),
14856            "other line resolved: {rows:?}"
14857        );
14858
14859        // Move to the other paragraph: the reveal follows, and the line just
14860        // left goes back to being resolved.
14861        caret_at(&mut d, "two");
14862        let rows = drawn_rows(&d);
14863        assert!(
14864            rows.iter().any(|r| r == "*two* there"),
14865            "caret's line raw: {rows:?}"
14866        );
14867        assert!(
14868            rows.iter().any(|r| r == "one here"),
14869            "left line resolved: {rows:?}"
14870        );
14871    }
14872
14873    #[test]
14874    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
14875        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
14876        // the same treatment as an emphasis's `*`: hidden while the caret is
14877        // elsewhere, shown in full where the caret lands. That falls out of
14878        // `delims` reading the bytes between the mark's span and its content
14879        // span, which is exactly `==🔴 ` and `==`, rather than from a table
14880        // of spellings — so the no-space form `==🟢green==` reveals right too.
14881        let mut d = doc_in(
14882            View::Wysiwyg,
14883            "reveal_coloured_mark",
14884            "a ==🔴 red== one\n\nb ==plain== two\n",
14885        );
14886        d.set_markup_mode(MarkupMode::Full);
14887
14888        caret_at(&mut d, "red");
14889        let rows = drawn_rows(&d);
14890        assert!(
14891            rows.iter().any(|r| r == "a ==🔴 red== one"),
14892            "the caret's line shows the colour it was written with: {rows:?}"
14893        );
14894        assert!(
14895            rows.iter().any(|r| r == "b plain two"),
14896            "and every other line stays resolved: {rows:?}"
14897        );
14898
14899        // Away from it, the emoji goes back to being markup — the reader sees
14900        // the words and the wash.
14901        caret_at(&mut d, "two");
14902        let rows = drawn_rows(&d);
14903        assert!(
14904            rows.iter().any(|r| r == "a red one"),
14905            "resolved again: {rows:?}"
14906        );
14907    }
14908
14909    #[test]
14910    fn hidden_modes_never_reveal_wherever_the_caret_is() {
14911        // The two rungs below `Full` share a rendering: delimiters stay hidden
14912        // even under the caret. `Shortcuts` differing from `None` only in what
14913        // typing does is exactly the point of splitting the axes.
14914        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
14915            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
14916            d.set_markup_mode(mode);
14917            caret_at(&mut d, "one");
14918            let rows = drawn_rows(&d);
14919            assert!(
14920                rows.iter().any(|r| r == "one here"),
14921                "{mode:?} hides: {rows:?}"
14922            );
14923            assert!(
14924                !rows.iter().any(|r| r.contains('*')),
14925                "{mode:?} shows no `*`: {rows:?}"
14926            );
14927        }
14928    }
14929
14930    #[test]
14931    fn revealed_delimiters_are_the_authors_own_spelling() {
14932        // Delimiters are re-read from the source rather than synthesized per
14933        // kind, so a line comes back spelled the way it was written: `_em_` does
14934        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
14935        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
14936        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
14937        d.set_markup_mode(MarkupMode::Full);
14938        caret_at(&mut d, "em");
14939        let rows = drawn_rows(&d);
14940        assert!(
14941            rows.iter().any(|r| r == body.trim_end()),
14942            "the revealed line is its own source: {rows:?}"
14943        );
14944    }
14945
14946    #[test]
14947    fn revealed_heading_shows_its_hashes() {
14948        // The `# ` marker is a block-level prefix, not an inline delimiter, so
14949        // it takes its own path — but it reveals on the same rule.
14950        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
14951        d.set_markup_mode(MarkupMode::Full);
14952
14953        caret_at(&mut d, "Title");
14954        assert!(
14955            drawn_rows(&d).iter().any(|r| r == "# Title"),
14956            "{:?}",
14957            drawn_rows(&d)
14958        );
14959
14960        caret_at(&mut d, "body");
14961        let rows = drawn_rows(&d);
14962        assert!(
14963            rows.iter().any(|r| r == "Title"),
14964            "hashes hidden again: {rows:?}"
14965        );
14966    }
14967
14968    #[test]
14969    fn revealed_delimiters_are_caret_stops() {
14970        // A delimiter that is drawn but can't be reached is worse than one
14971        // that's hidden: the mode exists so the markup can be *edited*. Every
14972        // revealed byte must be somewhere the caret can stand.
14973        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
14974        d.set_markup_mode(MarkupMode::Full);
14975        caret_at(&mut d, "em");
14976        let opener = d.source.find('*').unwrap();
14977        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
14978        assert!(
14979            d.vmap.is_stop(opener + 3),
14980            "the closing `*` is a caret stop"
14981        );
14982    }
14983
14984    #[test]
14985    fn setext_heading_reveals_nothing_across_its_newline() {
14986        // A setext heading's underline is on another line, so it is not the
14987        // caret line's to reveal — and emitting it would inject a `\n` glyph
14988        // that splits the row where the author wrote no break.
14989        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
14990        d.set_markup_mode(MarkupMode::Full);
14991        caret_at(&mut d, "Title");
14992        let rows = drawn_rows(&d);
14993        assert!(
14994            rows.iter().any(|r| r == "Title"),
14995            "title renders alone: {rows:?}"
14996        );
14997        assert!(
14998            !rows.iter().any(|r| r.contains('=')),
14999            "no underline leaks in: {rows:?}"
15000        );
15001    }
15002
15003    #[test]
15004    fn markup_mode_axes_split_the_ladder() {
15005        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
15006        // that authors markup but still hides it, and it's the only rung where
15007        // the two axes disagree.
15008        assert!(!MarkupMode::None.authors());
15009        assert!(!MarkupMode::None.reveals_caret_line());
15010        assert!(MarkupMode::Shortcuts.authors());
15011        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
15012        assert!(MarkupMode::Full.authors());
15013        assert!(MarkupMode::Full.reveals_caret_line());
15014    }
15015
15016    #[test]
15017    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
15018        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
15019        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
15020        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
15021        // nested bullet and `hello` stays prose: the file round-trips instead of
15022        // hiding a heading the user never asked for.
15023        for view in [View::Source, View::Wysiwyg] {
15024            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
15025            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
15026            d.indent();
15027            assert_eq!(d.source, "- hello\n  * \n");
15028            assert!(
15029                d.nodes().iter().all(|n| n.kind != Kind::Heading),
15030                "no heading"
15031            );
15032            // And it's genuinely a nested list, not a flat one.
15033            assert_eq!(
15034                d.nodes()
15035                    .iter()
15036                    .filter(|n| n.kind == Kind::BulletList)
15037                    .count(),
15038                2
15039            );
15040        }
15041    }
15042
15043    #[test]
15044    fn indenting_a_dash_item_with_content_keeps_its_dash() {
15045        // With content, `- x` can't be a setext underline, so there's nothing to
15046        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
15047        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
15048        d.caret = d.source.find('x').unwrap();
15049        d.indent();
15050        assert_eq!(d.source, "- hello\n  - x\n");
15051    }
15052
15053    #[test]
15054    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
15055        // The dash→`*` repair coalesces into the Tab, so a single undo restores
15056        // the whole pre-Tab state rather than stranding a half-collapsed doc.
15057        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
15058        d.caret = d.source.find("- \n").unwrap() + 2;
15059        d.indent();
15060        assert_eq!(d.source, "- hello\n  * \n");
15061        d.undo();
15062        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
15063    }
15064
15065    #[test]
15066    fn indent_leaves_a_nested_lists_first_item_put_too() {
15067        // The guard is about siblings, not depth: the first item of an *inner*
15068        // list (already nested under `a`) still has nothing before it at its own
15069        // level, so Tab can't take it deeper.
15070        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
15071        d.caret = d.source.find('b').unwrap();
15072        d.indent();
15073        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
15074        // But `c` (a sibling of `b`) nests under `b`.
15075        d.caret = d.source.find('c').unwrap();
15076        d.indent();
15077        assert_eq!(d.source, "- a\n  - b\n    - c\n");
15078    }
15079
15080    #[test]
15081    fn backspace_at_a_nested_item_start_outdents_it() {
15082        // Backspace with the caret right after a nested item's marker gives back
15083        // one level of nesting, the mirror of Tab — and renumbers the flattened
15084        // ordered list back to a clean run.
15085        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
15086        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
15087        d.backspace();
15088        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
15089    }
15090
15091    #[test]
15092    fn backspace_at_a_top_level_item_start_strips_the_marker() {
15093        // At the outermost level there's no nesting left to give back, so the same
15094        // keystroke drops the bullet and leaves a plain paragraph.
15095        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
15096        d.caret = d.source.find('b').unwrap(); // right after `- `
15097        d.backspace();
15098        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
15099    }
15100
15101    #[test]
15102    fn backspace_mid_item_still_deletes_a_character() {
15103        // The list behaviour is armed only at the item's content start; anywhere
15104        // else Backspace is the ordinary character delete.
15105        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
15106        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
15107        d.backspace();
15108        assert_eq!(d.source, "- b\n");
15109    }
15110
15111    #[test]
15112    fn backspace_at_a_heading_start_strips_the_marker() {
15113        // The `# ` is markup the rich view hides, so Backspace over it takes the
15114        // whole marker and leaves a paragraph. Deleting a byte of it instead left
15115        // `#Title` — no longer a heading, with the hash now literal text the user
15116        // never typed and has to delete again.
15117        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
15118        d.caret = d.source.find('T').unwrap(); // right after `## `
15119        d.backspace();
15120        assert_eq!(d.source, "Title\n");
15121        assert_eq!(
15122            d.caret, 0,
15123            "the caret stays with the text it was in front of"
15124        );
15125    }
15126
15127    #[test]
15128    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
15129        // Only the heading's own marker goes — the quote (or list) it sits in is
15130        // untouched, exactly as un-heading it should be.
15131        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
15132        d.caret = d.source.find('T').unwrap();
15133        d.backspace();
15134        assert_eq!(d.source, "> Title\n");
15135    }
15136
15137    #[test]
15138    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
15139        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
15140        // behind would surface the same stray hash the marker delete just avoided.
15141        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
15142        d.caret = d.source.find('T').unwrap();
15143        d.backspace();
15144        assert_eq!(d.source, "Title\n");
15145        // And it's one edit: a single undo puts the whole heading back.
15146        d.undo();
15147        assert_eq!(d.source, "# Title #\n");
15148    }
15149
15150    #[test]
15151    fn backspace_mid_heading_still_deletes_a_character() {
15152        // The heading behaviour is armed only at the content's start; anywhere
15153        // else Backspace is the ordinary character delete.
15154        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
15155        d.caret = d.source.find('b').unwrap();
15156        d.backspace();
15157        assert_eq!(d.source, "# b\n");
15158    }
15159
15160    #[test]
15161    fn source_view_backspace_still_edits_the_heading_marker_literally() {
15162        // In source view the `# ` is text on the screen the user is deleting a
15163        // byte of, so it keeps its literal meaning — the same split the list
15164        // ladder and Enter draw between the two views.
15165        let mut d = doc_with("bsp_head_src", "# Title\n");
15166        d.caret = d.source.find('T').unwrap();
15167        d.backspace();
15168        assert_eq!(d.source, "#Title\n");
15169    }
15170
15171    #[test]
15172    fn outdent_unnests_an_ordered_item_in_one_press() {
15173        // Shift+Tab gives back exactly the marker width the indent added, so a
15174        // nested ordered item unnests in a single press, and the flattened list
15175        // renumbers back to a clean 1, 2, 3.
15176        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
15177        d.caret = d.source.find('b').unwrap();
15178        d.outdent();
15179        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
15180        let lists = d
15181            .nodes()
15182            .iter()
15183            .filter(|n| n.kind == Kind::OrderedList)
15184            .count();
15185        assert_eq!(lists, 1, "back to one flat list");
15186    }
15187
15188    #[test]
15189    fn table_insert_row_adds_a_row_below_the_caret() {
15190        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
15191        d.caret = d.source.find('1').unwrap(); // in the body row
15192        d.table_insert_row(true);
15193        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
15194    }
15195
15196    #[test]
15197    fn table_insert_and_delete_column_at_the_caret() {
15198        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
15199        d.caret = d.source.find('a').unwrap(); // column 0
15200        d.table_insert_column(true); // add a column to the right of `a`
15201        assert_eq!(
15202            d.source,
15203            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
15204        );
15205        d.caret = d.source.find('b').unwrap(); // now the third column
15206        d.table_delete_column();
15207        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
15208    }
15209
15210    // ── ragged formats ───────────────────────────────────────────────────────
15211    // No format spells every gesture. HTML writes the inline marks as a tag pair
15212    // and no heading, list, quote or link; Markdown spells five of the eight
15213    // marks — the highlight only because leaf parses with `highlight`, which is
15214    // why the question is asked with the extensions; djot spells all eight and
15215    // no in-cell break. leaf asks twig per
15216    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
15217    // op discover the fact on its own — one of them didn't.
15218
15219    /// An HTML document in the rich view, ready for a gesture.
15220    fn html_doc(body: &str) -> Doc {
15221        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
15222        d.view = View::Wysiwyg;
15223        d.build_visual(80);
15224        d
15225    }
15226
15227    #[test]
15228    fn a_table_gesture_leaves_an_html_table_alone() {
15229        // The regression this guard exists for. twig's table editor consults no
15230        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
15231        // HTML `<table>` as a *pipe table* and reported success: the whole
15232        // element replaced by `| a | b |`, silently, on one press of a toolbar
15233        // button. Every grid op went the same way.
15234        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
15235        // A table of named operations, which is what it looks like.
15236        #[allow(clippy::type_complexity)]
15237        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
15238            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
15239            ("delete row", &|d: &mut Doc| d.table_delete_row()),
15240            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
15241            ("delete column", &|d: &mut Doc| d.table_delete_column()),
15242            ("align", &|d: &mut Doc| {
15243                d.table_set_alignment(Alignment::Right)
15244            }),
15245            ("move row", &|d: &mut Doc| d.table_move_row(true)),
15246            ("move column", &|d: &mut Doc| d.table_move_column(true)),
15247        ];
15248        for (name, op) in ops {
15249            let mut d = html_doc(src);
15250            d.caret = d.source.find('a').unwrap();
15251            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
15252            op(&mut d);
15253            assert_eq!(d.source, src, "{name} rewrote an HTML table");
15254            assert!(
15255                !d.dirty,
15256                "{name} marked the document dirty without editing it"
15257            );
15258            assert!(d.status.is_some(), "{name} refused without saying why");
15259        }
15260    }
15261
15262    #[test]
15263    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
15264        // A task box is a form control in HTML and a footnote has no native
15265        // spelling at all — the two gestures twig 3.5 still spells nothing
15266        // for, now that a quote, a list, a link and an image print through
15267        // its renderer (see the test below).
15268        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
15269        // A table of named operations, which is what it looks like.
15270        #[allow(clippy::type_complexity)]
15271        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
15272            ("task item", &|d: &mut Doc| d.toggle_task_item()),
15273            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
15274            ("footnote", &|d: &mut Doc| d.insert_footnote()),
15275        ];
15276        for (name, op) in ops {
15277            let mut d = html_doc(src);
15278            let at = d.source.find("Hello").unwrap();
15279            d.caret = at;
15280            d.anchor = Some(at + 5); // a selection, for the ops that want one
15281            op(&mut d);
15282            assert_eq!(d.source, src, "{name} edited an HTML document");
15283            assert!(
15284                !d.dirty,
15285                "{name} marked the document dirty without editing it"
15286            );
15287            let status = d.status.as_deref().unwrap_or("");
15288            assert!(
15289                status.contains("html"),
15290                "{name}: the refusal should name the format, got {status:?}"
15291            );
15292        }
15293    }
15294
15295    #[test]
15296    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
15297        // twig 3.5: where HTML has no marker alphabet it prints the fresh
15298        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
15299        // the paragraph as its item, an `<a>` or `<img>` over the selection.
15300        // Until then every one of these was a refusal; now each is a real
15301        // edit, which is what the toolbar's capability flags say too.
15302        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
15303        #[allow(clippy::type_complexity)]
15304        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
15305            (
15306                "quote",
15307                &|d: &mut Doc| d.toggle_blockquote(),
15308                "<blockquote>",
15309            ),
15310            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
15311            (
15312                "ordered list",
15313                &|d: &mut Doc| d.toggle_list(true),
15314                "<ol>\n<li>",
15315            ),
15316            (
15317                "link",
15318                &|d: &mut Doc| d.insert_link("https://example.dev"),
15319                "<a href=\"https://example.dev\">Hello</a>",
15320            ),
15321            (
15322                "image",
15323                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
15324                "<img alt=\"Hello\" src=\"pic.png\">",
15325            ),
15326        ];
15327        for (name, op, expect) in ops {
15328            let mut d = html_doc(src);
15329            let at = d.source.find("Hello").unwrap();
15330            d.caret = at;
15331            d.anchor = Some(at + 5);
15332            op(&mut d);
15333            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
15334            assert!(d.dirty, "{name}: a real edit");
15335            assert_eq!(
15336                d.status, None,
15337                "{name}: a supported gesture reports nothing"
15338            );
15339        }
15340    }
15341
15342    #[test]
15343    fn html_spells_a_heading_as_its_tag_pair() {
15344        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
15345        // along — the one block gesture whose HTML shape it can write. So ⌘2
15346        // in an HTML document is a real edit, and ⌘0 takes it back.
15347        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
15348        let mut d = html_doc(src);
15349        d.caret = d.source.find("Hello").unwrap();
15350        d.toggle_heading(2);
15351        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
15352        assert!(d.dirty);
15353        assert_eq!(d.status, None, "a supported gesture reports nothing");
15354        d.toggle_heading(2);
15355        assert_eq!(d.source, src, "the same level again is back to a paragraph");
15356    }
15357
15358    #[test]
15359    fn html_spells_the_inline_marks_and_the_rule() {
15360        // The other half, and why one per-document flag stopped being enough:
15361        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
15362        // already emits and the parser reads straight back as the same mark —
15363        // and the rule button writes an `<hr>`. Refusing these on the old
15364        // "HTML is parse-only" reading would now be leaf's own limitation.
15365        let mut d = html_doc("<p>Hello world</p>\n");
15366        let at = d.source.find("world").unwrap();
15367        d.caret = at;
15368        d.anchor = Some(at + 5);
15369        d.toggle(InlineKind::Strong);
15370        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
15371        assert!(d.dirty);
15372        assert_eq!(d.status, None, "a supported gesture reports nothing");
15373
15374        // And off again — the toggle reverses, which is the property that makes
15375        // authoring in HTML worth offering rather than a one-way trip.
15376        d.toggle(InlineKind::Strong);
15377        assert_eq!(d.source, "<p>Hello world</p>\n");
15378
15379        let mut d = html_doc("<p>Hello world</p>\n");
15380        d.caret = d.source.find("world").unwrap();
15381        d.insert_thematic_break();
15382        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
15383    }
15384
15385    #[test]
15386    fn a_mark_the_format_cannot_spell_arms_nothing() {
15387        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
15388        // sticky mark for the next text typed. Guarding only the twig call
15389        // leaves that path live, promising a mark the gesture will not write and
15390        // then swallowing the error inside `insert`.
15391        //
15392        // Markdown carries this, on the superscript now rather than on the
15393        // highlight: `^x^` is text there in any configuration, whereas twig
15394        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
15395        // which every leaf document does.
15396        let mut d = doc_with("mark", "Hello world\n");
15397        d.view = View::Wysiwyg;
15398        d.build_visual(80);
15399        d.caret = d.source.find("world").unwrap();
15400        d.toggle(InlineKind::Superscript);
15401        assert!(d.pending_marks.is_empty(), "no mark should be armed");
15402        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
15403        d.insert("X");
15404        assert_eq!(d.source, "Hello Xworld\n");
15405    }
15406
15407    #[test]
15408    fn markdown_authors_a_highlight_and_a_strikethrough() {
15409        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
15410        // write. `==x==` is authorable because leaf's own `parse_extensions`
15411        // turns `highlight` on — twig will only mint bytes this editor's reparse
15412        // reads back — and `~~x~~` because GFM strikethrough is parsed by
15413        // default, so the refusal there was never right for any leaf document.
15414        for (kind, marked) in [
15415            (InlineKind::Mark, "a ==word== b\n"),
15416            (InlineKind::Delete, "a ~~word~~ b\n"),
15417        ] {
15418            let mut d = doc_with("author_mark", "a word b\n");
15419            d.anchor = Some(2);
15420            d.caret = 6;
15421            d.toggle(kind);
15422            assert_eq!(d.source, marked, "{kind:?}");
15423            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
15424            assert!(d.dirty, "{kind:?}");
15425            // The region stays selected, so the second press reverses it — the
15426            // property that separates authoring from a one-way trip.
15427            d.toggle(kind);
15428            assert_eq!(d.source, "a word b\n", "{kind:?}");
15429        }
15430    }
15431
15432    #[test]
15433    fn an_authored_highlight_reads_back_as_a_mark() {
15434        // The round trip the extension gate exists to protect: what the toggle
15435        // writes, the reparse must read back as a `mark` rather than as two
15436        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
15437        // rebuilt map rather than from the source text.
15438        let mut d = doc_with("mark_roundtrip", "a word b\n");
15439        d.view = View::Wysiwyg;
15440        d.build_visual(80);
15441        d.anchor = Some(2);
15442        d.caret = 6;
15443        d.toggle(InlineKind::Mark);
15444        assert_eq!(d.source, "a ==word== b\n");
15445        d.build_visual(80);
15446        let w = d
15447            .vmap
15448            .rows
15449            .iter()
15450            .flat_map(|r| r.glyphs.iter())
15451            .find(|g| g.ch == 'w')
15452            .expect("the highlighted word");
15453        assert_eq!(w.style.role, crate::Role::Mark(None));
15454    }
15455
15456    #[test]
15457    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
15458        // The three states of one gesture, in the order a palette is pressed:
15459        // an uncoloured highlight takes the prefix, a coloured one has it
15460        // replaced, and `None` takes it away with the space that was part of the
15461        // spelling.
15462        let mut d = doc_with("mark_colour", "a ==word== b\n");
15463        d.caret = d.source.find("word").unwrap();
15464        d.set_mark_color(Some(MarkColor::Red));
15465        assert_eq!(d.source, "a ==🔴 word== b\n");
15466        assert_eq!(d.status, None);
15467        assert!(d.dirty);
15468
15469        d.set_mark_color(Some(MarkColor::Blue));
15470        assert_eq!(d.source, "a ==🔵 word== b\n");
15471
15472        d.set_mark_color(None);
15473        assert_eq!(d.source, "a ==word== b\n");
15474    }
15475
15476    #[test]
15477    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
15478        // The prefix is written *before* the word, so an offset in the word has
15479        // to ride its width — a caret that stayed put would be a caret that
15480        // walked backwards through the text it was standing in.
15481        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
15482        let word = d.source.find("word").unwrap();
15483        d.caret = word + 2; // between `wo` and `rd`
15484        d.set_mark_color(Some(MarkColor::Red));
15485        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
15486
15487        // And back the other way when the prefix goes.
15488        d.set_mark_color(None);
15489        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
15490    }
15491
15492    #[test]
15493    fn the_colour_at_the_caret_is_what_the_palette_lights() {
15494        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
15495        d.caret = d.source.find("red").unwrap();
15496        assert!(d.caret_in_mark());
15497        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
15498
15499        d.caret = d.source.find("plain").unwrap();
15500        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
15501        assert_eq!(d.mark_color_at_caret(), None);
15502
15503        d.caret = d.source.find(" and ").unwrap() + 2;
15504        assert!(!d.caret_in_mark());
15505        assert_eq!(d.mark_color_at_caret(), None);
15506    }
15507
15508    #[test]
15509    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
15510        // The gesture colours a highlight that exists; it does not make one.
15511        // Two presses is the price of a coloured highlight from bare text, and
15512        // the reason is undo — one press that spliced twice would take two
15513        // presses to take back.
15514        let mut d = doc_with("mark_colour_none", "a word b\n");
15515        d.caret = d.source.find("word").unwrap();
15516        d.set_mark_color(Some(MarkColor::Red));
15517        assert_eq!(d.source, "a word b\n");
15518        assert!(d.status.is_some(), "it should say why");
15519        assert!(!d.dirty);
15520
15521        // Clearing where there is nothing to clear is the same refusal, not a
15522        // quiet success — the caret is in no highlight either way.
15523        d.status = None;
15524        d.set_mark_color(None);
15525        assert_eq!(d.source, "a word b\n");
15526        assert!(d.status.is_some());
15527    }
15528
15529    #[test]
15530    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
15531        // twig answers this one *successfully* with a `Change` describing some
15532        // earlier edit, so a caller that trusted the change would jump the caret
15533        // to wherever that was. Core answers it before asking.
15534        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
15535        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
15536        d.caret = d.source.find("word").unwrap();
15537        let (source, caret) = (d.source.clone(), d.caret);
15538        d.set_mark_color(None);
15539        assert_eq!(d.source, source);
15540        assert_eq!(
15541            d.caret, caret,
15542            "the caret must not ride a change that isn't one"
15543        );
15544        assert_eq!(d.status, None, "and it is not an error either");
15545    }
15546
15547    #[test]
15548    fn djot_spells_the_highlight_and_not_its_colour() {
15549        // The reason the palette is its own capability rather than the Highlight
15550        // button's: `{=word=}` is a highlight djot writes happily, and there is
15551        // no djot spelling for a colour on it.
15552        assert!(Capabilities::of(Format::Djot).mark);
15553        assert!(!Capabilities::of(Format::Djot).mark_color);
15554        assert!(Capabilities::of(Format::Markdown).mark_color);
15555
15556        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
15557        d.caret = d.source.find("word").unwrap();
15558        assert!(
15559            d.caret_in_mark(),
15560            "the caret is in a highlight all the same"
15561        );
15562        d.set_mark_color(Some(MarkColor::Red));
15563        assert_eq!(d.source, "a {=word=} b\n");
15564        assert!(
15565            d.status.as_deref().unwrap_or("").contains("djot"),
15566            "and the refusal names the document's format: {:?}",
15567            d.status
15568        );
15569    }
15570
15571    #[test]
15572    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
15573        // The round trip that matters for a palette: the bytes twig writes are
15574        // bytes its own reparse reads back as a colour, so the swatch that was
15575        // pressed is the swatch that lights afterwards.
15576        let mut d = doc_with("mark_colour_undo", "a word b\n");
15577        d.anchor = Some(2);
15578        d.caret = 6;
15579        d.toggle(InlineKind::Mark);
15580        d.caret = d.source.find("word").unwrap();
15581        d.set_mark_color(Some(MarkColor::Green));
15582        assert_eq!(d.source, "a ==🟢 word== b\n");
15583        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
15584
15585        // One splice, one step: the colour comes off and the highlight stays.
15586        d.undo();
15587        assert_eq!(d.source, "a ==word== b\n");
15588        d.undo();
15589        assert_eq!(d.source, "a word b\n");
15590    }
15591
15592    #[test]
15593    fn every_colour_leaf_names_is_one_twig_writes() {
15594        // The two enums are one vocabulary, and this is what says so: each of
15595        // leaf's colours writes an emoji twig's reparse reads back as *that*
15596        // colour, so `twig_mark_color`'s table cannot quietly pair red with
15597        // orange.
15598        for color in MarkColor::ALL {
15599            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
15600            d.caret = d.source.find("word").unwrap();
15601            d.set_mark_color(Some(color));
15602            assert_eq!(d.status, None, "{color:?}");
15603            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
15604        }
15605    }
15606
15607    #[test]
15608    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
15609        // The two presses a coloured highlight is made of, in the state the
15610        // first one leaves: `toggle` selects the whole `==word==` and puts the
15611        // caret one past the closing `==`, which is *not* in the mark. Asking at
15612        // the caret alone would refuse to colour the highlight just written —
15613        // the selection's start is what answers.
15614        let mut d = doc_with("mark_colour_fresh", "a word b\n");
15615        d.anchor = Some(2);
15616        d.caret = 6;
15617        d.toggle(InlineKind::Mark);
15618        assert_eq!(d.source, "a ==word== b\n");
15619        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
15620
15621        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
15622        d.set_mark_color(Some(MarkColor::Yellow));
15623        assert_eq!(d.source, "a ==🟡 word== b\n");
15624        assert_eq!(d.status, None);
15625    }
15626
15627    #[test]
15628    fn one_press_highlights_a_selection_and_colours_it() {
15629        // What a toolbar swatch means over a plain selection, and the undo it
15630        // has to have: one press, one step. Two steps would leave an uncoloured
15631        // highlight behind on the way back, which is a state the author never
15632        // asked for and never saw.
15633        let mut d = doc_with("highlight_one", "a word b\n");
15634        d.anchor = Some(2);
15635        d.caret = 6;
15636        d.highlight(Some(MarkColor::Purple));
15637        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
15638        assert_eq!(d.status, None);
15639
15640        d.undo();
15641        assert_eq!(d.source, "a word b\n", "one press, one undo");
15642    }
15643
15644    #[test]
15645    fn one_press_on_an_existing_highlight_only_recolours_it() {
15646        // The other half: inside a highlight there is nothing to make, so the
15647        // compound is the plain gesture and the text is untouched.
15648        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
15649        d.caret = d.source.find("word").unwrap();
15650        d.highlight(Some(MarkColor::Blue));
15651        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
15652        d.undo();
15653        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
15654    }
15655
15656    #[test]
15657    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
15658        // `None` means "no colour", and over bare text that is the Highlight
15659        // button's own job. The fold must not happen here — there is no second
15660        // splice, and folding would take the *previous* edit into this one.
15661        let mut d = doc_with("highlight_none", "a word b and more\n");
15662        d.caret = d.source.find("more").unwrap() + 4; // after "more"
15663        d.insert("!"); // an earlier edit for a wrong fold to swallow
15664        d.anchor = Some(2);
15665        d.caret = 6;
15666        d.highlight(None);
15667        assert_eq!(d.source, "a ==word== b and more!\n");
15668
15669        d.undo();
15670        assert_eq!(
15671            d.source, "a word b and more!\n",
15672            "only the highlight came off"
15673        );
15674        d.undo();
15675        assert_eq!(
15676            d.source, "a word b and more\n",
15677            "and the edit before it survived"
15678        );
15679    }
15680
15681    #[test]
15682    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
15683        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
15684        // a colour cannot be armed with it — so the compound declines rather
15685        // than leaving half a promise.
15686        let mut d = doc_with("highlight_bare", "a word b\n");
15687        d.caret = 4;
15688        d.highlight(Some(MarkColor::Red));
15689        assert_eq!(d.source, "a word b\n");
15690        assert!(d.pending_marks.is_empty(), "and nothing armed");
15691        assert!(d.status.is_some());
15692    }
15693
15694    #[test]
15695    fn a_read_only_document_takes_no_colour() {
15696        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
15697        d.caret = d.source.find("word").unwrap();
15698        d.set_read_only(true);
15699        d.set_mark_color(Some(MarkColor::Red));
15700        assert_eq!(d.source, "a ==word== b\n");
15701    }
15702
15703    #[test]
15704    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
15705        // The other door into `toggle`: no selection, so nothing reaches twig
15706        // until `insert` realises the armed mark. It is armed now — the guard
15707        // above asks `Doc::supports`, which asks with the extensions — and what
15708        // it writes is the same `==…==`.
15709        let mut d = doc_with("sticky_mark", "xy\n");
15710        d.caret = 1;
15711        d.toggle(InlineKind::Mark);
15712        assert!(d.pending_marks.contains(InlineKind::Mark));
15713        d.insert("Z");
15714        assert_eq!(d.source, "x==Z==y\n");
15715    }
15716
15717    #[test]
15718    fn html_documents_still_take_typed_text() {
15719        // The guard covers *markup* gestures and must not touch plain editing:
15720        // twig's splicer is language-neutral, and typing into an HTML document
15721        // is the thing that does work today.
15722        let mut d = html_doc("<p>Hello world</p>\n");
15723        d.caret = d.source.find("world").unwrap();
15724        d.insert("big ");
15725        assert_eq!(d.source, "<p>Hello big world</p>\n");
15726        assert!(d.dirty);
15727        d.backspace();
15728        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
15729        d.undo();
15730        d.undo();
15731        assert_eq!(d.source, "<p>Hello world</p>\n");
15732    }
15733
15734    #[test]
15735    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
15736        // `authorable` only separates "there is a door in" from "there is not",
15737        // and HTML is on the near side of that line — which is exactly why a
15738        // toolbar must not be built from it.
15739        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
15740        assert!(html.authorable());
15741        assert!(
15742            !Doc::from_source("<r>x</r>".into(), Format::Xml)
15743                .unwrap()
15744                .authorable()
15745        );
15746
15747        let caps = html.capabilities();
15748        assert!(caps.bold && caps.italic && caps.code && caps.mark);
15749        assert!(caps.thematic_break && caps.cell_line_break);
15750        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
15751        // quote, a list, a code block's language, a link and an image — each
15752        // printed as a fresh node where HTML has no marker to rewrite. A task
15753        // box is a form control and a footnote has no spelling, so those two
15754        // are what keeps the record ragged.
15755        assert!(caps.heading && caps.blockquote && caps.bullet_list);
15756        assert!(caps.link && caps.image && caps.code_language);
15757        assert!(!caps.task && !caps.footnote);
15758        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
15759        // twig's table editor would happily re-emit as `| a | b |`.
15760        assert!(!caps.table);
15761
15762        // The two lightweight formats spell everything leaf offers — and still
15763        // differ from each other, which is the other half of why one boolean
15764        // can't serve.
15765        for fmt in [Format::Markdown, Format::Djot] {
15766            let caps = Capabilities::of(fmt);
15767            assert!(
15768                caps.heading && caps.blockquote && caps.ordered_list,
15769                "{fmt:?}"
15770            );
15771            assert!(
15772                caps.task && caps.link && caps.image && caps.table,
15773                "{fmt:?}"
15774            );
15775        }
15776        // Both spell the highlight and the strikethrough: djot natively, and
15777        // Markdown because `Capabilities` asks with `parse_extensions` rather
15778        // than with twig's defaults — `==x==` is text under those, and a mark
15779        // under the `highlight` leaf always parses with.
15780        for fmt in [Format::Markdown, Format::Djot] {
15781            let caps = Capabilities::of(fmt);
15782            assert!(caps.mark && caps.strike, "{fmt:?}");
15783        }
15784        // What still separates them, now that the highlight doesn't: djot has
15785        // no in-cell break, and Markdown spells neither of the scripts.
15786        assert!(Capabilities::of(Format::Djot).superscript);
15787        assert!(!Capabilities::of(Format::Markdown).superscript);
15788        assert!(Capabilities::of(Format::Markdown).cell_line_break);
15789        assert!(!Capabilities::of(Format::Djot).cell_line_break);
15790
15791        // A parse-only format answers no to every one of them, so the coarse
15792        // predicate and the record agree there.
15793        let caps = Capabilities::of(Format::Xml);
15794        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
15795    }
15796
15797    #[test]
15798    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
15799        // The guard exists to name the *document's* format rather than twig's
15800        // internals, so the message has to survive being one leaf writes itself.
15801        // Checked against a gesture twig also refuses, since that is the pair
15802        // most at risk of drifting apart — the task box, once the code
15803        // language stopped being one (twig 3.5).
15804        let mut d = html_doc("<p>Hello</p>\n");
15805        d.caret = d.source.find("Hello").unwrap();
15806        d.toggle_task_item();
15807        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
15808        assert!(!d.dirty);
15809    }
15810
15811    #[test]
15812    fn table_set_alignment_respells_the_delimiter() {
15813        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
15814        d.caret = d.source.find('b').unwrap();
15815        d.table_set_alignment(Alignment::Right);
15816        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
15817    }
15818
15819    #[test]
15820    fn each_empty_table_cell_has_its_own_editable_home() {
15821        // Regression: an empty cell has no twig content_span, so both cells of a
15822        // `|  |  |` row collapsed onto the row's start (before the first `│`).
15823        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
15824        // tell the cells apart. Each empty cell must now have a distinct home
15825        // inside it.
15826        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
15827        let (c0, c1) = {
15828            let cells = &d.vmap.tables[0].grid[1].cells;
15829            (cells[0].start, cells[1].start)
15830        };
15831        assert!(
15832            c0 < c1,
15833            "the two empty cells have distinct homes: {c0} < {c1}"
15834        );
15835        d.caret = c0;
15836        d.insert("x");
15837        assert_eq!(
15838            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
15839            "typed inside the cell"
15840        );
15841    }
15842
15843    #[test]
15844    fn arrows_step_into_each_empty_table_cell() {
15845        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
15846        let (c0, c1) = {
15847            let cells = &d.vmap.tables[0].grid[1].cells;
15848            (cells[0].start, cells[1].start)
15849        };
15850        d.caret = d.source.find('b').unwrap(); // in the header's second cell
15851        let mut seen = std::collections::HashSet::new();
15852        for _ in 0..6 {
15853            d.move_right(false);
15854            seen.insert(d.caret);
15855        }
15856        assert!(
15857            seen.contains(&c0),
15858            "right arrow reaches the first empty cell"
15859        );
15860        assert!(
15861            seen.contains(&c1),
15862            "right arrow reaches the second empty cell"
15863        );
15864    }
15865
15866    #[test]
15867    fn table_op_off_a_table_is_a_no_op_with_a_status() {
15868        let mut d = doc_with("tbl_none", "just text\n");
15869        d.caret = 3;
15870        d.table_insert_row(true);
15871        assert_eq!(d.source, "just text\n", "nothing changed");
15872        assert!(d.status.is_some(), "a status explains why");
15873        assert!(!d.caret_in_table());
15874    }
15875
15876    #[test]
15877    fn enter_in_an_ordered_list_renumbers_the_following_items() {
15878        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
15879        // the renumber pass keeps them sequential, matching what the view draws.
15880        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
15881        d.caret = d.source.find('a').unwrap() + 1; // end of item a
15882        d.newline();
15883        d.insert("x");
15884        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
15885    }
15886
15887    #[test]
15888    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
15889        for view in [View::Source, View::Wysiwyg] {
15890            let mut d = doc_in(view, "outdent_noop", "hello\n");
15891            d.caret = 2;
15892            d.outdent();
15893            assert_eq!(d.source, "hello\n");
15894            assert!(!d.dirty, "a no-op is not a modification");
15895            d.undo();
15896            assert_eq!(
15897                d.status.as_deref(),
15898                Some("nothing to undo"),
15899                "spends no undo step"
15900            );
15901            assert_eq!(d.source, "hello\n");
15902        }
15903    }
15904
15905    #[test]
15906    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
15907        for view in [View::Source, View::Wysiwyg] {
15908            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
15909            d.anchor = Some(0);
15910            d.caret = 7; // through "two"
15911            d.indent();
15912            assert_eq!(
15913                d.source, "  one\n\n  two\n",
15914                "the blank line keeps no trailing pad"
15915            );
15916            // Selected, so a second Tab lands on the same lines rather than on
15917            // whatever the shifted offsets now cover.
15918            assert_eq!(d.selection(), Some((0, 12)));
15919            d.indent();
15920            assert_eq!(d.source, "    one\n\n    two\n");
15921        }
15922    }
15923
15924    #[test]
15925    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
15926        for view in [View::Source, View::Wysiwyg] {
15927            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
15928            d.anchor = Some(0);
15929            d.caret = 15;
15930            d.outdent();
15931            assert_eq!(d.source, "two\none\nnone\n");
15932        }
15933    }
15934
15935    #[test]
15936    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
15937        for view in [View::Source, View::Wysiwyg] {
15938            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
15939            d.anchor = Some(0);
15940            d.caret = 7;
15941            d.indent();
15942            assert_eq!(d.source, "  one\n\n  two\n");
15943            d.undo();
15944            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
15945            assert_eq!(
15946                d.selection(),
15947                Some((0, 7)),
15948                "with the selection it was aimed at"
15949            );
15950            d.redo();
15951            assert_eq!(d.source, "  one\n\n  two\n");
15952            assert_eq!(
15953                d.selection(),
15954                Some((0, 12)),
15955                "redo replays the caret the indent placed, not the one splice left"
15956            );
15957        }
15958    }
15959
15960    #[test]
15961    fn vertical_motion_keeps_the_column() {
15962        let mut d = doc_with("move", "abcd\nef\n");
15963        d.caret = 3; // "abc|d" on row 0, col 3
15964        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
15965        assert_eq!(d.caret, 7); // just after "ef"
15966    }
15967
15968    // ── goal column ──────────────────────────────────────────────────────────
15969
15970    #[test]
15971    fn vertical_motion_goal_column_survives_a_short_line() {
15972        // Regression: re-deriving the column from the clamped position on
15973        // every step permanently forgets it once a short line clamps it.
15974        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
15975        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
15976        assert_eq!(
15977            g("abcd|ef\nxy\nghijkl\n", |d| {
15978                d.move_down(false); // clamps to end of "xy"
15979                d.move_down(false); // restores col 4 on the long line
15980            }),
15981            "abcdef\nxy\nghij|kl\n"
15982        );
15983    }
15984
15985    #[test]
15986    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
15987        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
15988        assert_eq!(d.goal_col, None);
15989        d.caret = 4; // row 0, col 4
15990        d.move_down(false); // clamps into "xy"; goal stays the original col
15991        assert_eq!(d.goal_col, Some(4));
15992        assert_eq!(d.caret_pos(), (1, 2));
15993
15994        // A horizontal motion drops the goal column...
15995        d.move_left(false);
15996        assert_eq!(d.goal_col, None);
15997
15998        // ...so the next vertical motion picks up the *new* column (1), not
15999        // the stale one (4).
16000        d.move_down(false);
16001        assert_eq!(d.goal_col, Some(1));
16002        assert_eq!(d.caret_pos(), (2, 1));
16003    }
16004
16005    #[test]
16006    fn editing_clears_the_goal_column() {
16007        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
16008        d.caret = 4;
16009        d.move_down(false);
16010        assert_eq!(d.goal_col, Some(4));
16011        d.insert("Z");
16012        assert_eq!(d.goal_col, None);
16013    }
16014
16015    #[test]
16016    fn vertical_motion_on_an_empty_document_is_a_no_op() {
16017        let mut d = doc_with("empty_vert", "");
16018        d.move_down(false);
16019        assert_eq!(d.caret, 0);
16020        d.move_up(false);
16021        assert_eq!(d.caret, 0);
16022    }
16023
16024    // ── the document's edges ─────────────────────────────────────────────────
16025
16026    #[test]
16027    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
16028        // The reproduction, and the disagreement: Down on the last line ran to
16029        // the end of the document in the source view — by accident, an
16030        // out-of-range row clamping to the end of the string — and did nothing
16031        // whatever in the view leaf opens in. One rule now, in both.
16032        for (view, tag) in VIEWS {
16033            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
16034            d.caret = 1;
16035            d.move_down(false);
16036            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
16037            d.move_up(false);
16038            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
16039        }
16040    }
16041
16042    #[test]
16043    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
16044        // Down off the bottom is a motion like any other, so it latches a goal
16045        // column — and Up comes back to the column the caret left, not to the
16046        // one the document's end happened to be in.
16047        for (view, tag) in VIEWS {
16048            let gap = if view == View::Source { "\n" } else { "\n\n" };
16049            let src = format!("abcdef{gap}ghijkl");
16050            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
16051            d.caret = 2; // row 0, col 2
16052            d.move_down(false);
16053            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
16054            d.move_down(false);
16055            assert_eq!(
16056                d.caret,
16057                src.len(),
16058                "{tag}: Down off the bottom reaches the end"
16059            );
16060            d.move_up(false);
16061            assert_eq!(
16062                d.caret_pos().1,
16063                2,
16064                "{tag}: Up returns to the column Down left"
16065            );
16066        }
16067    }
16068
16069    #[test]
16070    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
16071        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
16072        // Up that did nothing still armed a goal column, and the next Down aimed
16073        // at a column the caret had never been in.
16074        for (view, tag) in VIEWS {
16075            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
16076            d.caret = 0;
16077            d.move_up(false);
16078            assert_eq!(d.caret, 0, "{tag}: already at the start");
16079            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
16080
16081            d.caret = d.source.len();
16082            d.move_down(false);
16083            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
16084            assert_eq!(
16085                d.goal_col, None,
16086                "{tag}: a no-op Down latched a goal column"
16087            );
16088        }
16089    }
16090
16091    // ── soft wrap ────────────────────────────────────────────────────────────
16092    // Every other test here builds the map at 80 columns, where no fixture is
16093    // long enough to fold. A wrap is where one offset belongs to two rows at
16094    // once, and it broke everything that asks the caret what row it is on.
16095
16096    /// The wrapped fixture these cases share, folded at 12 columns into
16097    /// `one two ` / `three four ` / `five six ` / `seven eight`.
16098    fn wrapped_doc(name: &str) -> Doc {
16099        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
16100        d.build_visual(12);
16101        d
16102    }
16103
16104    #[test]
16105    fn home_and_end_work_from_a_wrapped_row() {
16106        // The reproduction: offset 19 is the `f` of "five", the first character
16107        // of the third row — and also the offset the second row ends at. It
16108        // resolved to the *second* row, so End aimed at a place the caret was
16109        // already in and did nothing, while Home walked backwards onto a row the
16110        // caret had left.
16111        let mut d = wrapped_doc("wrap_home_end");
16112        d.caret = 19;
16113        assert_eq!(
16114            d.caret_pos(),
16115            (2, 0),
16116            "the wrap boundary opens the third row"
16117        );
16118        d.move_end(false);
16119        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
16120        d.move_home(false);
16121        assert_eq!(d.caret, 19, "Home left the row the caret was on");
16122    }
16123
16124    #[test]
16125    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
16126        // The row's end is the last offset that is only ever its own: the offset
16127        // past it opens the row below, and aiming there would send a second
16128        // press on to *that* row's end, and a third to the next — End walking
16129        // down the paragraph rather than sitting where it landed.
16130        let mut d = wrapped_doc("wrap_end_twice");
16131        d.caret = 12; // inside "three", on the second row
16132        d.move_end(false);
16133        assert_eq!(
16134            d.caret, 18,
16135            "the end of `three four`, before the space the wrap ate"
16136        );
16137        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
16138        d.move_end(false);
16139        assert_eq!(d.caret, 18, "a second End moved the caret");
16140        d.move_home(false);
16141        assert_eq!(d.caret, 8, "Home takes the row's own start");
16142    }
16143
16144    #[test]
16145    fn vertical_motion_crosses_a_soft_wrap() {
16146        // Down aimed at the row below's column 0, an offset that resolved *up*
16147        // to the row above's end — so it landed on the offset it already had and
16148        // the caret could never leave a paragraph's first row.
16149        let mut d = wrapped_doc("wrap_down");
16150        d.caret = 0;
16151        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
16152            d.move_down(false);
16153            assert_eq!(d.caret, want, "Down stalled");
16154            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
16155        }
16156        d.move_down(false);
16157        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
16158
16159        // ...and back up, one row per press. The goal column is the end of the
16160        // last row, past every other row's width, so each press clamps to the
16161        // row's own last offset rather than to the one that opens the next.
16162        let mut d = wrapped_doc("wrap_up");
16163        d.caret = 39;
16164        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
16165            d.move_up(false);
16166            assert_eq!(d.caret, want, "Up stalled");
16167            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
16168        }
16169    }
16170
16171    #[test]
16172    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
16173        // The kills take the same line Home and End do, so in WYSIWYG they take
16174        // the visual row — and a soft wrap has no newline in it to delete, so
16175        // nothing is joined by reaching the end of one.
16176        let mut d = wrapped_doc("wrap_kill");
16177        d.caret = 19; // the `f` of "five", opening the third row
16178        d.delete_to_line_end();
16179        // The space the wrap ate goes with the row it was drawn on: sparing it
16180        // would leave "four  seven", two spaces where the row had been.
16181        assert_eq!(d.source, "one two three four seven eight");
16182
16183        // Backwards from the row's last caret position — which is *before* that
16184        // space, so this one survives, being on the far side of the caret.
16185        let mut d = wrapped_doc("wrap_kill_back");
16186        d.caret = 27;
16187        d.delete_to_line_start();
16188        assert_eq!(d.source, "one two three four  seven eight");
16189    }
16190
16191    // ── document start / end ────────────────────────────────────────────────
16192
16193    #[test]
16194    fn move_doc_start_and_end_jump_to_the_edges() {
16195        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
16196        assert_eq!(
16197            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
16198            "|hello\nworld\n"
16199        );
16200        assert_eq!(
16201            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
16202            "hello\nworld\n|"
16203        );
16204        // Already at the edge: a no-op.
16205        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
16206        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
16207    }
16208
16209    #[test]
16210    fn move_doc_start_and_end_extend_the_selection() {
16211        assert_eq!(
16212            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
16213                .move_doc_end(true)),
16214            "hello wor[ld\n|]"
16215        );
16216        assert_eq!(
16217            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
16218                .move_doc_start(true)),
16219            "[|hello wor]ld\n"
16220        );
16221    }
16222
16223    #[test]
16224    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
16225        let mut d = doc_with("empty_edges", "");
16226        d.move_doc_end(false);
16227        assert_eq!(d.caret, 0);
16228        d.move_doc_start(false);
16229        assert_eq!(d.caret, 0);
16230    }
16231
16232    // ── arrow collapses an active selection ─────────────────────────────────
16233
16234    #[test]
16235    fn arrow_collapses_selection_to_its_near_edge() {
16236        let mut d = doc_with("collapse", "hello world\n");
16237
16238        // Forward selection (anchor before caret): Right -> end, Left -> start.
16239        d.anchor = Some(2);
16240        d.caret = 7;
16241        d.move_right(false);
16242        assert_eq!((d.caret, d.anchor), (7, None));
16243
16244        d.anchor = Some(2);
16245        d.caret = 7;
16246        d.move_left(false);
16247        assert_eq!((d.caret, d.anchor), (2, None));
16248
16249        // Backward selection (anchor after caret): edges are the same
16250        // regardless of which end the caret started on.
16251        d.anchor = Some(7);
16252        d.caret = 2;
16253        d.move_right(false);
16254        assert_eq!((d.caret, d.anchor), (7, None));
16255
16256        d.anchor = Some(7);
16257        d.caret = 2;
16258        d.move_left(false);
16259        assert_eq!((d.caret, d.anchor), (2, None));
16260    }
16261
16262    #[test]
16263    fn arrow_with_extend_keeps_growing_the_selection() {
16264        let mut d = doc_with("collapse_extend", "hello world\n");
16265        d.anchor = Some(2);
16266        d.caret = 7;
16267        d.move_right(true); // extend: no collapse, caret steps one further
16268        assert_eq!((d.caret, d.anchor), (8, Some(2)));
16269    }
16270
16271    #[test]
16272    fn arrow_without_a_selection_moves_one_character_as_before() {
16273        let mut d = doc_with("no_collapse", "hello\n");
16274        d.caret = 2;
16275        d.move_right(false);
16276        assert_eq!(d.caret, 3);
16277        d.move_left(false);
16278        assert_eq!(d.caret, 2);
16279    }
16280
16281    /// Press Right until it stops, collecting the offsets walked through. Every
16282    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
16283    /// two stops sharing one source offset can't be moved between, so the caret
16284    /// stalls on the first of them and the walk never reaches the rest.
16285    fn walk_right(d: &mut Doc) -> Vec<usize> {
16286        let mut seen = vec![d.caret];
16287        for _ in 0..2000 {
16288            let before = d.caret;
16289            d.move_right(false);
16290            if d.caret == before {
16291                break;
16292            }
16293            seen.push(d.caret);
16294        }
16295        seen
16296    }
16297
16298    #[test]
16299    fn the_caret_crosses_a_soft_break() {
16300        // A newline inside a paragraph is a `soft_break`, which twig gives no
16301        // span of its own — the space it renders as used to borrow the offset of
16302        // the character before it, and a caret can't move without changing
16303        // offset. Right must walk clean off the end of the first line.
16304        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
16305        d.caret = 0;
16306        let seen = walk_right(&mut d);
16307        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
16308    }
16309
16310    #[test]
16311    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
16312        // The paragraph holds one soft break. Folded (the default) it lays out as
16313        // a single reflowed row; Preserve re-lays it as a row per source line.
16314        // The setter must invalidate the cached map for the change to show, and
16315        // again on the way back — so a round trip returns to the folded layout.
16316        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
16317        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
16318        d.build_visual(80);
16319        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
16320
16321        d.set_line_flow(LineFlow::Preserve);
16322        d.build_visual(80);
16323        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
16324
16325        d.set_line_flow(LineFlow::Fold);
16326        d.build_visual(80);
16327        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
16328    }
16329
16330    #[test]
16331    fn the_caret_still_crosses_a_preserved_soft_break() {
16332        // Preserve renders the soft break as a row boundary rather than a space,
16333        // but the caret must still reach every offset — the break's own offset is
16334        // the first row's end stop, so Right walks clean off the end of line one
16335        // onto line two, exactly as it does when the break is folded.
16336        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
16337        d.set_line_flow(LineFlow::Preserve);
16338        d.build_visual(80);
16339        d.caret = 0;
16340        let seen = walk_right(&mut d);
16341        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
16342    }
16343
16344    #[test]
16345    fn the_caret_walks_a_code_block() {
16346        // Every glyph of a code block used to map to the block's start, so the
16347        // whole block was a single offset and the caret couldn't move inside it.
16348        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
16349        let mut d = wysiwyg_doc("code_walk", src);
16350        d.caret = 0;
16351        let seen = walk_right(&mut d);
16352        // The fences are markup: hidden, and no caret stop. The code between
16353        // them is reached a character at a time.
16354        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
16355        for off in code.clone() {
16356            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
16357        }
16358        assert!(seen.contains(&code.end), "no stop after the last line");
16359    }
16360
16361    #[test]
16362    fn the_caret_walks_an_indented_code_block() {
16363        // An indented block's text has the four-space indent stripped, so it
16364        // isn't a verbatim slice and its lines have to be re-found. The caret
16365        // lands on the code, never in the indent.
16366        let src = "    indented\n    code\n";
16367        let mut d = wysiwyg_doc("indent_code_walk", src);
16368        d.caret = 0;
16369        let seen = walk_right(&mut d);
16370        assert!(seen.contains(&src.find("indented").unwrap()));
16371        assert!(seen.contains(&src.find("code").unwrap()));
16372        assert!(
16373            !seen.contains(&0) || seen[0] == 0,
16374            "the caret starts where it was put"
16375        );
16376        // Nothing in the stripped indent is a stop.
16377        for off in [1, 2, 3] {
16378            assert!(!seen.contains(&off), "landed in the indent at {off}");
16379        }
16380    }
16381
16382    #[test]
16383    fn the_caret_leaves_a_tight_heading() {
16384        // "# H" with text directly under it: the heading row's end and the
16385        // separator row's end are the same offset. Right used to find the
16386        // separator's copy, set the caret to where it already was, and stop.
16387        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
16388        d.caret = 2; // the "H"
16389        let seen = walk_right(&mut d);
16390        assert!(
16391            seen.len() > 2,
16392            "Right stalled at the heading's end: {seen:?}"
16393        );
16394        assert!(
16395            seen.contains(&8),
16396            "never reached the end of \"text\": {seen:?}"
16397        );
16398    }
16399
16400    #[test]
16401    fn the_caret_skips_the_gap_between_two_paragraphs() {
16402        // The blank line between two paragraphs is the boundary itself. The
16403        // caret used to be able to sit on it, and typing there landed in the
16404        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
16405        // soft break, so the text visibly snapped back up.
16406        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
16407        d.caret = 1; // the end of "A"
16408        d.move_right(false);
16409        assert_eq!(d.caret, 3, "Right stopped in the gap");
16410        d.insert("x");
16411        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
16412    }
16413
16414    #[test]
16415    fn down_from_a_paragraph_lands_on_the_next_one() {
16416        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
16417        d.caret = 0;
16418        d.move_down(false);
16419        assert_eq!(d.caret, 3, "Down stopped in the gap");
16420    }
16421
16422    #[test]
16423    fn clicking_the_gap_lands_on_real_text() {
16424        // A click can still *reach* the gap — it's drawn, so it's clickable.
16425        // It has to resolve to somewhere the caret can be.
16426        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
16427        d.click(1, 0, false); // the gap row
16428        assert!(
16429            d.caret == 1 || d.caret == 3,
16430            "click left the caret in the gap at {}",
16431            d.caret
16432        );
16433        d.insert("x");
16434        // Either edge of the boundary is a fair place to land; inside it isn't.
16435        assert!(
16436            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
16437            "click in the gap typed into the boundary: {:?}",
16438            d.source
16439        );
16440    }
16441
16442    #[test]
16443    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
16444        // Enter inserts a paragraph break, which leaves a blank line spare on
16445        // either side of a new one. That middle line is a real empty paragraph:
16446        // the caret lands there, and typing makes a paragraph rather than
16447        // extending a neighbour.
16448        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
16449        d.caret = 1;
16450        d.newline();
16451        assert_eq!(d.source, "A\n\n\n\nB\n");
16452        d.build_visual(80);
16453        let (row, _) = d.caret_pos();
16454        assert!(
16455            d.vmap.row_is_navigable(row),
16456            "the caret landed on a gap row"
16457        );
16458        d.insert("x");
16459        assert_eq!(
16460            d.source, "A\n\nx\n\nB\n",
16461            "the new paragraph merged into a neighbour"
16462        );
16463    }
16464
16465    #[test]
16466    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
16467        let mut d = wysiwyg_doc("gap_eof", "A\n");
16468        d.caret = 1;
16469        d.newline();
16470        d.build_visual(80);
16471        let (row, _) = d.caret_pos();
16472        assert!(
16473            d.vmap.row_is_navigable(row),
16474            "the caret landed on a gap row"
16475        );
16476        d.insert("x");
16477        assert!(
16478            d.source.starts_with("A\n\n") && d.source.contains('x'),
16479            "typing at the end merged into A: {:?}",
16480            d.source
16481        );
16482    }
16483
16484    // ── click_past_end ───────────────────────────────────────────────────────
16485
16486    /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
16487    /// rendered as `|`.
16488    fn past_end(name: &str, body: &str) -> (Doc, String) {
16489        let mut d = wysiwyg_doc(name, body);
16490        d.click_past_end();
16491        let out = render_caret(&d);
16492        (d, out)
16493    }
16494
16495    #[test]
16496    fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
16497        let (mut d, out) = past_end("pe_para", "A\n");
16498        assert_eq!(out, "A\n\n|");
16499        d.build_visual(80);
16500        let (row, _) = d.caret_pos();
16501        assert!(
16502            d.vmap.row_is_navigable(row),
16503            "the caret landed on a gap row"
16504        );
16505        d.insert("x");
16506        assert_eq!(d.source, "A\n\nx", "typing merged into A");
16507    }
16508
16509    #[test]
16510    fn click_past_end_writes_both_newlines_when_the_file_has_none() {
16511        assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
16512    }
16513
16514    #[test]
16515    fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
16516        let (d, out) = past_end("pe_there", "A\n\n");
16517        assert_eq!(out, "A\n\n|");
16518        assert!(!d.dirty, "a click wrote to a document it did not need to");
16519        assert!(
16520            !d.can_undo(),
16521            "a click that changed nothing left an undo step"
16522        );
16523    }
16524
16525    #[test]
16526    fn click_past_end_leaves_a_closed_fence() {
16527        let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
16528        assert_eq!(out, "```\ncode\n```\n\n|");
16529        d.build_visual(80);
16530        let (row, _) = d.caret_pos();
16531        assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
16532        d.insert("x");
16533        assert_eq!(d.source, "```\ncode\n```\n\nx");
16534    }
16535
16536    #[test]
16537    fn click_past_end_closes_an_unclosed_fence_first() {
16538        assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
16539        assert_eq!(
16540            past_end("pe_open2", "````\ncode").1,
16541            "````\ncode\n````\n\n|"
16542        );
16543        assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
16544        assert_eq!(
16545            past_end("pe_quoted", "> ```\n> code\n").1,
16546            "> ```\n> code\n> ```\n\n|"
16547        );
16548    }
16549
16550    #[test]
16551    fn click_past_end_does_not_close_an_indented_block() {
16552        assert_eq!(past_end("pe_indent", "    code\n").1, "    code\n\n|");
16553    }
16554
16555    #[test]
16556    fn click_past_end_under_a_list_and_a_table_leaves_them() {
16557        let (mut d, out) = past_end("pe_list", "- a\n- b\n");
16558        assert_eq!(out, "- a\n- b\n\n|");
16559        d.insert("x");
16560        assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
16561        assert_eq!(
16562            past_end("pe_table", "| a |\n|---|\n| b |\n").1,
16563            "| a |\n|---|\n| b |\n\n|"
16564        );
16565    }
16566
16567    #[test]
16568    fn click_past_end_on_an_empty_document_writes_nothing() {
16569        let (d, out) = past_end("pe_empty", "");
16570        assert_eq!(out, "|");
16571        assert!(!d.dirty);
16572    }
16573
16574    #[test]
16575    fn click_past_end_is_one_undo_step() {
16576        let (mut d, _) = past_end("pe_undo", "A\n");
16577        d.undo();
16578        assert_eq!(d.source, "A\n");
16579    }
16580
16581    #[test]
16582    fn click_past_end_in_the_source_view_only_moves_the_caret() {
16583        let mut d = doc_with("pe_source", "A\n");
16584        d.click_past_end();
16585        assert_eq!(render_caret(&d), "A\n|");
16586        assert!(!d.dirty);
16587    }
16588
16589    #[test]
16590    fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
16591        let mut d = wysiwyg_doc("pe_ro", "A\n");
16592        d.set_read_only(true);
16593        d.click_past_end();
16594        assert_eq!(render_caret(&d), "A\n|");
16595    }
16596
16597    // ── toggle_code_block ────────────────────────────────────────────────────
16598
16599    #[test]
16600    fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
16601        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
16602        assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
16603        assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
16604        assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
16605        assert_eq!(
16606            g("cb_wrap", "aaa\nb|bb\nccc\n"),
16607            "```\naaa\nb|bb\nccc\n```\n"
16608        );
16609        assert_eq!(
16610            g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
16611            "aaa\nb|bb\nccc\n"
16612        );
16613        // An indented block dedents, and the lines stay one-to-one.
16614        assert_eq!(g("cb_indent", "    co|de\n"), "co|de\n");
16615        // A quote's marker is kept on every line, the fence's included.
16616        assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
16617    }
16618
16619    #[test]
16620    fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
16621        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
16622        assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
16623        assert_eq!(
16624            g("cb_blank_mid", "A\n\n|\n\nB\n"),
16625            "A\n\n```\n|\n```\n\nB\n"
16626        );
16627        // Tight against a neighbour, a blank line goes in on that side.
16628        assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
16629        // Inside a quote, on a quoted blank line.
16630        assert_eq!(
16631            g("cb_blank_quote", "> A\n>\n> |\n"),
16632            "> A\n>\n> ```\n> |\n> ```\n"
16633        );
16634    }
16635
16636    #[test]
16637    fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
16638        let mut d = wysiwyg_doc("cb_type", "A\n\n");
16639        d.click_past_end();
16640        d.toggle_code_block();
16641        assert!(
16642            d.caret_in_code_block(),
16643            "the caret is not in the block it opened"
16644        );
16645        d.insert("let x = 1;");
16646        d.newline();
16647        d.insert("x");
16648        assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
16649        d.build_visual(80);
16650        let (row, _) = d.caret_pos();
16651        assert!(d.vmap.rows[row].code, "typed text is not on a code row");
16652        // And back out: the button reverses what it did, text kept.
16653        d.toggle_code_block();
16654        assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
16655        assert!(!d.caret_in_code_block());
16656    }
16657
16658    #[test]
16659    fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
16660        let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
16661        d.anchor = Some(1);
16662        d.caret = 7;
16663        d.toggle_code_block();
16664        assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
16665        assert!(d.selection().is_some(), "the block came back unselected");
16666        d.toggle_code_block();
16667        assert_eq!(
16668            d.source, "one\n\ntwo\n\nthree\n",
16669            "a second press did not reverse the first"
16670        );
16671    }
16672
16673    #[test]
16674    fn toggle_code_block_inside_a_list_item_is_refused_and_reported() {
16675        let mut d = wysiwyg_doc("cb_list", "- it|em\n".replace('|', "").as_str());
16676        d.caret = 3;
16677        d.toggle_code_block();
16678        assert_eq!(d.source, "- item\n");
16679        assert!(
16680            d.status
16681                .as_deref()
16682                .is_some_and(|s| s.starts_with("code block:"))
16683        );
16684    }
16685
16686    #[test]
16687    fn caret_in_code_block_reads_fenced_and_indented_blocks() {
16688        let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n    more\n");
16689        d.caret = 2;
16690        assert!(!d.caret_in_code_block());
16691        d.caret = 11;
16692        assert!(d.caret_in_code_block());
16693        d.caret = 25;
16694        assert!(d.caret_in_code_block(), "an indented block is a code block");
16695    }
16696
16697    #[test]
16698    fn toggle_code_block_is_one_undo_step() {
16699        let mut d = wysiwyg_doc("cb_undo", "hello\n");
16700        d.caret = 2;
16701        d.toggle_code_block();
16702        d.undo();
16703        assert_eq!(render_caret(&d), "he|llo\n");
16704    }
16705
16706    #[test]
16707    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
16708        // A paragraph broken over two source lines is one paragraph. Selecting
16709        // it must not stop at the newline inside it — that newline is markup the
16710        // rich-text view exists to hide.
16711        let src = "one two\nthree four\n\nnext\n";
16712        let mut d = wysiwyg_doc("triple_para", src);
16713        d.select_block_at(2);
16714        assert_eq!(
16715            d.selected_text(),
16716            Some("one two\nthree four"),
16717            "stopped at the soft break"
16718        );
16719    }
16720
16721    #[test]
16722    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
16723        // The reader scrolls down past the caret's row. Nothing moved the
16724        // caret, so the view must stay where it was put — the old code revealed
16725        // the caret every frame, which dragged the view straight back and made
16726        // the document unscrollable past the caret.
16727        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
16728        d.caret = 0;
16729        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
16730        d.scroll = 4; // the wheel
16731        d.follow_caret(0, 3, 9);
16732        assert_eq!(
16733            d.scroll, 4,
16734            "the wheel was overruled by a caret that never moved"
16735        );
16736    }
16737
16738    #[test]
16739    fn moving_the_caret_brings_the_view_back_to_it() {
16740        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
16741        d.caret = 0;
16742        d.follow_caret(0, 3, 9);
16743        d.scroll = 6; // scrolled away
16744        d.move_right(false); // ...and now the caret moves
16745        let (row, _) = d.caret_pos();
16746        d.follow_caret(row, 3, 9);
16747        assert!(
16748            d.scroll <= row && row < d.scroll + 3,
16749            "caret row {row} off screen at scroll {}",
16750            d.scroll
16751        );
16752    }
16753
16754    #[test]
16755    fn scrolling_stops_at_the_last_row() {
16756        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
16757        d.caret = 0;
16758        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
16759        d.scroll = 999; // the wheel, spun hard
16760        d.follow_caret(0, 3, 3);
16761        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
16762    }
16763
16764    #[test]
16765    fn every_cell_of_a_wide_table_is_reachable() {
16766        // A table whose cells are far wider than the surface: the columns are
16767        // cut to fit and the text wraps inside them, so no cell hangs off the
16768        // right edge where the caret can never go.
16769        let src = "| Ingredient | Notes |\n|---|---|\n\
16770                   | flour milled coarse | sift it twice before folding it in |\n";
16771        let mut d = wysiwyg_doc("wide_table_walk", src);
16772        d.build_visual(30);
16773        d.caret = 0;
16774        let seen = walk_right(&mut d);
16775        for word in ["Ingredient", "Notes", "coarse", "folding"] {
16776            let at = src.find(word).unwrap();
16777            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
16778        }
16779    }
16780
16781    // ── view parity ──────────────────────────────────────────────────────────
16782    // `doc_with` pins the source view, so everything above tests a view users
16783    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
16784    // deletion golden cases through *both*, plus the WYSIWYG cases the two
16785    // can't share: where the source carries markup the rendered text is a
16786    // different string, and the views agreeing would itself be the bug.
16787
16788    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
16789
16790    /// Run `action` in both views on one `|`-marked fixture and assert they
16791    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
16792    /// source verbatim, so the two views are looking at the same text and any
16793    /// disagreement is one of them having lost the plot.
16794    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
16795        let (src, caret) = parse_caret(marked);
16796        let run = |view: View, tag: &str| {
16797            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
16798            d.caret = caret;
16799            action(&mut d);
16800            render_caret(&d)
16801        };
16802        let source = run(VIEWS[0].0, VIEWS[0].1);
16803        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
16804        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
16805        source
16806    }
16807
16808    #[test]
16809    fn word_motion_agrees_across_the_views_on_plain_prose() {
16810        let g = both_views;
16811        assert_eq!(
16812            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
16813            "hello |world"
16814        );
16815        assert_eq!(
16816            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
16817            "|hello world"
16818        );
16819        assert_eq!(
16820            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
16821            "hello| world"
16822        );
16823        assert_eq!(
16824            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
16825            "hello world|"
16826        );
16827        assert_eq!(
16828            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
16829            "foo|.bar"
16830        );
16831        assert_eq!(
16832            g("par_ext", "hello |world", |d| d.move_word_right(true)),
16833            "hello [world|]"
16834        );
16835    }
16836
16837    #[test]
16838    fn word_deletion_agrees_across_the_views_on_plain_prose() {
16839        let g = both_views;
16840        assert_eq!(
16841            g("par_db", "hello world|", |d| d.delete_word_back()),
16842            "hello |"
16843        );
16844        assert_eq!(
16845            g("par_df", "hello |world", |d| d.delete_word_forward()),
16846            "hello |"
16847        );
16848        assert_eq!(
16849            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
16850            "|bar baz"
16851        );
16852        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
16853    }
16854
16855    #[test]
16856    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
16857        let g = both_views;
16858        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
16859        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
16860        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
16861        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
16862    }
16863
16864    #[test]
16865    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
16866        // The reproduction: the stop table was built one stop per `char`, so
16867        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
16868        // source view, which steps by grapheme, can't reach and backspace can't
16869        // survive. The two views must land on the same offset.
16870        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
16871        for (view, tag) in VIEWS {
16872            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
16873            d.caret = 1;
16874            d.move_right(false);
16875            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
16876
16877            // ...and the edit that used to sever a joiner off the front of it.
16878            d.backspace();
16879            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
16880            assert_eq!(d.caret, 1);
16881        }
16882    }
16883
16884    #[test]
16885    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
16886        for (view, tag) in VIEWS {
16887            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
16888            d.caret = 0;
16889            d.move_right(false);
16890            assert_eq!(
16891                d.caret,
16892                "e\u{0301}".len(),
16893                "{tag} stopped on the combining mark"
16894            );
16895        }
16896    }
16897
16898    #[test]
16899    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
16900        // The general form: whatever route the caret takes through a document
16901        // full of clusters, it never lands between the codepoints of one — so no
16902        // motion-then-backspace sequence can leave a dangling joiner behind.
16903        use unicode_segmentation::UnicodeSegmentation;
16904
16905        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
16906        let mut d = wysiwyg_doc("cluster_walk", src);
16907        d.caret = 0;
16908        let boundaries: Vec<usize> = src
16909            .grapheme_indices(true)
16910            .map(|(i, _)| i)
16911            .chain(std::iter::once(src.len()))
16912            .collect();
16913        for off in walk_right(&mut d) {
16914            assert!(
16915                boundaries.contains(&off),
16916                "Right stopped at {off}, inside a grapheme cluster"
16917            );
16918        }
16919    }
16920
16921    #[test]
16922    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
16923        // The reproduction: ⌥→ from inside the opening `**` computed its
16924        // boundary over the raw source and landed on byte 8 — inside the
16925        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
16926        // "bold". The caret drew past the bold word and sat inside it.
16927        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
16928        d.caret = 2;
16929        d.move_word_right(false);
16930        assert!(
16931            d.vmap.is_stop(d.caret),
16932            "landed at {}, not a caret stop",
16933            d.caret
16934        );
16935        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
16936        // The rendered row is "a bold c": column 6 is the space just past "bold",
16937        // and now the caret is really there rather than only drawn there.
16938        assert_eq!(d.caret_pos(), (0, 6));
16939
16940        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
16941        d.move_word_left(false);
16942        assert_eq!(d.caret, 4);
16943        assert_eq!(d.caret_pos(), (0, 2));
16944    }
16945
16946    #[test]
16947    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
16948        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
16949        // inside the closing `**`, and left "a ** c\n" — delimiters with no
16950        // opener. Glyph space covers the word alone, which would leave
16951        // "a **** c": markup wrapped around nothing. The word and the styling
16952        // that was only ever the word's go together.
16953        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
16954        d.caret = 10;
16955        d.delete_word_back();
16956        assert_eq!(d.source, "a  c\n");
16957        assert_eq!(d.caret, 2);
16958
16959        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
16960        d.caret = 4; // the "b"
16961        d.delete_word_forward();
16962        assert_eq!(d.source, "a  c\n");
16963    }
16964
16965    #[test]
16966    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
16967        let src = "a ***bold*** c\n";
16968        let mut d = wysiwyg_doc("wys_word_del_nest", src);
16969        d.caret = src.find(" c").unwrap();
16970        d.delete_word_back();
16971        assert_eq!(
16972            d.source, "a  c\n",
16973            "the emph inside the strong empties it too"
16974        );
16975
16976        let src = "a `code` c\n";
16977        let mut d = wysiwyg_doc("wys_word_del_code", src);
16978        d.caret = src.find(" c").unwrap();
16979        d.delete_word_back();
16980        assert_eq!(d.source, "a  c\n");
16981    }
16982
16983    #[test]
16984    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
16985        // Only an *emptied* node goes. Take one word of two and the `**` still
16986        // has a job to do — over the word that's left, with the space the delete
16987        // pushed against the opening delimiter moved out in front of it, or the
16988        // run would be no run at all (`** words**` is literal asterisks — see
16989        // the mark-edge rule on `splice`).
16990        let src = "a **two words** c\n";
16991        let mut d = wysiwyg_doc("wys_word_del_partial", src);
16992        d.caret = src.find(" words").unwrap();
16993        d.delete_word_back();
16994        assert_eq!(d.source, "a  **words** c\n");
16995    }
16996
16997    #[test]
16998    fn source_view_word_motion_still_walks_the_markup() {
16999        // The other half of the decision: in the source view the `**` are
17000        // characters like any other — they're on the screen, so word motion has
17001        // to stop at them and a word-delete has to leave them behind. Only
17002        // WYSIWYG hides them, so only WYSIWYG steps over them.
17003        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
17004        assert_eq!(
17005            g("src_word_motion", "a |**bold** c\n", |d| d
17006                .move_word_right(false)),
17007            "a **bold|** c\n"
17008        );
17009        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
17010        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
17011        assert_eq!(
17012            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
17013            "a **| c\n"
17014        );
17015    }
17016
17017    #[test]
17018    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
17019        // The single invariant both bugs violated: the caret draws and edits at
17020        // the same place only when it's on a stop. `debug_assert_on_a_stop`
17021        // makes the same claim in-place; this pins it from the outside, over a
17022        // document with every kind of thing the map has to be careful about.
17023        // At two widths: the wide one every other test builds at, where no
17024        // fixture folds, and one narrow enough that they all do. A soft wrap is
17025        // where an offset stops being on exactly one row, and testing only the
17026        // width that never wraps is how the caret came to be pinned at the first
17027        // one Down reached.
17028        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
17029                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
17030        // A table of named operations, which is what it looks like.
17031        #[allow(clippy::type_complexity)]
17032        let motions: [(&str, fn(&mut Doc)); 8] = [
17033            ("right", |d| d.move_right(false)),
17034            ("left", |d| d.move_left(false)),
17035            ("word_right", |d| d.move_word_right(false)),
17036            ("word_left", |d| d.move_word_left(false)),
17037            ("down", |d| d.move_down(false)),
17038            ("up", |d| d.move_up(false)),
17039            ("home", |d| d.move_home(false)),
17040            ("end", |d| d.move_end(false)),
17041        ];
17042        for width in [80, 12] {
17043            let mut d = wysiwyg_doc("stop_invariant", src);
17044            d.build_visual(width);
17045            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
17046            assert!(stops.len() > 20, "fixture should have plenty of stops");
17047            for start in stops {
17048                for (name, motion) in &motions {
17049                    d.caret = start;
17050                    d.anchor = None;
17051                    motion(&mut d);
17052                    assert!(
17053                        d.vmap.is_stop(d.caret),
17054                        "{name} from {start} at width {width} landed at {} — not a caret stop",
17055                        d.caret
17056                    );
17057                }
17058            }
17059        }
17060    }
17061
17062    #[test]
17063    fn no_wysiwyg_motion_is_a_dead_end() {
17064        // Down held to the bottom of a document reaches the bottom, and Up held
17065        // to the top reaches the top — from anywhere, at a width that wraps. The
17066        // invariant above says a motion lands somewhere legal; this one says it
17067        // gets somewhere at all, which is what a caret pinned at a wrap boundary
17068        // was quietly failing to do while every assertion around it held.
17069        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
17070                   - item one two three four five\n\nlast\n";
17071        for width in [80, 12] {
17072            let mut d = wysiwyg_doc("no_dead_end", src);
17073            d.build_visual(width);
17074            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
17075            let (first, last) = (stops[0], stops[stops.len() - 1]);
17076            for &start in &stops {
17077                for (name, motion, want) in [
17078                    (
17079                        "down",
17080                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
17081                        last,
17082                    ),
17083                    ("up", |d: &mut Doc| d.move_up(false), first),
17084                ] {
17085                    d.caret = start;
17086                    d.anchor = None;
17087                    d.goal_col = None;
17088                    // Every row, plus the presses the edges take, plus slack.
17089                    for _ in 0..d.vmap.num_rows() + 4 {
17090                        motion(&mut d);
17091                    }
17092                    assert_eq!(
17093                        d.caret, want,
17094                        "{name} held from {start} at width {width} never arrived"
17095                    );
17096                }
17097            }
17098        }
17099    }
17100    // ── display columns ──────────────────────────────────────────────────────
17101    // A `col` is a terminal cell, not a character. The two are the same number
17102    // for the ASCII the fixtures above are written in, which is how they came
17103    // apart in the first place: `你` is one character drawn in two cells, so a
17104    // column counted in characters names a cell the text isn't in — one earlier
17105    // for every wide character to its left.
17106
17107    #[test]
17108    fn a_wide_character_is_two_columns_wide() {
17109        // The reproduction: `你` is one char and two cells, so the caret just
17110        // past it drew at column 1 — inside the character it had already left.
17111        for (view, tag) in VIEWS {
17112            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
17113            d.caret = "你".len();
17114            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
17115            d.caret = "你好".len();
17116            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
17117        }
17118    }
17119
17120    #[test]
17121    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
17122        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
17123        // two-cell — measuring six cells one at a time, but the character they
17124        // spell is drawn in two. Width belongs to the cluster, not the glyph,
17125        // and the frontends measure it the same way.
17126        let family = "👨‍👩‍👧";
17127        for (view, tag) in VIEWS {
17128            let src = format!("a{family}b\n");
17129            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
17130            d.caret = 1 + family.len();
17131            assert_eq!(
17132                d.caret_pos(),
17133                (0, 3),
17134                "{tag}: 'a' is one cell, the family two"
17135            );
17136        }
17137    }
17138
17139    #[test]
17140    fn both_cells_of_a_wide_character_mean_the_character() {
17141        // Clicking the far half of `好` is still clicking `好`: half a character
17142        // is not a place the caret can be, so it comes to rest at the
17143        // character's start — the column it would have been drawn at anyway.
17144        for (view, tag) in VIEWS {
17145            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
17146            for col in [2, 3] {
17147                d.caret = 0;
17148                d.click(0, col, false);
17149                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
17150                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
17151            }
17152            // Past the last cell is the line's end, as it is for ASCII.
17153            d.click(0, 9, false);
17154            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
17155        }
17156    }
17157
17158    #[test]
17159    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
17160        // The mapping is only a mapping if it inverts: the cell the caret is
17161        // drawn in has to be the cell that brings it back to the same offset.
17162        // Over a fixture where a character may be one cell or two, and one
17163        // codepoint or five.
17164        use unicode_segmentation::UnicodeSegmentation;
17165
17166        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
17167
17168        let mut d = doc_in(View::Source, "roundtrip_source", src);
17169        // Every offset the source view's caret can occupy: it steps by grapheme
17170        // cluster, so those are its boundaries.
17171        for (off, _) in src
17172            .grapheme_indices(true)
17173            .chain(std::iter::once((src.len(), "")))
17174        {
17175            d.caret = off;
17176            let (row, col) = d.caret_pos();
17177            d.click(row, col, false);
17178            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
17179        }
17180
17181        // And in WYSIWYG, where the offsets the caret can occupy are the map's
17182        // stops rather than every boundary.
17183        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
17184        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
17185        assert!(stops.len() > 20, "fixture should have plenty of stops");
17186        for off in stops {
17187            d.caret = off;
17188            let (row, col) = d.caret_pos();
17189            d.click(row, col, false);
17190            assert_eq!(
17191                d.caret, off,
17192                "wysiwyg: {off} → ({row}, {col}) → {}",
17193                d.caret
17194            );
17195        }
17196    }
17197
17198    #[test]
17199    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
17200        // Down from under `世` lands under the glyph in that cell, not two
17201        // characters further along the line. The goal is a column, so a line of
17202        // wide characters and a line of ASCII line up the way they're drawn.
17203        //
17204        // The gap differs by view: a bare newline inside a paragraph is a soft
17205        // break, which WYSIWYG draws as a space on a single row. The views share
17206        // a grid only where the source's lines are the renderer's rows too.
17207        for (view, tag) in VIEWS {
17208            let gap = if view == View::Source { "\n" } else { "\n\n" };
17209            let src = format!("你好世{gap}abcdef\n");
17210            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
17211            d.caret = "你好".len();
17212            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
17213            d.move_down(false);
17214            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
17215            assert!(
17216                d.source[d.caret..].starts_with('e'),
17217                "{tag}: landed on the wrong glyph"
17218            );
17219        }
17220    }
17221
17222    #[test]
17223    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
17224        // Down from column 3 onto `你好`, whose characters start at columns 0
17225        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
17226        // between the cells of one character, so the caret rests on it — and on
17227        // its start, which is the only offset there that is a caret stop.
17228        for (view, tag) in VIEWS {
17229            let gap = if view == View::Source { "\n" } else { "\n\n" };
17230            let src = format!("abcdef{gap}你好\n");
17231            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
17232            let line = src.find('你').unwrap();
17233            d.caret = 3;
17234            d.move_down(false);
17235            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
17236            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
17237        }
17238    }
17239
17240    #[test]
17241    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
17242        // The column the cell's text is laid out in is measured in cells, so the
17243        // caret walking that text has to be too — the two agreeing is the whole
17244        // point of the grid staying square.
17245        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
17246        let at = d.source.find("你").unwrap();
17247        d.caret = at;
17248        let (row, col) = d.caret_pos();
17249        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
17250        // cells further along.
17251        assert_eq!(col, 2, "the cell's first character");
17252        d.move_right(false);
17253        assert_eq!(
17254            d.caret_pos(),
17255            (row, 4),
17256            "`好` is drawn past `你`'s two cells"
17257        );
17258        assert_eq!(d.caret, at + "你".len());
17259    }
17260
17261    // ── active inline marks ───────────────────────────────────────────────────
17262
17263    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
17264    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
17265        let (src, caret) = parse_caret(marked);
17266        let mut d = doc_in(view, name, &src);
17267        d.caret = caret;
17268        d.active_inline_marks().iter().collect()
17269    }
17270
17271    /// The marks over the selection `[start, end)`.
17272    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
17273        let mut d = doc_in(view, name, src);
17274        d.anchor = Some(start);
17275        d.caret = end;
17276        d.active_inline_marks().iter().collect()
17277    }
17278
17279    #[test]
17280    fn a_caret_in_a_mark_reports_it() {
17281        for (view, tag) in VIEWS {
17282            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
17283            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
17284            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
17285            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
17286            // Plain text under no mark lights nothing — the toolbar's resting state.
17287            assert_eq!(m("a| **bold** b"), [], "{tag}");
17288            assert!(m("plain t|ext").is_empty(), "{tag}");
17289        }
17290    }
17291
17292    #[test]
17293    fn nested_marks_all_report() {
17294        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
17295        // the ancestor chain is a chain, and every mark on it is in force.
17296        for (view, tag) in VIEWS {
17297            assert_eq!(
17298                marks(
17299                    view,
17300                    &format!("marks_nested_{tag}"),
17301                    "**bold and *bo|th*** end"
17302                ),
17303                [InlineKind::Strong, InlineKind::Emph],
17304                "{tag}"
17305            );
17306        }
17307    }
17308
17309    #[test]
17310    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
17311        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
17312        // the first byte of its text and the byte after its last — both inside
17313        // the mark's span, both places typing lands inside the bold. The offset
17314        // past the closing delimiter is the next text, and reports nothing.
17315        let src = "a **bold** b";
17316        let inner_start = src.find("bold").unwrap(); // 4
17317        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
17318        for (view, tag) in VIEWS {
17319            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
17320            for off in [2, 3, inner_start, inner_end, 9] {
17321                d.caret = off;
17322                assert!(
17323                    d.active_inline_marks().contains(InlineKind::Strong),
17324                    "{tag}: offset {off} is inside the strong span"
17325                );
17326            }
17327            for off in [0, 1, 10, 11, 12] {
17328                d.caret = off;
17329                assert!(
17330                    !d.active_inline_marks().contains(InlineKind::Strong),
17331                    "{tag}: offset {off} is outside the strong run"
17332                );
17333            }
17334        }
17335    }
17336
17337    #[test]
17338    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
17339        // Regression: twig resolves an offset that is one node's end and the
17340        // next one's start to the node that *starts* there, so `**bold**|\n`
17341        // isn't bold. With nothing following there's no tie to break and the
17342        // chain still ended at the mark, which made a trailing `\n` — not the
17343        // text — decide whether the caret after a bold word reported bold. It's
17344        // the offset past the mark either way, and typing there is plain either
17345        // way. A blank document typed into is exactly this shape.
17346        for (view, tag) in VIEWS {
17347            let m = |name: String, marked| marks(view, &name, marked);
17348            assert_eq!(
17349                m(format!("marks_eob_{tag}"), "**bold**|"),
17350                [],
17351                "{tag}: no trailing newline"
17352            );
17353            assert_eq!(
17354                m(format!("marks_eol_{tag}"), "**bold**|\n"),
17355                [],
17356                "{tag}: with one"
17357            );
17358            // And the last offset that *is* in the mark still is.
17359            assert_eq!(
17360                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
17361                [InlineKind::Strong],
17362                "{tag}"
17363            );
17364        }
17365    }
17366
17367    #[test]
17368    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
17369        let src = "a **bold** b";
17370        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
17371        for (view, tag) in VIEWS {
17372            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
17373            // The whole bold word, and a slice of it.
17374            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
17375            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
17376            // Ending exactly at the closing delimiter's start is still all-bold:
17377            // an exclusive end sits *past* the last selected character, so the
17378            // question is asked of the character, not the boundary.
17379            assert_eq!(
17380                m(b, d_ + 2),
17381                [InlineKind::Strong],
17382                "{tag}: through the close"
17383            );
17384            // Half in, half out: Bold lit here would claim a press turns it off.
17385            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
17386            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
17387        }
17388    }
17389
17390    #[test]
17391    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
17392        // Both ends are bold, but the space between them isn't — two runs are two
17393        // nodes, which is exactly what the node id catches and a kind-only
17394        // comparison would not.
17395        let src = "**one** **two**";
17396        for (view, tag) in VIEWS {
17397            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
17398            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
17399        }
17400    }
17401
17402    #[test]
17403    fn marks_read_the_document_as_it_is_edited() {
17404        // The point of asking twig every frame instead of caching: the answer has
17405        // to follow the toggle that changed it.
17406        let mut d = wysiwyg_doc("marks_live", "one two\n");
17407        d.anchor = Some(0);
17408        d.caret = 3;
17409        assert!(d.active_inline_marks().is_empty(), "plain to start");
17410        d.toggle(InlineKind::Strong);
17411        assert_eq!(d.source, "**one** two\n");
17412        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
17413        assert!(d.active_inline_marks().contains(InlineKind::Strong));
17414        d.toggle(InlineKind::Strong);
17415        assert!(d.active_inline_marks().is_empty(), "and off again");
17416    }
17417
17418    #[test]
17419    fn a_link_is_not_an_inline_mark() {
17420        // `link`/`str` are inline nodes, but nothing on the inline toolbar
17421        // toggles them — a set with a "link mark" in it would have no button.
17422        for (view, tag) in VIEWS {
17423            assert_eq!(
17424                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
17425                [],
17426                "{tag}"
17427            );
17428        }
17429    }
17430
17431    // ── blank documents ───────────────────────────────────────────────────────
17432
17433    #[test]
17434    fn a_blank_document_is_untitled_empty_and_markdown() {
17435        let mut d = Doc::blank().unwrap();
17436        assert!(d.is_untitled());
17437        assert_eq!(d.path, PathBuf::new());
17438        assert_eq!(
17439            d.file_name(),
17440            "untitled",
17441            "the header has to show something"
17442        );
17443        assert_eq!(d.format_name(), "markdown");
17444        assert_eq!(d.source, "");
17445        assert!(!d.dirty, "nothing typed yet is nothing to lose");
17446        assert_eq!(d.disk_state(), DiskState::Untitled);
17447        // And it's a document you can be in: the default view renders it.
17448        d.build_visual(80);
17449        assert_eq!(d.caret, 0);
17450    }
17451
17452    #[test]
17453    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
17454        let mut d = Doc::blank().unwrap();
17455        d.insert("hello");
17456        assert!(d.dirty);
17457        d.save();
17458        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
17459        assert!(d.dirty, "it must not come away believing it saved");
17460        assert!(d.is_untitled(), "and it still has no file");
17461    }
17462
17463    #[test]
17464    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
17465        let p = temp_path("blank_save_as");
17466        let mut d = Doc::blank().unwrap();
17467        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
17468        // be kept literal (`\#`); this test is about save-as, not escaping (which
17469        // has its own test), so it types nothing that escaping would touch.
17470        d.insert("hi");
17471        d.save_as(p.clone());
17472        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
17473        assert!(!d.is_untitled());
17474        assert!(!d.dirty);
17475        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
17476        assert_eq!(
17477            d.disk_state(),
17478            DiskState::Unchanged,
17479            "the watermark is stamped"
17480        );
17481        // And ⌘S is a plain save from here on.
17482        d.insert("!");
17483        d.save();
17484        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
17485        let _ = std::fs::remove_file(&p);
17486    }
17487
17488    // ── a file that isn't there yet ───────────────────────────────────────────
17489
17490    /// A unique path in the temp dir with the given extension, guaranteed not to
17491    /// exist — what `leaf notes.md` is handed when the file has never been made.
17492    fn missing_path(name: &str, ext: &str) -> PathBuf {
17493        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
17494        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
17495        let mut p = std::env::temp_dir();
17496        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
17497        let _ = std::fs::remove_file(&p);
17498        p
17499    }
17500
17501    #[test]
17502    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
17503        let p = missing_path("named", "md");
17504        let mut d = Doc::open_or_create(p.clone()).unwrap();
17505
17506        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
17507        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
17508        assert!(
17509            !d.is_untitled(),
17510            "it has the name the user asked for — ^S must not detour to Save As"
17511        );
17512        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
17513        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
17514        assert!(!p.exists(), "and opening it wrote nothing");
17515        // And it's a document you can be in.
17516        d.build_visual(80);
17517        assert_eq!(d.caret, 0);
17518    }
17519
17520    #[test]
17521    fn a_new_file_is_created_by_its_first_save() {
17522        let p = missing_path("first_save", "md");
17523        let mut d = Doc::open_or_create(p.clone()).unwrap();
17524        d.insert("hello\n");
17525        assert!(d.dirty);
17526        d.save();
17527
17528        assert_eq!(
17529            std::fs::read_to_string(&p).unwrap(),
17530            "hello\n",
17531            "a plain ^S wrote it — no Save As, no name to invent"
17532        );
17533        assert!(!d.dirty);
17534        assert_eq!(d.disk_state(), DiskState::Unchanged);
17535        let _ = std::fs::remove_file(&p);
17536    }
17537
17538    #[test]
17539    fn a_new_file_takes_its_format_from_the_extension() {
17540        // The one thing `blank` can't do: with no name it has to assume Markdown,
17541        // and typing djot into a Markdown parse is the wrong buffer.
17542        let dj = missing_path("format", "dj");
17543        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
17544        let md = missing_path("format", "md");
17545        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
17546    }
17547
17548    #[test]
17549    fn a_new_file_reports_itself_missing_until_it_is_saved() {
17550        // Not `Untitled` — that's the answer for a document with no path, and it
17551        // would tell a frontend there is nothing a save could collide with. Here
17552        // there is a path, and the file simply isn't at it yet.
17553        let p = missing_path("disk_state", "md");
17554        let mut d = Doc::open_or_create(p.clone()).unwrap();
17555        assert_eq!(d.disk_state(), DiskState::Missing);
17556
17557        // Somebody else creates it while the buffer is open: that's an overwrite
17558        // the frontend has to be able to prompt about, exactly as for an opened
17559        // file. Their bytes, not ours, so `Changed`.
17560        std::fs::write(&p, "theirs\n").unwrap();
17561        assert_eq!(d.disk_state(), DiskState::Changed);
17562
17563        // Saving makes the file ours and re-stamps the watermark.
17564        d.insert("ours\n");
17565        d.save();
17566        assert_eq!(d.disk_state(), DiskState::Unchanged);
17567        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
17568        let _ = std::fs::remove_file(&p);
17569    }
17570
17571    #[test]
17572    fn open_or_create_still_opens_a_file_that_is_there() {
17573        let d = doc_with("open_or_create_existing", "body\n");
17574        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
17575        assert_eq!(reopened.source, "body\n");
17576        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
17577    }
17578
17579    #[test]
17580    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
17581        // A mistyped flag or a stray argument must not become a buffer promising
17582        // to save somewhere — the same refusal `open` gives a real file.
17583        let mut p = std::env::temp_dir();
17584        p.push("leaf_test_new_bad_ext.wat");
17585        assert!(Doc::open_or_create(p).is_err());
17586        let mut none = std::env::temp_dir();
17587        none.push("leaf_test_new_no_ext");
17588        assert!(Doc::open_or_create(none).is_err());
17589    }
17590
17591    #[test]
17592    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
17593        // Opening reads nothing, so there is nothing to fail on yet; the write is
17594        // where it fails, and it says so rather than claiming a save.
17595        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
17596        let mut d = Doc::open_or_create(p).unwrap();
17597        d.insert("x");
17598        d.save();
17599        assert!(
17600            d.status.as_deref().unwrap().starts_with("save failed:"),
17601            "got {:?}",
17602            d.status
17603        );
17604        assert!(d.dirty, "it must not come away believing it saved");
17605    }
17606
17607    // ── save as ───────────────────────────────────────────────────────────────
17608
17609    /// A unique path in the temp dir that no fixture wrote — a Save As target.
17610    fn temp_path(name: &str) -> PathBuf {
17611        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
17612        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
17613        let mut p = std::env::temp_dir();
17614        p.push(format!("leaf_test_target_{name}_{seq}.md"));
17615        let _ = std::fs::remove_file(&p);
17616        p
17617    }
17618
17619    #[test]
17620    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
17621        let mut d = doc_with("save_as_move", "original\n");
17622        let old = d.path.clone();
17623        let new = temp_path("save_as_move");
17624        d.insert("edited: ");
17625        d.save_as(new.clone());
17626
17627        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
17628        assert_eq!(
17629            std::fs::read_to_string(&old).unwrap(),
17630            "original\n",
17631            "Save As doesn't touch the file it came from"
17632        );
17633        assert_eq!(d.path, new, "the document moved");
17634        assert!(!d.dirty);
17635        assert_eq!(
17636            d.status.as_deref(),
17637            Some(&*format!("saved {}", d.file_name()))
17638        );
17639
17640        // Every later save follows it, which is the whole difference from a copy.
17641        d.caret = 0;
17642        d.insert("re-");
17643        d.save();
17644        assert_eq!(
17645            std::fs::read_to_string(&new).unwrap(),
17646            "re-edited: original\n"
17647        );
17648        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
17649        let _ = std::fs::remove_file(&new);
17650    }
17651
17652    #[test]
17653    fn save_as_overwrites_an_existing_target() {
17654        // The picker already asked; asking again down here is the same question
17655        // twice, and the second one has no way to be answered.
17656        let new = temp_path("save_as_over");
17657        std::fs::write(&new, "theirs\n").unwrap();
17658        let mut d = doc_with("save_as_over", "ours\n");
17659        d.save_as(new.clone());
17660        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
17661        let _ = std::fs::remove_file(&new);
17662    }
17663
17664    #[test]
17665    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
17666        let mut d = doc_with("save_as_fail", "body\n");
17667        let old = d.path.clone();
17668        d.insert("x");
17669        // A directory that doesn't exist: the write can't land.
17670        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
17671        d.save_as(bad);
17672
17673        assert_eq!(
17674            d.path, old,
17675            "the document must not move to a file that isn't there"
17676        );
17677        assert!(d.dirty, "and must not believe it saved");
17678        assert!(
17679            d.status.as_deref().unwrap().starts_with("save failed:"),
17680            "the same failure a plain save reports, got {:?}",
17681            d.status
17682        );
17683        // The original is still the document's file, and still saveable.
17684        d.save();
17685        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
17686        assert!(!d.dirty);
17687    }
17688
17689    #[test]
17690    fn save_as_renames_without_reparsing_the_format() {
17691        // `.dj` on the name doesn't make the buffer djot: it was parsed as
17692        // Markdown and still is, and saying otherwise would be a conversion the
17693        // user never asked for (and an undo history thrown away to do it).
17694        let mut d = doc_with("save_as_format", "**b**\n");
17695        let mut new = temp_path("save_as_format");
17696        new.set_extension("dj");
17697        d.save_as(new.clone());
17698        assert_eq!(d.format_name(), "markdown");
17699        let _ = std::fs::remove_file(&new);
17700    }
17701
17702    // ── external change / reload ──────────────────────────────────────────────
17703
17704    #[test]
17705    fn an_untouched_file_reports_unchanged() {
17706        let mut d = doc_with("disk_clean", "body\n");
17707        assert_eq!(d.disk_state(), DiskState::Unchanged);
17708        // Editing the buffer is not editing the file.
17709        d.insert("x");
17710        assert_eq!(d.disk_state(), DiskState::Unchanged);
17711        assert!(d.dirty);
17712        // Saving re-stamps the watermark rather than reporting our own bytes back.
17713        d.save();
17714        assert_eq!(d.disk_state(), DiskState::Unchanged);
17715    }
17716
17717    #[test]
17718    fn a_file_written_underneath_reports_changed() {
17719        let mut d = doc_with("disk_changed", "body\n");
17720        std::fs::write(&d.path, "someone else\n").unwrap();
17721        assert_eq!(d.disk_state(), DiskState::Changed);
17722        // Dirty *and* changed is the clobber: both halves are readable, and
17723        // leaf-core takes neither side.
17724        d.insert("x");
17725        assert!(d.dirty && d.disk_state() == DiskState::Changed);
17726        // Saving anyway is allowed — the frontend asked, or chose not to.
17727        d.save();
17728        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
17729        assert_eq!(d.disk_state(), DiskState::Unchanged);
17730    }
17731
17732    #[test]
17733    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
17734        // The hash is what makes this honest: the file was written (a fresh
17735        // mtime), and nothing about the document is stale.
17736        let d = doc_with("disk_same_bytes", "body\n");
17737        std::fs::write(&d.path, "body\n").unwrap();
17738        assert_eq!(d.disk_state(), DiskState::Unchanged);
17739    }
17740
17741    #[test]
17742    fn a_deleted_file_reports_missing() {
17743        let mut d = doc_with("disk_missing", "body\n");
17744        std::fs::remove_file(&d.path).unwrap();
17745        assert_eq!(d.disk_state(), DiskState::Missing);
17746        // A save recreates it, and the document is whole again.
17747        d.save();
17748        assert_eq!(d.disk_state(), DiskState::Unchanged);
17749        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
17750    }
17751
17752    #[test]
17753    fn reload_replaces_the_document_with_the_file() {
17754        for (view, tag) in VIEWS {
17755            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
17756            d.insert("edited ");
17757            assert!(d.dirty);
17758            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
17759            d.reload();
17760
17761            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
17762            assert!(!d.dirty, "{tag}: the file is what we have");
17763            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
17764            assert_eq!(
17765                d.status.as_deref(),
17766                Some(&*format!("reloaded {}", d.file_name()))
17767            );
17768            // The reloaded tree is live, not the old parse.
17769            d.caret = d.source.find("three").unwrap();
17770            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
17771        }
17772    }
17773
17774    #[test]
17775    fn reload_clamps_the_caret_and_drops_the_selection() {
17776        let mut d = doc_with("reload_caret", "a long first line\n");
17777        d.caret = 12;
17778        d.anchor = Some(4);
17779        std::fs::write(&d.path, "short\n").unwrap();
17780        d.reload();
17781        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
17782        assert_eq!(
17783            d.anchor, None,
17784            "a selection over bytes that changed is a lie"
17785        );
17786        assert!(d.selection().is_none());
17787
17788        // A caret the file still has room for stays put.
17789        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
17790        d.caret = 2;
17791        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
17792        d.reload();
17793        assert_eq!(d.caret, 2);
17794    }
17795
17796    /// A silent reload is something that happened *to* a reader — a formatter,
17797    /// a `git checkout` — so it has to be undoable like anything else that
17798    /// changes the document, and undoable as one step rather than as however
17799    /// many the file happens to differ by.
17800    #[test]
17801    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
17802        let mut d = doc_with("reload_undo", "body\n");
17803        d.insert("x");
17804        assert_eq!(d.source, "xbody\n");
17805        std::fs::write(&d.path, "replaced\n").unwrap();
17806        d.reload();
17807        assert_eq!(d.source, "replaced\n");
17808        assert!(!d.dirty, "a reload lands clean");
17809
17810        // One ^Z takes the whole swap off, and hands back the unsaved work it
17811        // replaced — which is unsaved again, because the file no longer says it.
17812        d.undo();
17813        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
17814        assert!(d.dirty, "and what it comes back to is unsaved");
17815        // …and the history under it is still there.
17816        d.undo();
17817        assert_eq!(
17818            d.source, "body\n",
17819            "the typing before the reload undoes too"
17820        );
17821        // Redo walks back up through the reload.
17822        d.redo();
17823        d.redo();
17824        assert_eq!(d.source, "replaced\n");
17825    }
17826
17827    /// A file rewritten with the bytes it already had is not an edit, so it
17828    /// must not leave an undo step behind for something nobody did.
17829    #[test]
17830    fn reloading_identical_bytes_pushes_no_undo_step() {
17831        let mut d = doc_with("reload_same", "body\n");
17832        d.insert("x");
17833        std::fs::write(&d.path, "xbody\n").unwrap();
17834        d.reload();
17835        assert_eq!(d.source, "xbody\n");
17836        assert!(!d.dirty, "the file now says what the buffer does");
17837        d.undo();
17838        assert_eq!(
17839            d.source, "body\n",
17840            "one step back is the typing, not a no-op"
17841        );
17842    }
17843
17844    #[test]
17845    fn a_reload_that_cant_read_leaves_the_document_alone() {
17846        let mut d = doc_with("reload_gone", "body\n");
17847        d.insert("x");
17848        std::fs::remove_file(&d.path).unwrap();
17849        d.reload();
17850        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
17851        assert!(d.dirty);
17852        assert!(
17853            d.status.as_deref().unwrap().starts_with("reload failed:"),
17854            "{:?}",
17855            d.status
17856        );
17857
17858        // And an untitled document has nothing to reload from.
17859        let mut d = Doc::blank().unwrap();
17860        d.insert("typed");
17861        d.reload();
17862        assert_eq!(d.source, "typed");
17863        assert_eq!(d.status.as_deref(), Some("no file to reload"));
17864    }
17865
17866    #[test]
17867    fn a_read_only_document_refuses_every_door() {
17868        let mut d = doc_with("readonly", "one two three\n");
17869        d.insert("x");
17870        assert!(d.dirty, "writable first, so the undo step exists");
17871        d.set_read_only(true);
17872        let before = d.source.clone();
17873        d.insert("y");
17874        d.backspace();
17875        d.undo();
17876        d.redo();
17877        assert_eq!(d.source, before, "no door moved a byte");
17878        d.set_read_only(false);
17879        d.undo();
17880        assert_ne!(d.source, before, "off again, the same doors work");
17881    }
17882
17883    /// The doors that go to twig's own verbs rather than through the splice.
17884    /// Typed text in the rendered view under the default markup mode is the
17885    /// everyday one — it is what a keystroke in leaf-web or the Apple views
17886    /// becomes — and it walked straight past the gate.
17887    #[test]
17888    fn a_read_only_document_refuses_the_doors_around_the_splice() {
17889        let mut d = wysiwyg_doc(
17890            "readonly-doors",
17891            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
17892        );
17893        d.set_markup_mode(MarkupMode::None);
17894        d.set_read_only(true);
17895        let before = d.source.clone();
17896        d.place_caret(3, false);
17897        d.insert("y");
17898        d.insert_link("https://example.com");
17899        d.insert_image("a.png", "alt");
17900        d.insert_thematic_break();
17901        d.insert_footnote();
17902        d.place_caret(0, false);
17903        d.place_caret(3, true);
17904        d.toggle(InlineKind::Strong);
17905        d.toggle_heading(2);
17906        d.set_block(BlockKind::Paragraph);
17907        d.toggle_list(false);
17908        d.toggle_blockquote();
17909        d.toggle_task_item();
17910        d.newline();
17911        d.indent();
17912        d.set_code_language("rust");
17913        let in_cell = d.source.find("| c").unwrap() + 2;
17914        d.place_caret(in_cell, false);
17915        assert!(d.caret_in_table(), "the caret is in the grid");
17916        assert!(!d.cell_line_break(), "the cell break reports the refusal");
17917        assert_eq!(d.source, before, "no door moved a byte");
17918        assert!(!d.dirty, "nothing to save");
17919        d.set_read_only(false);
17920        d.place_caret(3, false);
17921        d.insert("y");
17922        assert_ne!(d.source, before, "off again, the same doors work");
17923    }
17924
17925    #[test]
17926    fn a_selection_quote_carries_its_context_on_char_boundaries() {
17927        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
17928        let start = d.source.find("exact").unwrap();
17929        d.place_caret(start, false);
17930        d.place_caret(start + "exact".len(), true);
17931        let q = d.selection_quote(3).unwrap();
17932        assert_eq!(q.exact, "exact");
17933        assert_eq!(
17934            q.prefix, "你好 ",
17935            "chars, not bytes — the multibyte pair counts as two"
17936        );
17937        assert_eq!(q.suffix, " 世界");
17938        assert_eq!(&d.source[q.start..q.end], "exact");
17939        // At the edges the context clips rather than erring.
17940        d.place_caret(0, false);
17941        d.place_caret(6, true);
17942        let q = d.selection_quote(40).unwrap();
17943        assert_eq!(q.prefix, "");
17944        assert_eq!(q.exact, "before");
17945        // No selection is no quote.
17946        d.place_caret(0, false);
17947        assert!(d.selection_quote(3).is_none());
17948    }
17949
17950    #[test]
17951    fn highlights_are_kept_sorted_and_answer_point_queries() {
17952        let mut d = doc_with("hl", "one two three\n");
17953        d.set_highlights(vec![
17954            Highlight {
17955                start: 8,
17956                end: 13,
17957                id: "b".into(),
17958                color: None,
17959                marker: None,
17960            },
17961            Highlight {
17962                start: 0,
17963                end: 3,
17964                id: "a".into(),
17965                color: Some("#ffe066".into()),
17966                marker: None,
17967            },
17968            Highlight {
17969                start: 5,
17970                end: 5,
17971                id: "empty".into(),
17972                color: None,
17973                marker: None,
17974            },
17975        ]);
17976        assert_eq!(
17977            d.highlights()
17978                .iter()
17979                .map(|h| h.id.as_str())
17980                .collect::<Vec<_>>(),
17981            ["a", "b"],
17982            "sorted by start, the empty range dropped"
17983        );
17984        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
17985        assert_eq!(d.highlight_at(3), None, "end is exclusive");
17986        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
17987        d.set_highlights(Vec::new());
17988        assert!(d.highlights().is_empty(), "a replace is a replace");
17989    }
17990
17991    /// `Highlight::covering` and the cursor over it are what both painters ask
17992    /// per glyph, so they have to answer the same as the scan they replaced —
17993    /// including in the gaps, which is where most glyphs are.
17994    #[test]
17995    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
17996        let hl = |start: usize, end: usize, id: &str| Highlight {
17997            start,
17998            end,
17999            id: id.into(),
18000            color: None,
18001            marker: None,
18002        };
18003        // Disjoint, as search hits are: in a range, in a gap, and past the end.
18004        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
18005        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
18006        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
18007        assert_eq!(
18008            Highlight::covering(&hits, 105),
18009            None,
18010            "a gap covers nothing"
18011        );
18012        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
18013        assert_eq!(Highlight::covering(&hits, 9_999), None);
18014        assert_eq!(Highlight::covering(&[], 0), None);
18015
18016        // Nested: first by start, so a hit inside an annotation still resolves
18017        // to the annotation — and the range that stops short doesn't mask it.
18018        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
18019        assert_eq!(
18020            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
18021            Some("outer")
18022        );
18023        assert_eq!(
18024            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
18025            Some("outer")
18026        );
18027    }
18028
18029    /// The cursor is an optimisation, so the only thing worth asserting is that
18030    /// it is not also a change of answer — at every offset, over a list with a
18031    /// nest in it, walked forwards and then backwards.
18032    #[test]
18033    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
18034        let hl = |start: usize, end: usize, id: &str| Highlight {
18035            start,
18036            end,
18037            id: id.into(),
18038            color: None,
18039            marker: None,
18040        };
18041        let mut list = vec![
18042            hl(0, 20, "outer"),
18043            hl(5, 10, "inner"),
18044            hl(30, 33, "hit"),
18045            hl(40, 43, "hit"),
18046        ];
18047        list.sort_by_key(|h| (h.start, h.end));
18048
18049        let mut cursor = HighlightCursor::new(&list);
18050        for offset in 0..50 {
18051            assert_eq!(
18052                cursor.at(offset).map(|h| h.id.as_str()),
18053                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
18054                "cursor disagrees at {offset}"
18055            );
18056        }
18057        // Backwards: the cursor re-seats rather than answering from where it
18058        // had got to, so a painter that revisits a row is still told the truth.
18059        for offset in (0..50).rev() {
18060            assert_eq!(
18061                cursor.at(offset).map(|h| h.id.as_str()),
18062                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
18063                "cursor disagrees walking back at {offset}"
18064            );
18065        }
18066    }
18067
18068    // ── the presentation vocabulary ─────────────────────────────────────────
18069
18070    /// A document in `format`, for the gesture tests that want more than the
18071    /// Markdown `doc_with` writes.
18072    fn fmt_doc(body: &str, format: Format) -> Doc {
18073        Doc::from_source(body.to_string(), format).unwrap()
18074    }
18075
18076    /// Alignment is a block property, so the gesture is `set_block_attrs` on
18077    /// the caret's block whatever is selected — and each format spells it its
18078    /// own way: djot's `{…}` line above the block, a `<div>` around it in
18079    /// Markdown (the format has nowhere else to put it), the tag in HTML.
18080    #[test]
18081    fn set_alignment_spells_the_class_the_format_s_own_way() {
18082        let mut dj = fmt_doc("hello\n", Format::Djot);
18083        dj.caret = 1;
18084        dj.set_alignment(Some(Align::Center));
18085        assert_eq!(dj.source, "{.center}\nhello\n");
18086        assert!(dj.dirty);
18087        assert_eq!(dj.status, None);
18088
18089        let mut md = fmt_doc("hello\n", Format::Markdown);
18090        md.caret = 1;
18091        md.set_alignment(Some(Align::Right));
18092        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
18093
18094        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
18095        html.caret = html.source.find("hello").unwrap();
18096        html.set_alignment(Some(Align::Justify));
18097        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
18098    }
18099
18100    /// Each gesture edits **one key and keeps the rest** — twig's contract is
18101    /// replace-not-merge, so leaf reads the node's attributes, edits its own
18102    /// key out of them, and passes the list back whole. A document from
18103    /// elsewhere passes through the editor unharmed.
18104    #[test]
18105    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
18106        let mut d = fmt_doc(
18107            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
18108            Format::Djot,
18109        );
18110        d.caret = d.source.find("hello").unwrap();
18111        d.set_alignment(Some(Align::Right));
18112        // `center` goes, `lead` stays, and neither the id nor the spacing is
18113        // touched.
18114        // The serializer picks the order; what matters is which keys survive.
18115        assert!(d.source.contains(".lead"), "{:?}", d.source);
18116        assert!(d.source.contains(".right"), "{:?}", d.source);
18117        assert!(!d.source.contains(".center"), "{:?}", d.source);
18118        assert!(d.source.contains("#intro"), "{:?}", d.source);
18119        assert!(
18120            d.source.contains("data-line-height=\"1.5\""),
18121            "{:?}",
18122            d.source
18123        );
18124        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
18125        assert_eq!(
18126            d.line_spacing_at_caret(),
18127            Some(LineHeight::Step(LineSpacing::OneHalf))
18128        );
18129
18130        // And the other way round: the spacing gesture leaves the classes be.
18131        d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
18132        assert!(d.source.contains(".lead"), "{:?}", d.source);
18133        assert!(d.source.contains(".right"), "{:?}", d.source);
18134        assert_eq!(
18135            d.line_spacing_at_caret(),
18136            Some(LineHeight::Step(LineSpacing::Double))
18137        );
18138    }
18139
18140    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
18141    /// owns go out of `class`, and a block left with nothing at all is spelled
18142    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
18143    #[test]
18144    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
18145        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
18146        dj.caret = dj.source.find("hello").unwrap();
18147        dj.set_alignment(None);
18148        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
18149        assert_eq!(dj.alignment_at_caret(), None);
18150
18151        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
18152        bare.caret = bare.source.find("hello").unwrap();
18153        bare.set_alignment(None);
18154        assert_eq!(
18155            bare.source, "hello\n",
18156            "the last key takes the line with it"
18157        );
18158
18159        let mut md = fmt_doc("hello\n", Format::Markdown);
18160        md.caret = 1;
18161        md.set_alignment(Some(Align::Center));
18162        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
18163        md.caret = md.source.find("hello").unwrap();
18164        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
18165        assert_eq!(
18166            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
18167            "the second key rewrites the div rather than nesting a second"
18168        );
18169        md.caret = md.source.find("hello").unwrap();
18170        md.set_alignment(None);
18171        md.caret = md.source.find("hello").unwrap();
18172        md.set_line_spacing(None);
18173        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
18174    }
18175
18176    /// Size, face and colour are the run's over a selection and the block's
18177    /// with none — so "make this paragraph larger" is a click with the caret in
18178    /// it rather than a select-all first.
18179    #[test]
18180    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
18181        // With a selection: a span, in each format's own spelling.
18182        let mut dj = fmt_doc("a big b\n", Format::Djot);
18183        dj.anchor = Some(2);
18184        dj.caret = 5;
18185        dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
18186        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
18187        assert_eq!(
18188            dj.font_size_at_caret(),
18189            Some(FontSize::Step(SizeStep::Large))
18190        );
18191
18192        let mut md = fmt_doc("a big b\n", Format::Markdown);
18193        md.anchor = Some(2);
18194        md.caret = 5;
18195        md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
18196        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
18197        assert_eq!(
18198            md.text_color_at_caret(),
18199            Some(TextColor::Named(MarkColor::Blue))
18200        );
18201
18202        // Without one: the caret's block, through the block gesture.
18203        let mut block = fmt_doc("a big b\n", Format::Djot);
18204        block.caret = 3;
18205        block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
18206        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
18207        assert_eq!(
18208            block.font_family_at_caret(),
18209            Some(FontFace::Generic(FontFamily::Monospace))
18210        );
18211    }
18212
18213    /// The *Other…* row of each of the four menus: a value goes into the
18214    /// document in its canonical spelling and comes back out of the query as
18215    /// the same value. One round trip per property, because the four go out
18216    /// through different doors — two block gestures, and the run three through
18217    /// the span that `wrap_range_attrs` mints.
18218    #[test]
18219    fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
18220        // Size: the run three, over a selection.
18221        let mut d = fmt_doc("a big b\n", Format::Djot);
18222        d.anchor = Some(2);
18223        d.caret = 5;
18224        d.set_font_size(FontSize::points(14.0));
18225        assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
18226        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
18227
18228        // Colour, onto the same span — the gesture keeps the size it finds.
18229        d.set_text_color(Some(TextColor::Rgb {
18230            r: 0xc0,
18231            g: 0x30,
18232            b: 0x30,
18233        }));
18234        assert_eq!(
18235            d.source,
18236            "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
18237        );
18238        assert_eq!(
18239            d.text_color_at_caret(),
18240            Some(TextColor::Rgb {
18241                r: 0xc0,
18242                g: 0x30,
18243                b: 0x30
18244            })
18245        );
18246
18247        // Face: a family name, as given.
18248        d.set_font_family(Some(FontFace::Named("Garamond".into())));
18249        assert!(
18250            d.source.contains("data-font=\"Garamond\""),
18251            "{:?}",
18252            d.source
18253        );
18254        assert_eq!(
18255            d.font_family_at_caret(),
18256            Some(FontFace::Named("Garamond".into()))
18257        );
18258
18259        // Line spacing: a block gesture, and an exact ratio.
18260        let mut block = fmt_doc("hello\n", Format::Djot);
18261        block.caret = 1;
18262        block.set_line_spacing(LineHeight::ratio(1.3));
18263        assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
18264        assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
18265
18266        // And a value spelled long is written back short, so the same press
18267        // twice writes the same bytes: `14.0pt` in, `14pt` out.
18268        let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
18269        long.caret = long.source.find("hello").unwrap();
18270        assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
18271        let in_force = long.font_size_at_caret();
18272        long.set_font_size(in_force);
18273        assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
18274    }
18275
18276    /// A value the grammar does not cover is what it was before the vocabulary
18277    /// opened: carried untouched by the document, answered `None` by the query
18278    /// so the menu ticks *Default*, and rewritten only by a gesture on its own
18279    /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
18280    #[test]
18281    fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
18282        let src =
18283            "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
18284        let mut d = fmt_doc(src, Format::Djot);
18285        d.caret = d.source.find("hello").unwrap();
18286        assert_eq!(d.font_size_at_caret(), None);
18287        assert_eq!(d.text_color_at_caret(), None);
18288        assert_eq!(d.line_spacing_at_caret(), None);
18289
18290        // The keys are still there, untouched, after a gesture on a *different*
18291        // key — "edit one key and keep the rest" holds for a value it cannot
18292        // read as readily as for one it can.
18293        d.set_alignment(Some(Align::Center));
18294        assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
18295        assert!(
18296            d.source.contains("data-color=\"rgb(1,2,3)\""),
18297            "{:?}",
18298            d.source
18299        );
18300        assert!(
18301            d.source.contains("data-line-height=\"1.3em\""),
18302            "{:?}",
18303            d.source
18304        );
18305        // And the gesture on its *own* key replaces it, which is the one way a
18306        // carried value ever changes.
18307        d.caret = d.source.find("hello").unwrap();
18308        d.set_font_size(FontSize::points(12.0));
18309        assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
18310        assert!(!d.source.contains("huge"), "{:?}", d.source);
18311    }
18312
18313    /// The nearest node wins whichever *form* either node wrote: a value inside
18314    /// a name, a name inside a value. The fold has one rule and does not learn
18315    /// a second one for exact values.
18316    #[test]
18317    fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
18318        // A value inside a name: the block says `small`, the span says `14pt`.
18319        let mut d = fmt_doc(
18320            "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
18321            Format::Djot,
18322        );
18323        d.caret = d.source.find('y').unwrap();
18324        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
18325        d.caret = d.source.find('x').unwrap();
18326        assert_eq!(
18327            d.font_size_at_caret(),
18328            Some(FontSize::Step(SizeStep::Small))
18329        );
18330
18331        // And a name inside a value, which is the same rule read the other way.
18332        let mut e = fmt_doc(
18333            "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
18334            Format::Djot,
18335        );
18336        e.caret = e.source.find('y').unwrap();
18337        assert_eq!(
18338            e.font_size_at_caret(),
18339            Some(FontSize::Step(SizeStep::Small))
18340        );
18341        assert_eq!(
18342            e.text_color_at_caret(),
18343            Some(TextColor::Rgb {
18344                r: 0xc0,
18345                g: 0x30,
18346                b: 0x30
18347            }),
18348            "the block's colour still reaches the span"
18349        );
18350        e.caret = e.source.find('x').unwrap();
18351        assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
18352    }
18353
18354    /// twig re-styles the span a range already lies in rather than nesting a
18355    /// second, and an empty set unwraps it — so a second press of the menu
18356    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
18357    /// means "the theme's own" takes the span away.
18358    #[test]
18359    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
18360        let mut d = fmt_doc("a big b\n", Format::Djot);
18361        d.anchor = Some(2);
18362        d.caret = 5;
18363        d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
18364        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
18365
18366        // The selection `wrap_range_attrs` left behind covers the whole span;
18367        // colouring it now keeps the size, because the gesture reads the span's
18368        // attributes before it edits its own key.
18369        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
18370        assert_eq!(
18371            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
18372            "one span, both keys"
18373        );
18374        assert_eq!(
18375            d.font_size_at_caret(),
18376            Some(FontSize::Step(SizeStep::Large))
18377        );
18378        assert_eq!(
18379            d.text_color_at_caret(),
18380            Some(TextColor::Named(MarkColor::Red))
18381        );
18382
18383        d.set_text_color(None);
18384        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
18385        d.set_font_size(None);
18386        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
18387        assert_eq!(d.font_size_at_caret(), None);
18388    }
18389
18390    /// The queries read the nearest node that names the property: the span the
18391    /// caret is in, then its block, then the `div`s around it.
18392    #[test]
18393    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
18394        let mut d = fmt_doc(
18395            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
18396            Format::Djot,
18397        );
18398        // In the span: its own size, the block's face and alignment.
18399        d.caret = d.source.find('y').unwrap();
18400        assert_eq!(
18401            d.font_size_at_caret(),
18402            Some(FontSize::Step(SizeStep::XxLarge))
18403        );
18404        assert_eq!(
18405            d.font_family_at_caret(),
18406            Some(FontFace::Generic(FontFamily::Serif))
18407        );
18408        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
18409        assert_eq!(d.line_spacing_at_caret(), None);
18410        assert_eq!(d.text_color_at_caret(), None);
18411
18412        // Outside it: the block's size.
18413        d.caret = d.source.find('x').unwrap();
18414        assert_eq!(
18415            d.font_size_at_caret(),
18416            Some(FontSize::Step(SizeStep::Small))
18417        );
18418
18419        // And through a Markdown div, which is where a Markdown block's
18420        // attributes live.
18421        let mut md = fmt_doc(
18422            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
18423            Format::Markdown,
18424        );
18425        md.caret = md.source.find("hello").unwrap();
18426        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
18427        assert_eq!(
18428            md.font_size_at_caret(),
18429            Some(FontSize::Step(SizeStep::Large))
18430        );
18431
18432        // A document that names none of it answers `None` everywhere, which is
18433        // "the theme's own" and what every toolbar draws unlit.
18434        let mut plain = doc_with("plain_presentation", "hello\n");
18435        plain.caret = 1;
18436        assert_eq!(plain.alignment_at_caret(), None);
18437        assert_eq!(plain.line_spacing_at_caret(), None);
18438        assert_eq!(plain.font_size_at_caret(), None);
18439        assert_eq!(plain.font_family_at_caret(), None);
18440        assert_eq!(plain.text_color_at_caret(), None);
18441    }
18442
18443    /// A djot fenced div is anonymous the way an attributed span is, and is a
18444    /// block all the same — the *form* is the whole of what tells them apart.
18445    /// Read as a span it poisoned both halves: the run gesture copied the div's
18446    /// entire attribute set onto the span it minted, duplicating the `id`, and
18447    /// the run and block queries answered off a node the walker draws nothing
18448    /// for.
18449    #[test]
18450    fn a_djot_fenced_div_is_not_an_attributed_span() {
18451        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
18452        let mut d = fmt_doc(src, Format::Djot);
18453        let at = d.source.find("world").unwrap();
18454        d.anchor = Some(at);
18455        d.caret = at + "world".len();
18456        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
18457        assert_eq!(
18458            d.source,
18459            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
18460            "the span carries its own key and nothing of the div's"
18461        );
18462
18463        // And the queries stop at the block: a djot div is not a `<div>`, the
18464        // walker lends its keys to nothing inside it, and a query that said
18465        // otherwise would tick a menu entry no glyph on screen obeys.
18466        assert_eq!(
18467            d.text_color_at_caret(),
18468            Some(TextColor::Named(MarkColor::Red))
18469        );
18470        assert_eq!(d.font_size_at_caret(), None);
18471        assert_eq!(d.alignment_at_caret(), None);
18472    }
18473
18474    /// Clearing a property the block does not name and a `div` around it does
18475    /// would write nothing and change nothing — twig's `set_block_attrs`
18476    /// reaches one node, and the div is not it. The gesture says so instead of
18477    /// leaving the author pressing an entry that never ticks.
18478    #[test]
18479    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
18480        // Markdown, two paragraphs in one div: not the sole-child shape twig
18481        // writes, so `block_attrs_at_caret` reads the paragraph and the
18482        // paragraph names none of it.
18483        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
18484        let mut md = fmt_doc(src, Format::Markdown);
18485        md.caret = md.source.find("hello").unwrap();
18486        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
18487
18488        md.set_alignment(None);
18489        assert_eq!(md.source, src, "nothing written");
18490        assert!(!md.dirty);
18491        assert_eq!(
18492            md.status.as_deref(),
18493            Some("alignment: set on the div around the block")
18494        );
18495        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
18496
18497        // The same for a `data-` key, at both levels — the block pair and the
18498        // run three, the run three at a bare caret being the block gesture.
18499        md.set_line_spacing(None);
18500        assert_eq!(md.source, src);
18501        assert_eq!(
18502            md.status.as_deref(),
18503            Some("line spacing: set on the div around the block")
18504        );
18505        md.set_font_size(None);
18506        assert_eq!(md.source, src);
18507        assert_eq!(
18508            md.status.as_deref(),
18509            Some("size: set on the div around the block")
18510        );
18511
18512        // HTML has no sole-child fold at all: a block's attributes go on the
18513        // block, so the div around one is always out of reach.
18514        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
18515        let mut html = fmt_doc(html_src, Format::Html);
18516        html.caret = html.source.find("hi").unwrap();
18517        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
18518        html.set_alignment(None);
18519        assert_eq!(html.source, html_src);
18520        assert!(!html.dirty);
18521        assert_eq!(
18522            html.status.as_deref(),
18523            Some("alignment: set on the div around the block")
18524        );
18525
18526        // And it is a refusal, not a rule against clearing: a block that names
18527        // the property itself still loses it, div or no div.
18528        let mut own = fmt_doc(
18529            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
18530            Format::Html,
18531        );
18532        own.caret = own.source.find("hi").unwrap();
18533        own.set_alignment(None);
18534        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
18535        assert_eq!(own.status, None);
18536    }
18537
18538    /// An edited key is rewritten **where it stands**. The proposal's worked
18539    /// example is the test: a paragraph that came in as `id="intro"
18540    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
18541    /// out as the same list with one token changed. Removing the key and
18542    /// pushing it back shuffled a document's attributes on every press.
18543    #[test]
18544    fn an_edited_key_keeps_its_place_among_the_attributes() {
18545        let mut html = fmt_doc(
18546            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
18547            Format::Html,
18548        );
18549        html.caret = html.source.find("hello").unwrap();
18550        html.set_alignment(Some(Align::Right));
18551        assert_eq!(
18552            html.source,
18553            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
18554        );
18555
18556        // A `data-` key the same way, and a key the block did not have still
18557        // goes on the end.
18558        html.caret = html.source.find("hello").unwrap();
18559        html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
18560        assert_eq!(
18561            html.source,
18562            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
18563        );
18564        html.caret = html.source.find("hello").unwrap();
18565        html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
18566        assert_eq!(
18567            html.source,
18568            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
18569        );
18570
18571        // Djot writes the same list in its own spelling, and the order is the
18572        // author's there too.
18573        let mut dj = fmt_doc(
18574            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
18575            Format::Djot,
18576        );
18577        dj.caret = dj.source.find("hello").unwrap();
18578        dj.set_alignment(Some(Align::Right));
18579        assert_eq!(
18580            dj.source,
18581            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
18582        );
18583    }
18584
18585    /// A page break is a block, so twig alone lands one after the caret's whole
18586    /// block; the paragraph is parted at the caret first, exactly as
18587    /// `insert_thematic_break` parts it, and each format spells the directive
18588    /// its own way.
18589    #[test]
18590    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
18591        let mut md = doc_with("page_break_md", "hello world\n");
18592        md.caret = 5;
18593        md.insert_page_break();
18594        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
18595        assert!(md.dirty);
18596        assert_eq!(md.status, None);
18597
18598        let mut dj = fmt_doc("hello world\n", Format::Djot);
18599        dj.caret = 5;
18600        dj.insert_page_break();
18601        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
18602
18603        // At a block's end there is no second half to mint, so the break simply
18604        // follows the block — the rule the rule button already has.
18605        let mut end = doc_with("page_break_end", "hello\n");
18606        end.caret = 5;
18607        end.insert_page_break();
18608        assert_eq!(end.source, "hello\n\n::page-break\n");
18609
18610        // And it reaches the map as the placeholder row a frontend paginates on.
18611        end.view = View::Wysiwyg;
18612        end.build_visual(80);
18613        assert_eq!(
18614            end.vmap
18615                .rows
18616                .iter()
18617                .find_map(|r| r.leaf_directive.as_ref())
18618                .map(|m| m.name.as_str()),
18619            Some(PAGE_BREAK)
18620        );
18621
18622        // HTML and AsciiDoc spell it their own way, and draw it the same.
18623        for (fmt, src, spelled) in [
18624            (Format::Html, "<p>hello</p>\n", "<page-break></page-break>"),
18625            (Format::Asciidoc, "hello\n", "<<<"),
18626        ] {
18627            let mut d = fmt_doc(src, fmt);
18628            d.caret = d.source.find("hello").unwrap() + 5;
18629            d.insert_page_break();
18630            assert!(d.source.contains(spelled), "{fmt:?}: {:?}", d.source);
18631            d.view = View::Wysiwyg;
18632            d.build_visual(80);
18633            let marks = d
18634                .vmap
18635                .rows
18636                .iter()
18637                .filter_map(|r| r.leaf_directive.as_ref())
18638                .map(|m| m.name.as_str())
18639                .collect::<Vec<_>>();
18640            assert_eq!(marks, [PAGE_BREAK], "{fmt:?}");
18641        }
18642    }
18643
18644    /// The vocabulary's capabilities, per format. The two block properties are
18645    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
18646    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
18647    /// keeps an id and a role and has no slot for a `data-` key.
18648    #[test]
18649    fn the_presentation_capabilities_are_ragged_per_format() {
18650        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
18651            let c = Capabilities::of(fmt);
18652            assert!(c.alignment, "{fmt:?} alignment");
18653            assert!(c.line_spacing, "{fmt:?} line spacing");
18654            assert!(c.font_size, "{fmt:?} size");
18655            assert!(c.font_family, "{fmt:?} face");
18656            assert!(c.text_color, "{fmt:?} colour");
18657        }
18658        // Markdown spells both only under the extensions leaf parses with — a
18659        // `<div>` and a `<span>` read back as containers under `html_elements`,
18660        // and `::page-break` as a directive under `directives`. Ask twig's own
18661        // defaults and the answer is no, which is why `Capabilities` is built
18662        // with `supports_with`.
18663        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
18664        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
18665        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
18666
18667        let adoc = Capabilities::of(Format::Asciidoc);
18668        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
18669        assert!(
18670            !adoc.font_size && !adoc.font_family && !adoc.text_color,
18671            "AsciiDoc has no inline spelling that keeps a data- key"
18672        );
18673
18674        // XML spells none of it, and neither page break — nor has it blocks a
18675        // caret could name for a move.
18676        let xml = Capabilities::of(Format::Xml);
18677        assert!(!xml.alignment && !xml.font_size && !xml.page_break && !xml.move_block);
18678        for f in [Format::Markdown, Format::Djot, Format::Html] {
18679            assert!(Capabilities::of(f).move_block, "{f:?} moves blocks");
18680        }
18681        for f in [
18682            Format::Markdown,
18683            Format::Djot,
18684            Format::Html,
18685            Format::Asciidoc,
18686        ] {
18687            assert!(Capabilities::of(f).page_break, "{f:?} breaks pages");
18688        }
18689    }
18690
18691    /// A format that cannot spell a property refuses in its own words and
18692    /// writes nothing — the guard every other gesture has.
18693    #[test]
18694    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
18695        let src = "<doc><p>hello</p></doc>\n";
18696        #[allow(clippy::type_complexity)]
18697        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
18698            ("alignment", &|d: &mut Doc| {
18699                d.set_alignment(Some(Align::Center))
18700            }),
18701            ("line spacing", &|d: &mut Doc| {
18702                d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
18703            }),
18704            ("size", &|d: &mut Doc| {
18705                d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
18706            }),
18707            ("face", &|d: &mut Doc| {
18708                d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
18709            }),
18710            ("colour", &|d: &mut Doc| {
18711                d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
18712            }),
18713            ("page break", &|d: &mut Doc| d.insert_page_break()),
18714        ];
18715        for (name, op) in ops {
18716            let mut d = fmt_doc(src, Format::Xml);
18717            let at = d.source.find("hello").unwrap();
18718            d.caret = at;
18719            d.anchor = Some(at + 5);
18720            op(&mut d);
18721            assert_eq!(d.source, src, "{name} edited an XML document");
18722            assert!(!d.dirty, "{name} marked the document dirty");
18723            let status = d.status.as_deref().unwrap_or("");
18724            assert!(
18725                status.contains("xml"),
18726                "{name}: the refusal should name the format, got {status:?}"
18727            );
18728        }
18729
18730        // AsciiDoc is the ragged one: the block gesture works where the run
18731        // gesture does not, and a *selection* is what tells the two apart.
18732        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
18733        adoc.anchor = Some(0);
18734        adoc.caret = 5;
18735        adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
18736        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
18737        assert!(adoc.status.is_some());
18738    }
18739
18740    /// A read-only document takes none of it, and a caret on a blank line has
18741    /// no block to carry an attribute — both say so rather than writing.
18742    #[test]
18743    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
18744        let mut ro = fmt_doc("hello\n", Format::Djot);
18745        ro.read_only = true;
18746        ro.caret = 1;
18747        ro.set_alignment(Some(Align::Center));
18748        assert_eq!(ro.source, "hello\n");
18749
18750        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
18751        blank.caret = 2; // the empty line between the two paragraphs
18752        blank.set_alignment(Some(Align::Center));
18753        assert_eq!(blank.source, "a\n\n\nb\n");
18754        assert!(
18755            blank.status.as_deref().unwrap_or("").contains("no block"),
18756            "got {:?}",
18757            blank.status
18758        );
18759    }
18760
18761    /// A block attribute gesture keeps the caret on the **text** it was on, not
18762    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
18763    /// attributes but a `<div>` around it, and twig splices the div and the
18764    /// block it wraps as one region — so a caret that kept its offset landed in
18765    /// the markup, and every press after the first answered "no block at the
18766    /// caret" with the toolbar's queries reading nothing.
18767    #[test]
18768    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
18769        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
18770        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
18771        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
18772
18773        // Wrapping: the div and two blank lines open above the block.
18774        md.set_alignment(Some(Align::Center));
18775        assert_eq!(
18776            md.source,
18777            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
18778        );
18779        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
18780        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
18781
18782        // Re-styling: the attribute line changes length under the same caret,
18783        // and the second press reaches the same block rather than nothing.
18784        md.set_alignment(Some(Align::Right));
18785        assert_eq!(
18786            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
18787            "a second press re-styles the div"
18788        );
18789        assert_eq!(md.status, None);
18790        assert_eq!(word(&md), 2);
18791
18792        // A second key on the same div — the line grows, the caret rides it.
18793        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
18794        assert_eq!(
18795            md.source,
18796            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
18797        );
18798        assert_eq!(word(&md), 2);
18799        assert_eq!(
18800            md.line_spacing_at_caret(),
18801            Some(LineHeight::Step(LineSpacing::Double))
18802        );
18803
18804        // Unwrapping: the line shrinks, and then the div goes altogether.
18805        md.set_alignment(None);
18806        assert_eq!(
18807            md.source,
18808            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
18809        );
18810        assert_eq!(word(&md), 2);
18811        md.set_line_spacing(None);
18812        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
18813        assert_eq!(word(&md), 2, "the caret came back down with the block");
18814        assert_eq!(md.alignment_at_caret(), None);
18815        assert_eq!(md.status, None);
18816    }
18817
18818    /// The same rule in djot, where the spelling is a `{…}` line *above* the
18819    /// block rather than a wrapper around it: inserting it pushes the block
18820    /// down, re-styling it changes the line's length, and clearing the last key
18821    /// takes the line away again. The caret rides all three.
18822    #[test]
18823    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
18824        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
18825        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
18826        dj.caret = dj.source.find("brown").unwrap() + 2;
18827
18828        dj.set_alignment(Some(Align::Center));
18829        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
18830        assert_eq!(word(&dj), 2);
18831        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
18832
18833        dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
18834        assert_eq!(
18835            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
18836            "a second press edits the line the first wrote"
18837        );
18838        assert_eq!(word(&dj), 2);
18839
18840        dj.set_alignment(None);
18841        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
18842        assert_eq!(word(&dj), 2);
18843
18844        dj.set_line_spacing(None);
18845        assert_eq!(dj.source, "the quick brown fox\n");
18846        assert_eq!(word(&dj), 2);
18847        assert_eq!(dj.status, None);
18848    }
18849
18850    /// The run gestures with no selection are the block gesture, so they keep
18851    /// the caret the same way — and a heading keeps it inside the heading's own
18852    /// text, past the `# ` its content span starts after. A selection rides
18853    /// along whole: a block gesture is not a run gesture, and what was selected
18854    /// before the press is still selected after it.
18855    #[test]
18856    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
18857        // No selection: the run gesture goes through the block door.
18858        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
18859        md.caret = md.source.find("brown").unwrap() + 2;
18860        md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
18861        assert_eq!(
18862            md.source,
18863            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
18864        );
18865        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
18866        assert_eq!(
18867            md.font_size_at_caret(),
18868            Some(FontSize::Step(SizeStep::Large))
18869        );
18870        md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
18871        assert_eq!(
18872            md.font_size_at_caret(),
18873            Some(FontSize::Step(SizeStep::Small)),
18874            "the second press reached the same block"
18875        );
18876
18877        // A selection: alignment is the block's whatever is selected, and the
18878        // words stay selected.
18879        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
18880        let at = sel.source.find("brown").unwrap();
18881        sel.anchor = Some(at);
18882        sel.caret = at + 5;
18883        sel.set_alignment(Some(Align::Center));
18884        let now = sel.source.find("brown").unwrap();
18885        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
18886
18887        // A heading: the content span starts past the `# `.
18888        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
18889        h.caret = h.source.find("there").unwrap() + 1;
18890        h.set_alignment(Some(Align::Right));
18891        assert_eq!(
18892            h.source,
18893            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
18894        );
18895        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
18896        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
18897    }
18898
18899    /// A djot document open in the rich view, with its map built as
18900    /// [`wysiwyg_doc`] builds a Markdown one's.
18901    fn wysiwyg_djot(body: &str) -> Doc {
18902        let mut d = fmt_doc(body, Format::Djot);
18903        d.view = View::Wysiwyg;
18904        d.build_visual(80);
18905        d
18906    }
18907
18908    /// Backspace at the start of a block whose presentation is spelled as
18909    /// hidden markup before it strips that presentation, the way Backspace at
18910    /// a heading's start strips its `#`. The ordinary delete fused djot's
18911    /// `{.center}` line onto the text and took the blank line a Markdown div
18912    /// needs between its tag and its paragraph.
18913    #[test]
18914    fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
18915        let mut md = wysiwyg_doc(
18916            "wys_attr_bksp",
18917            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
18918        );
18919        md.caret = md.source.find("hello").unwrap();
18920        md.backspace();
18921        assert_eq!(
18922            md.source, "above\n\nhello\n\nbelow\n",
18923            "the div is unwrapped"
18924        );
18925        assert_eq!(md.caret, 7, "the caret stays at the start of its text");
18926        md.backspace();
18927        assert_eq!(
18928            md.source, "above\nhello\n\nbelow\n",
18929            "the next press joins the paragraphs, as it always did"
18930        );
18931
18932        let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
18933        dj.caret = dj.source.find("hello").unwrap();
18934        dj.backspace();
18935        assert_eq!(
18936            dj.source, "above\n\nhello\n\nbelow\n",
18937            "the attribute line goes"
18938        );
18939        assert_eq!(dj.caret, 7);
18940
18941        // A heading's own marker is the nearer hidden markup, and goes first;
18942        // the attributes are the next press's.
18943        let mut dj = wysiwyg_djot("{.center}\n# Title\n");
18944        dj.caret = dj.source.find("Title").unwrap();
18945        dj.backspace();
18946        assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
18947        dj.build_visual(80);
18948        dj.backspace();
18949        assert_eq!(dj.source, "Title\n", "then the attributes");
18950        assert_eq!(dj.caret, 0);
18951    }
18952
18953    /// A div around several blocks has no sole child for twig to unwrap, so
18954    /// at its first block the caret steps back to the stop before rather than
18955    /// taking the div apart; a later block has an ordinary paragraph above it
18956    /// and joins as any paragraph does.
18957    #[test]
18958    fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
18959        let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
18960        let mut d = wysiwyg_doc("wys_div_first", src);
18961        d.caret = d.source.find("hello").unwrap();
18962        d.backspace();
18963        assert_eq!(d.source, src, "nothing is deleted");
18964        assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
18965
18966        let mut d = wysiwyg_doc("wys_div_later", src);
18967        d.caret = d.source.find("world").unwrap();
18968        d.backspace();
18969        assert_eq!(
18970            d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
18971            "a later block joins the one above it"
18972        );
18973    }
18974
18975    /// Backspace at the start of the paragraph after a Markdown div joins it
18976    /// into the div's last paragraph — the join any two paragraphs make, with
18977    /// the hidden `</div>` carried past the joined text. The ordinary delete
18978    /// took the newline under the tag, which drew nothing different, and the
18979    /// next press took the `>` and left the div unclosed.
18980    #[test]
18981    fn backspace_after_a_div_joins_the_paragraph_into_it() {
18982        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
18983        let mut d = wysiwyg_doc("wys_div_join", src);
18984        d.caret = d.source.find("below").unwrap();
18985        d.backspace();
18986        assert_eq!(
18987            d.source,
18988            "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
18989        );
18990        assert_eq!(
18991            d.caret,
18992            d.source.find("below").unwrap(),
18993            "the caret stays at the start of the joined text"
18994        );
18995        d.build_visual(80);
18996        assert_eq!(
18997            d.alignment_at_caret(),
18998            Some(Align::Center),
18999            "and is centred now"
19000        );
19001        d.undo();
19002        assert_eq!(d.source, src, "one undo step");
19003
19004        // A list closes the div: the paragraph joins the last item's text,
19005        // under the item's continuation indent, inside the div.
19006        let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
19007        let mut d = wysiwyg_doc("wys_div_list", src);
19008        d.caret = d.source.find("below").unwrap();
19009        d.backspace();
19010        assert_eq!(
19011            d.source, "<div class=\"center\">\n\n- item\n  below\n\n</div>\n",
19012            "the paragraph joins the item"
19013        );
19014        assert_eq!(d.caret, d.source.find("below").unwrap());
19015
19016        // And where twig has nothing to join into — a code block above — the
19017        // caret steps back to the stop before, and nothing is deleted.
19018        let src = "```\ncode\n```\n\nbelow\n";
19019        let mut d = wysiwyg_doc("wys_code_then_para", src);
19020        d.caret = d.source.find("below").unwrap();
19021        d.backspace();
19022        assert_eq!(d.source, src, "nothing is deleted");
19023        assert!(
19024            d.caret < d.source.find("below").unwrap(),
19025            "the caret stepped back"
19026        );
19027    }
19028
19029    /// Backspace on a blank line collapses to the stop before it — but not
19030    /// across hidden markup, which that collapse deleted whole: a `</div>`,
19031    /// or a comment between two blocks. There the blank line goes alone, and
19032    /// the caret lands where the collapse would have put it.
19033    #[test]
19034    fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
19035        let mut d = wysiwyg_doc(
19036            "wys_div_blank",
19037            "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
19038        );
19039        d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
19040        assert!(
19041            d.vmap.is_stop(d.caret),
19042            "the empty paragraph is a caret home"
19043        );
19044        d.backspace();
19045        assert_eq!(
19046            d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
19047            "the blank line goes and the div stays closed"
19048        );
19049        assert_eq!(
19050            d.caret,
19051            d.source.find("hello").unwrap() + 5,
19052            "onto the end of `hello`"
19053        );
19054
19055        let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
19056        d.caret = d.source.find("below").unwrap() - 2;
19057        assert!(d.vmap.is_stop(d.caret));
19058        d.backspace();
19059        assert_eq!(
19060            d.source, "above\n\n<!-- note -->\n\nbelow\n",
19061            "the comment stays"
19062        );
19063        assert_eq!(d.caret, 5);
19064    }
19065
19066    /// Backspace at the end of an attributed span steps inside its hidden
19067    /// closing tag the way it steps inside a `**`, and takes the span with
19068    /// its last letter. Before, the byte-step took the `>` of `</span>`,
19069    /// which left the paragraph unparseable: it vanished from the rich view,
19070    /// and the Backspace after that joined the next block into the wreck.
19071    #[test]
19072    fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
19073        let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
19074        let mut d = wysiwyg_doc("wys_span_bs", src);
19075        d.caret = d.source.find("a test").unwrap() + 6;
19076        for _ in 0..7 {
19077            d.backspace();
19078        }
19079        assert_eq!(
19080            d.source,
19081            "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
19082        );
19083        d.backspace();
19084        assert_eq!(
19085            d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
19086            "the first Backspace after the tag takes the letter, not the `>`"
19087        );
19088        d.backspace();
19089        assert_eq!(
19090            d.source, "above\n\nThis \n\nTest 2\n",
19091            "the last letter takes the span with it"
19092        );
19093        assert_eq!(d.caret, 12, "the caret is where the letter was");
19094        d.backspace();
19095        assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
19096        assert_eq!(d.caret, 11);
19097        d.build_visual(80);
19098        assert!(
19099            d.vmap
19100                .rows
19101                .iter()
19102                .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
19103            "the paragraph is still drawn"
19104        );
19105    }
19106
19107    #[test]
19108    fn backspace_walks_into_a_djot_sized_span_too() {
19109        let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
19110        d.caret = d.source.find("\n\nTest 2").unwrap();
19111        // The caret home at the paragraph's end is inside the span, before
19112        // its `]`: the map offers no stop after `]{…}`.
19113        d.build_visual(80);
19114        assert_eq!(d.vmap.stop_before(31), Some(8));
19115        d.caret = 8;
19116        d.backspace();
19117        assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
19118        d.backspace();
19119        assert_eq!(
19120            d.source, "This \n\nTest 2\n",
19121            "the attribute block outside the span goes with it"
19122        );
19123        d.backspace();
19124        assert_eq!(d.source, "This\n\nTest 2\n");
19125        assert_eq!(d.caret, 4);
19126    }
19127
19128    /// A span that is empty as the file was written has no stop of its own;
19129    /// Backspace reaching it from behind takes it with the character before
19130    /// it, the character the key looked aimed at.
19131    #[test]
19132    fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
19133        let src = "This <span data-size=\"x-large\"></span> a test\n";
19134        let mut d = wysiwyg_doc("wys_span_empty", src);
19135        d.caret = d.source.find(" a test").unwrap();
19136        d.backspace();
19137        assert_eq!(d.source, "This a test\n");
19138        assert_eq!(d.caret, 4);
19139    }
19140
19141    /// The mirror: Delete in front of a span's opening tag takes its first
19142    /// letter, and the span with its last.
19143    #[test]
19144    fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
19145        let src = "This <span data-size=\"x-large\">is</span> a test\n";
19146        let mut d = wysiwyg_doc("wys_span_del", src);
19147        d.caret = 5;
19148        d.delete_forward();
19149        assert_eq!(
19150            d.source,
19151            "This <span data-size=\"x-large\">s</span> a test\n"
19152        );
19153        d.delete_forward();
19154        assert_eq!(d.source, "This  a test\n");
19155        assert_eq!(d.caret, 5);
19156        d.delete_forward();
19157        assert_eq!(d.source, "This a test\n");
19158        assert_eq!(d.caret, 5);
19159    }
19160
19161    /// The block version of the span's emptying rule. Centre a one-letter
19162    /// paragraph — Markdown spells that as a `<div>` around it — and
19163    /// Backspace the letter: the div goes with it, leaving a plain blank line
19164    /// the caret is at home on, in the incremental map and the from-scratch
19165    /// one alike. Before, the letter went alone; the emptied div drew a
19166    /// caret home only the stale map had, and the next Backspace collapsed
19167    /// the line and left `<div class="center">\n\n</div>` standing invisibly
19168    /// in the file.
19169    #[test]
19170    fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
19171        let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
19172        d.caret = d.source.len() - 1;
19173        d.set_alignment(Some(Align::Center));
19174        assert_eq!(
19175            d.source,
19176            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
19177        );
19178        d.backspace();
19179        assert_eq!(d.source, "Try the toolbar.\n\n\n");
19180        assert_eq!(d.caret, 18, "on the blank line where the letter was");
19181        // The host rebuilds the map after every key; the spliced map and a
19182        // fresh one both give the line a caret home.
19183        d.build_visual_unwrapped();
19184        assert!(d.vmap.is_stop(18));
19185        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
19186        d.backspace();
19187        assert_eq!(
19188            d.source, "Try the toolbar.\n",
19189            "then the blank line collapses"
19190        );
19191        assert_eq!(d.caret, 16);
19192
19193        // With a block after the div the blank line keeps a gap each side.
19194        let mut d = wysiwyg_doc(
19195            "wys_div_empty_mid",
19196            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
19197        );
19198        d.caret = d.source.find("T\n").unwrap() + 1;
19199        d.backspace();
19200        assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
19201        assert_eq!(d.caret, 18);
19202        d.build_visual_unwrapped();
19203        assert!(d.vmap.is_stop(18));
19204        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
19205    }
19206
19207    /// djot spells the same paragraph as a `{.center}` line above it, and
19208    /// twig has no node at all for that line once the paragraph is gone —
19209    /// so the line goes with the letter too.
19210    #[test]
19211    fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
19212        let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
19213        d.build_visual(80);
19214        d.caret = d.source.len() - 1;
19215        d.set_alignment(Some(Align::Center));
19216        assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
19217        d.backspace();
19218        assert_eq!(d.source, "Try the toolbar.\n\n\n");
19219        assert_eq!(d.caret, 18);
19220        d.build_visual(80);
19221        assert!(d.vmap.is_stop(18));
19222    }
19223
19224    /// The rule is for a block that would be no block: a div holding more
19225    /// keeps its tags, and an emptied heading is still a heading.
19226    #[test]
19227    fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
19228        let mut d = wysiwyg_doc(
19229            "wys_div_more",
19230            "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
19231        );
19232        d.caret = d.source.find("T\n").unwrap() + 1;
19233        d.backspace();
19234        assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
19235        assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
19236
19237        let mut d = wysiwyg_doc(
19238            "wys_div_heading",
19239            "<div class=\"center\">\n\n# T\n\n</div>\n",
19240        );
19241        d.caret = d.source.find("T\n").unwrap() + 1;
19242        d.backspace();
19243        assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
19244        assert_eq!(d.caret, 24);
19245        d.build_visual(80);
19246        assert!(
19247            d.vmap.is_stop(24),
19248            "the empty heading is still a caret home"
19249        );
19250    }
19251
19252    /// The mirror: Delete in front of the letter takes the div with it.
19253    #[test]
19254    fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
19255        let mut d = wysiwyg_doc(
19256            "wys_div_empty_del",
19257            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
19258        );
19259        d.caret = d.source.find("T\n").unwrap();
19260        d.delete_forward();
19261        assert_eq!(d.source, "Try the toolbar.\n\n\n");
19262        assert_eq!(d.caret, 18);
19263        d.build_visual(80);
19264        assert!(d.vmap.is_stop(18));
19265
19266        let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
19267        d.build_visual(80);
19268        d.caret = d.source.find("T\n").unwrap();
19269        d.delete_forward();
19270        assert_eq!(d.source, "Try the toolbar.\n\n\n");
19271        assert_eq!(d.caret, 18);
19272    }
19273
19274    /// Backspace at a block's start is twig's join, spelled per format — so
19275    /// the cases the one-newline delete got wrong come out right: a
19276    /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
19277    /// and a quote's prefix is written on the joined line.
19278    #[test]
19279    fn backspace_at_a_block_start_joins_it_the_format_s_way() {
19280        let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
19281        d.caret = d.source.find("below").unwrap();
19282        d.backspace();
19283        assert_eq!(d.source, "# Title below\n", "onto the heading's line");
19284        assert_eq!(d.caret, d.source.find("below").unwrap());
19285        d.undo();
19286        assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
19287
19288        let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
19289        d.caret = d.source.find('b').unwrap();
19290        d.backspace();
19291        assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
19292        assert_eq!(d.caret, d.source.find('b').unwrap());
19293
19294        let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
19295        h.view = View::Wysiwyg;
19296        h.build_visual(80);
19297        h.caret = h.source.find("below").unwrap();
19298        h.backspace();
19299        assert_eq!(
19300            h.source, "<p>above\nbelow</p>\n",
19301            "one paragraph, the tag gone whole"
19302        );
19303        assert_eq!(h.caret, h.source.find("below").unwrap());
19304    }
19305
19306    /// Delete at the end of a block's content is the same join aimed at the
19307    /// block after it, and the caret stays where the joined text now begins.
19308    #[test]
19309    fn delete_at_a_block_end_joins_the_next_block_into_it() {
19310        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
19311        let mut d = wysiwyg_doc("wys_del_join", src);
19312        d.caret = d.source.find("hello").unwrap() + 5;
19313        d.delete_forward();
19314        assert_eq!(
19315            d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
19316            "below joins hello inside the div"
19317        );
19318        assert_eq!(
19319            d.caret,
19320            d.source.find("hello").unwrap() + 5,
19321            "the caret stays"
19322        );
19323
19324        let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
19325        d.caret = 5;
19326        d.delete_forward();
19327        assert_eq!(
19328            d.source, "above\nTitle\n",
19329            "the heading's marker goes with the join"
19330        );
19331        assert_eq!(d.caret, 5);
19332
19333        // A code block after the paragraph: nothing to join, the caret steps
19334        // forward onto the next stop and nothing is deleted.
19335        let src = "above\n\n```\ncode\n```\n";
19336        let mut d = wysiwyg_doc("wys_del_code", src);
19337        d.caret = 5;
19338        d.delete_forward();
19339        assert_eq!(d.source, src);
19340        assert!(d.caret > 5, "the caret stepped forward");
19341    }
19342
19343    // ── Text statistics ──────────────────────────────────────────────────────
19344
19345    /// The counts of `body`, from a document open in the WYSIWYG view — the
19346    /// shape every case below starts from.
19347    fn counts_of(name: &str, body: &str) -> TextCounts {
19348        doc_in(View::Wysiwyg, name, body).counts()
19349    }
19350
19351    #[test]
19352    fn counts_tally_plain_prose() {
19353        let c = counts_of(
19354            "counts_prose",
19355            "The quick brown fox jumps over the lazy dog.\n",
19356        );
19357        assert_eq!(
19358            c,
19359            TextCounts {
19360                words: 9,
19361                characters: 44,
19362                characters_without_spaces: 36,
19363                paragraphs: 1,
19364            }
19365        );
19366    }
19367
19368    #[test]
19369    fn counts_read_the_text_and_not_the_markup() {
19370        // The `**` are four bytes of source and no part of the word.
19371        assert_eq!(
19372            counts_of("counts_marks", "a **bold** word\n"),
19373            TextCounts {
19374                words: 3,
19375                characters: 11,
19376                characters_without_spaces: 9,
19377                paragraphs: 1,
19378            }
19379        );
19380        // A link is its label; the destination is plumbing, however long.
19381        assert_eq!(
19382            counts_of(
19383                "counts_link",
19384                "see [the label](https://example.com/a/b/c) here\n"
19385            ),
19386            TextCounts {
19387                words: 4,
19388                characters: 18,
19389                characters_without_spaces: 15,
19390                paragraphs: 1,
19391            }
19392        );
19393    }
19394
19395    #[test]
19396    fn counts_spend_nothing_on_a_picture() {
19397        // A block image renders as a `🖼 alt` placeholder — a picture, not a
19398        // sentence, and not a paragraph either.
19399        assert_eq!(
19400            counts_of("counts_image", "![a long caption](pic.png)\n"),
19401            TextCounts::default()
19402        );
19403        // And it adds nothing to the prose around it.
19404        assert_eq!(
19405            counts_of("counts_image_prose", "text\n\n![a long caption](pic.png)\n"),
19406            TextCounts {
19407                words: 1,
19408                characters: 4,
19409                characters_without_spaces: 4,
19410                paragraphs: 1,
19411            }
19412        );
19413    }
19414
19415    #[test]
19416    fn counts_spend_nothing_on_drawn_furniture() {
19417        // A thematic break is drawn, not written, and an empty paragraph has
19418        // nothing in it — neither is a paragraph of the document.
19419        assert_eq!(
19420            counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
19421            TextCounts {
19422                words: 2,
19423                characters: 6,
19424                characters_without_spaces: 6,
19425                paragraphs: 2,
19426            }
19427        );
19428    }
19429
19430    #[test]
19431    fn counts_measure_characters_as_a_reader_does() {
19432        // Four Han characters (each its own word under UAX#29), one ZWJ emoji
19433        // family that is a single grapheme cluster, and two letters.
19434        let c = counts_of(
19435            "counts_graphemes",
19436            "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
19437        );
19438        assert_eq!(
19439            c,
19440            TextCounts {
19441                words: 5,
19442                characters: 9,
19443                characters_without_spaces: 7,
19444                paragraphs: 1,
19445            }
19446        );
19447    }
19448
19449    #[test]
19450    fn counts_take_a_code_block_as_one_paragraph() {
19451        let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
19452        assert_eq!(
19453            c,
19454            TextCounts {
19455                words: 6,
19456                characters: 20,
19457                characters_without_spaces: 14,
19458                paragraphs: 1,
19459            }
19460        );
19461    }
19462
19463    #[test]
19464    fn counts_take_a_table_as_one_paragraph() {
19465        // The box-drawn borders and the column padding are the renderer's, not
19466        // the author's; the cells are what was written.
19467        let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
19468        assert_eq!(
19469            c,
19470            TextCounts {
19471                words: 5,
19472                characters: 6,
19473                characters_without_spaces: 5,
19474                paragraphs: 1,
19475            }
19476        );
19477    }
19478
19479    #[test]
19480    fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
19481        let c = counts_of(
19482            "counts_blocks",
19483            "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
19484        );
19485        assert_eq!(
19486            c,
19487            TextCounts {
19488                words: 7,
19489                characters: 36,
19490                characters_without_spaces: 34,
19491                paragraphs: 5,
19492            }
19493        );
19494    }
19495
19496    #[test]
19497    fn counts_leave_the_frontmatter_out() {
19498        // The WYSIWYG view doesn't render it and a writer didn't write it.
19499        let c = counts_of(
19500            "counts_frontmatter",
19501            "---\ntitle: Hidden\n---\n\nvisible words here\n",
19502        );
19503        assert_eq!(
19504            c,
19505            TextCounts {
19506                words: 3,
19507                characters: 18,
19508                characters_without_spaces: 16,
19509                paragraphs: 1,
19510            }
19511        );
19512    }
19513
19514    #[test]
19515    fn counts_of_an_empty_document_are_all_zero() {
19516        assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
19517    }
19518
19519    /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
19520    /// words. Recorded rather than corrected: it is what the algorithm says,
19521    /// and what every other UAX#29 counter reports.
19522    #[test]
19523    fn counts_split_a_hyphenated_compound_in_two() {
19524        let c = counts_of("counts_hyphen", "well-known example\n");
19525        assert_eq!(c.words, 3);
19526        assert_eq!(c.characters, 18);
19527        // Punctuation on its own is no word, and an apostrophe doesn't split one.
19528        assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
19529    }
19530
19531    #[test]
19532    fn selection_counts_measure_the_selection_and_nothing_without_one() {
19533        let src = "alpha beta\n\ngamma delta\n";
19534        let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
19535        assert_eq!(d.selection_counts(), None, "no selection, no counts");
19536
19537        // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
19538        d.select_range(6, 17);
19539        assert_eq!(
19540            d.selection_counts(),
19541            Some(TextCounts {
19542                words: 2,
19543                characters: 9,
19544                characters_without_spaces: 9,
19545                paragraphs: 2,
19546            })
19547        );
19548    }
19549
19550    /// The count is of the document, not of the window it is shown in — so
19551    /// ⌘E must not move it, and neither must a resize or an edit made with no
19552    /// map built at all.
19553    #[test]
19554    fn counts_agree_across_the_views() {
19555        let src =
19556            "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
19557        let mut d = doc_in(View::Wysiwyg, "counts_views", src);
19558        let wysiwyg = d.counts();
19559        assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
19560
19561        d.toggle_view();
19562        assert_eq!(d.view, View::Source);
19563        d.build_source();
19564        assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
19565
19566        // A narrower measure is a narrower window, not a shorter document.
19567        d.toggle_view();
19568        d.build_visual(24);
19569        assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
19570
19571        // And an edit made in the source view, with the visual map left stale,
19572        // still counts the document as it now stands.
19573        d.toggle_view();
19574        d.caret = d.source.len();
19575        d.insert("\n\ntail words\n");
19576        let after = d.counts();
19577        assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
19578        assert_eq!(after.words, wysiwyg.words + 2);
19579    }
19580
19581    // ── math ─────────────────────────────────────────────────────────────────
19582
19583    #[test]
19584    fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
19585        // The rule the proposal states: a formula's content is its TeX, not
19586        // its picture, so it reveals on the caret's line in *every* mode —
19587        // and nothing else on that line does outside `Full`.
19588        let mut d = doc_in(
19589            View::Wysiwyg,
19590            "math_reveal",
19591            "*one* $x+y$ here\n\ntwo there\n",
19592        );
19593        d.set_inline_pictures(true);
19594        assert_eq!(d.markup_mode(), MarkupMode::None);
19595
19596        // Away from the formula's line: the atom, and no reveal at all.
19597        caret_at(&mut d, "two");
19598        assert!(
19599            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
19600            "{:?}",
19601            drawn_rows(&d)
19602        );
19603        assert_eq!(d.vmap.math.len(), 1);
19604        assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
19605
19606        // On it: the formula is its source, the emphasis is still resolved.
19607        caret_at(&mut d, "here");
19608        assert!(
19609            drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
19610            "{:?}",
19611            drawn_rows(&d)
19612        );
19613        assert!(d.vmap.math.is_empty());
19614        assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
19615
19616        // Off again, and the picture is back.
19617        caret_at(&mut d, "two");
19618        assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
19619
19620        // The same in Shortcuts; and Full reveals the emphasis too.
19621        d.set_markup_mode(MarkupMode::Shortcuts);
19622        caret_at(&mut d, "here");
19623        assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
19624        d.set_markup_mode(MarkupMode::Full);
19625        caret_at(&mut d, "here");
19626        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
19627    }
19628
19629    #[test]
19630    fn an_unrevealed_map_shows_the_carets_line_as_a_page_does() {
19631        let mut d = doc_in(
19632            View::Wysiwyg,
19633            "math_unrevealed",
19634            "*one* $x+y$ here\n\ntwo there\n",
19635        );
19636        d.set_inline_pictures(true);
19637        d.set_markup_mode(MarkupMode::Full);
19638        caret_at(&mut d, "here");
19639        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
19640        let (screen, caret) = (d.visual_key(), d.caret);
19641
19642        // On paper: the delimiters hidden and the formula a picture, though
19643        // the caret has not moved off the line.
19644        d.set_unrevealed(true);
19645        d.build_visual(80);
19646        assert!(
19647            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
19648            "{:?}",
19649            drawn_rows(&d)
19650        );
19651        assert_eq!(d.vmap.math.len(), 1);
19652        assert_eq!(d.caret, caret);
19653
19654        // Off again: the screen's map is back as it was, and the next build
19655        // reuses it rather than building it over.
19656        d.set_unrevealed(false);
19657        assert_eq!(d.visual_key(), screen);
19658        d.build_visual(80);
19659        assert_eq!(d.visual_key(), screen);
19660        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
19661        assert_eq!(d.caret, caret);
19662    }
19663
19664    #[test]
19665    fn a_formula_closed_by_typing_reveals_at_once() {
19666        // The reveal line is decided from the last build's layout, which
19667        // across an edit is stale: the keystroke that closes a `$…$` asks a
19668        // layout that knew no math. `build_map` asks again once the new
19669        // layout is in, so the formula does not snap to its picture under
19670        // the caret.
19671        let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
19672        d.set_inline_pictures(true);
19673        d.set_markup_mode(MarkupMode::Shortcuts);
19674        d.caret = 4;
19675        for ch in ["$", "x", "$"] {
19676            d.insert(ch);
19677            d.build_visual(80);
19678        }
19679        assert_eq!(d.source, "say $x$\n");
19680        assert_eq!(
19681            drawn_rows(&d)[0],
19682            "say $x$",
19683            "source, not a picture, under the caret"
19684        );
19685        assert!(d.vmap.math.is_empty());
19686        // Leaving the line folds it — there is only one line, so add one.
19687        d.newline();
19688        d.insert("more");
19689        d.build_visual(80);
19690        assert_eq!(drawn_rows(&d)[0], "say ∑");
19691        assert_eq!(d.vmap.math.len(), 1);
19692        // And deleting the formula while revealed drops the reveal with it.
19693        d.caret = 7;
19694        d.build_visual(80);
19695        assert_eq!(drawn_rows(&d)[0], "say $x$");
19696        for _ in 0..3 {
19697            d.backspace();
19698        }
19699        d.build_visual(80);
19700        assert_eq!(d.source, "say \n\nmore\n");
19701        assert_eq!(d.reveal_line(), None);
19702    }
19703
19704    #[test]
19705    fn a_display_block_is_edited_where_it_stands() {
19706        let mut d = doc_in(
19707            View::Wysiwyg,
19708            "math_block",
19709            "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
19710        );
19711        caret_at(&mut d, "end");
19712        assert_eq!(
19713            drawn_rows(&d),
19714            vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
19715        );
19716        // Up from `end` lands on the placeholder, whose glyphs all carry the
19717        // block's start — which is on its `$$` line, so the block reveals.
19718        d.move_up(false);
19719        d.build_visual(80);
19720        assert_eq!(d.caret, 7);
19721        assert_eq!(
19722            drawn_rows(&d),
19723            vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
19724        );
19725        // Down walks the source lines, still revealed; typing edits the TeX.
19726        d.move_down(false);
19727        d.build_visual(80);
19728        assert_eq!(d.caret, 10);
19729        d.move_end(false);
19730        d.insert("^2");
19731        d.build_visual(80);
19732        assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
19733        assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
19734        // Out below, and it folds to the placeholder with the new TeX.
19735        d.move_down(false);
19736        d.move_down(false);
19737        d.move_down(false);
19738        d.build_visual(80);
19739        assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
19740        assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
19741    }
19742
19743    #[test]
19744    fn set_math_rows_reserves_filler_rows_by_tex() {
19745        let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
19746        caret_at(&mut d, "end");
19747        assert_eq!(d.vmap.math[0].rows_span, 0..1);
19748        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
19749        d.build_visual(80);
19750        assert_eq!(d.vmap.math[0].rows_span, 0..3);
19751        assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
19752        // Cheap when nothing changed.
19753        let key = d.visual_key();
19754        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
19755        d.build_visual(80);
19756        assert_eq!(d.visual_key(), key);
19757    }
19758
19759    #[test]
19760    fn a_dollar_typed_in_shortcuts_authors_math() {
19761        let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
19762        d.set_markup_mode(MarkupMode::Shortcuts);
19763        d.caret = 0;
19764        d.insert("$x$");
19765        assert_eq!(d.source, "$x$\n");
19766        d.caret = 1;
19767        assert_eq!(d.breadcrumb(), "doc › para › inline_math");
19768
19769        // `None` keeps typed syntax literal, and under the math extension a
19770        // `$` is syntax: twig escapes it, and the paragraph stays text.
19771        let mut d = doc_in(View::Wysiwyg, "math_dollar_none", "\n");
19772        assert_eq!(d.markup_mode(), MarkupMode::None);
19773        d.caret = 0;
19774        d.insert("$x$");
19775        assert_eq!(d.source, "\\$x\\$\n");
19776        d.caret = 2;
19777        assert_eq!(d.breadcrumb(), "doc › para › str");
19778    }
19779
19780    #[test]
19781    fn counts_see_a_formula_as_a_picture_however_it_is_written() {
19782        let c = counts_of(
19783            "counts_math",
19784            "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
19785        );
19786        // `the sum … and` is three words; neither formula counts, and the
19787        // display block is not a paragraph of text.
19788        assert_eq!(c.words, 3);
19789        assert_eq!(c.paragraphs, 1);
19790    }
19791}