Skip to main content

leaf_core/
doc.rs

1//! The document model: a `twig::Editor` plus a byte-offset caret and selection.
2//!
3//! Where bough moves a selection through the *tree*, leaf moves a *caret*
4//! through the *characters* — a normal text editor's model — and expresses
5//! every mutation as one of twig's offset-addressed ops:
6//!
7//!   - typing / delete  → `edit_range(start, end, text)`   (P0)
8//!   - re-anchoring      → the returned `Change`            (P1)
9//!   - cursor context    → `node_at` / `ancestors_at`       (P3)
10//!   - the toolbar       → `wrap_range`/`toggle_inline`/`set_block`,
11//!                         `toggle_block_container`/`insert_link`   (P5)
12//!
13//! twig reparses after every edit and leaves everything outside the splice
14//! byte-for-byte untouched, so the document stays a live, navigable AST while
15//! you type into it.
16
17// `PathBuf` names the `path` field and the untitled marker on every build;
18// `Path` is only touched by the filesystem I/O gated behind the `fs` feature.
19// The docs in this file lay their `- key → meaning` lists out in aligned
20// columns, which puts a continuation line further right than clippy's
21// list-indent rule likes. A lazy continuation renders as the same paragraph
22// either way, and the alignment is what makes those tables readable, so the
23// layout wins over the lint.
24#![allow(clippy::doc_overindented_list_items)]
25
26use std::collections::HashMap;
27use std::ops::Range;
28#[cfg(feature = "fs")]
29use std::path::Path;
30use std::path::PathBuf;
31
32#[cfg(feature = "fs")]
33use anyhow::Context;
34use anyhow::{Result, anyhow};
35use twig::{
36    Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture,
37    InlineKind, Kind, MarkdownExtensions, NodeId, QueryMatch,
38};
39use unicode_segmentation::GraphemeCursor;
40
41use crate::html;
42use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
43
44/// Which view the body shows.
45#[derive(Clone, Copy, PartialEq, Eq, Debug)]
46pub enum View {
47    /// The raw document with a caret in source bytes.
48    Source,
49    /// Markup resolved to real styles, caret riding the rendered glyphs.
50    Wysiwyg,
51}
52
53/// How much of the source markup the WYSIWYG view exposes — a per-editor
54/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
55/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
56/// terms, and every rung below is about *delimiters*, whatever grammar spells
57/// them. The examples are Markdown only because that is what most documents are.
58///
59/// A single ladder over two underlying axes, because only three of their four
60/// combinations are coherent:
61///
62/// | | authoring off | authoring on |
63/// |---|---|---|
64/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
65/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
66///
67/// The empty quadrant would show delimiters on the caret's line and then escape
68/// the ones you type — a surface that displays a syntax it refuses to accept.
69/// Someone who wants to read raw markup without authoring it has
70/// [`View::Source`], which is the better tool for it.
71///
72/// The two axes are read separately by the code that cares — see
73/// [`reveals_caret_line`](Self::reveals_caret_line) and
74/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
75/// as a ladder.
76#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
77pub enum MarkupMode {
78    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
79    /// literal — twig escapes anything that would open markup, so formatting
80    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
81    /// reading surface for people who don't write markup by hand; the default,
82    /// and what Diaryx ships.
83    #[default]
84    None,
85    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
86    /// becomes italic and the asterisks disappear into the styling
87    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
88    /// clean surface back once it has been applied.
89    Shortcuts,
90    /// The caret's line shows its raw markup while every other line renders
91    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
92    /// — for people fluent in the document's grammar who want to see and edit
93    /// the delimiters they type.
94    Full,
95}
96
97impl MarkupMode {
98    /// Whether the rich view shows raw delimiters on the line holding the caret.
99    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
100    /// WYSIWYG builder.
101    pub fn reveals_caret_line(self) -> bool {
102        matches!(self, MarkupMode::Full)
103    }
104
105    /// Whether typed markup characters author real formatting. The editing axis
106    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
107    pub fn authors(self) -> bool {
108        !matches!(self, MarkupMode::None)
109    }
110}
111
112/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
113/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
114/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
115/// either flow. The renderer consults it when it lays a block's inline content
116/// into visual rows.
117#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
118pub enum LineFlow {
119    /// A soft break folds into a space and the paragraph reflows to the
120    /// viewport width — flowing prose, where the source's line wrapping is
121    /// insignificant. The default, and what Diaryx ships.
122    #[default]
123    Fold,
124    /// A soft break renders as a line break exactly where it was written, so
125    /// the author's source line structure shows on screen unchanged — the mode
126    /// for people who lay out their prose deliberately (one sentence or clause
127    /// per line, semantic line breaks). The break is still a soft break in the
128    /// source; only its rendering changes.
129    Preserve,
130}
131
132/// What the file behind a document looks like right now, against the bytes leaf
133/// last read from it or wrote to it — the question a frontend asks before it
134/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
135/// happen) or when its window regains focus. See [`Doc::disk_state`].
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum DiskState {
138    /// The file holds exactly the bytes leaf last read or wrote.
139    Unchanged,
140    /// Someone else wrote the file since. Saving overwrites their work; see
141    /// [`Doc::reload`] for the other direction.
142    Changed,
143    /// The file is gone — deleted or renamed away. A save recreates it.
144    Missing,
145    /// There is a path, but the file couldn't be read (permissions, a directory
146    /// in the way): leaf can't tell, and won't guess.
147    Unreadable,
148    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
149    /// changed under a document that was never on disk.
150    Untitled,
151}
152
153/// The inline marks in force at a point in the document — what a toolbar
154/// lights up. A `Copy` bitset rather than a `HashSet`, because
155/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
156/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
158pub struct InlineMarks(u8);
159
160impl InlineMarks {
161    /// Every kind, in the order [`InlineMarks::iter`] yields them.
162    const ALL: [InlineKind; 8] = [
163        InlineKind::Strong,
164        InlineKind::Emph,
165        InlineKind::Verbatim,
166        InlineKind::Mark,
167        InlineKind::Superscript,
168        InlineKind::Subscript,
169        InlineKind::Insert,
170        InlineKind::Delete,
171    ];
172
173    pub const fn empty() -> Self {
174        InlineMarks(0)
175    }
176
177    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
178    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
179    fn insert(&mut self, kind: InlineKind) {
180        self.0 |= Self::bit(kind);
181    }
182
183    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
184    fn flip(&mut self, kind: InlineKind) {
185        self.0 ^= Self::bit(kind);
186    }
187
188    /// The symmetric difference: which marks differ between the two sets. Used
189    /// to resolve the marks already in force at the caret against the pending
190    /// delta — a bit set in the delta flips the base mark for the next keystroke.
191    fn xor(self, other: InlineMarks) -> InlineMarks {
192        InlineMarks(self.0 ^ other.0)
193    }
194
195    /// Whether `kind` is in force — the toolbar's "is Bold active?".
196    pub fn contains(self, kind: InlineKind) -> bool {
197        self.0 & Self::bit(kind) != 0
198    }
199
200    pub fn is_empty(self) -> bool {
201        self.0 == 0
202    }
203
204    /// The marks in force, for a frontend that renders whatever is on rather
205    /// than asking after a fixed list.
206    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
207        Self::ALL.into_iter().filter(move |&k| self.contains(k))
208    }
209
210    fn bit(kind: InlineKind) -> u8 {
211        1 << match kind {
212            InlineKind::Strong => 0,
213            InlineKind::Emph => 1,
214            InlineKind::Verbatim => 2,
215            InlineKind::Mark => 3,
216            InlineKind::Superscript => 4,
217            InlineKind::Subscript => 5,
218            InlineKind::Insert => 6,
219            InlineKind::Delete => 7,
220        }
221    }
222}
223
224impl FromIterator<InlineKind> for InlineMarks {
225    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
226        let mut m = InlineMarks::empty();
227        for k in iter {
228            m.insert(k);
229        }
230        m
231    }
232}
233
234/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
235/// into one undo step (a run of typed characters undoes together); `Other` never
236/// coalesces, so a paste, format toggle, or block change is always its own step.
237#[derive(Clone, Copy, PartialEq, Eq)]
238enum EditKind {
239    Insert,
240    Delete,
241    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
242    /// rather than `Insert`'s because a composition is not typing: each step
243    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
244    /// though no two steps insert the same bytes, and it must not fold into the
245    /// typed characters on either side of it.
246    Compose,
247    Other,
248}
249
250/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
251/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
252/// the caret), `Forward` is Delete (one starting at it).
253#[derive(Clone, Copy)]
254enum BreakEdge {
255    Backward,
256    Forward,
257}
258
259/// A re-spelling of one inline mark run, held ready in case the edit about to
260/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
261/// Every offset in it is in the coordinates the document will have *after* the
262/// plain edit, since that is when it may be applied.
263struct MarkEdgeFix {
264    /// The run's kind, and an offset inside what was its content: together they
265    /// answer "did the plain edit actually break this mark?" — the question that
266    /// decides whether any of this is applied at all.
267    kind: InlineKind,
268    probe: usize,
269    /// The byte range to re-spell (the run's delimiters included) and its new
270    /// spelling, with the edge whitespace moved outside the delimiters.
271    start: usize,
272    end: usize,
273    text: String,
274    /// Where the caret belongs afterwards — the same place on screen it would
275    /// have had, which is now on the other side of a delimiter.
276    caret: usize,
277    /// The marks in force for text typed at that caret. The caret can land
278    /// outside a run it was inside, and the marks have to survive the move or
279    /// the toolbar goes dark mid-word.
280    want: InlineMarks,
281}
282
283/// The caret and selection at one moment — the part of a history step twig's
284/// `Change` cannot carry, because the caret is leaf's state and twig only knows
285/// about bytes. leaf serializes it into the opaque per-state blob twig now
286/// stores in its own undo history (see `record_caret`), so undo and redo hand
287/// back the caret that matches the source they restore.
288#[derive(Clone, Copy)]
289struct CaretState {
290    caret: usize,
291    anchor: Option<usize>,
292}
293
294impl CaretState {
295    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
296    /// then an anchor-present flag and the anchor. twig copies these bytes and
297    /// never reads them.
298    fn to_blob(self) -> [u8; 17] {
299        let mut b = [0u8; 17];
300        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
301        if let Some(a) = self.anchor {
302            b[8] = 1;
303            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
304        }
305        b
306    }
307
308    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
309    /// length — a state twig restored that never had a caret set on it, which
310    /// leaves the caller to fall back to the edit site.
311    fn from_blob(b: &[u8]) -> Option<Self> {
312        let b: &[u8; 17] = b.try_into().ok()?;
313        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
314        let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
315        Some(CaretState { caret, anchor })
316    }
317}
318
319/// A footnote reference and the note it names — the answer to
320/// [`Doc::footnote_at`].
321///
322/// The two `Option`s move together: a reference whose definition is missing has
323/// neither a body to show nor a place to jump to, and one that resolved has
324/// both.
325#[derive(Clone, PartialEq, Eq, Debug)]
326pub struct FootnoteRef {
327    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
328    /// spells it a footnote nor the brackets around it.
329    pub label: String,
330    /// The note's body as source bytes (see
331    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
332    /// document defines no `[^label]:` to read one from.
333    pub text: Option<String>,
334    /// Where the note's *body* starts, for a "go to note" that moves the caret
335    /// there. `None` alongside a `None` `text`.
336    ///
337    /// The body rather than the definition, because this is an offset to put a
338    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
339    /// aiming at the definition's first byte snaps to the nearest real stop,
340    /// which is up in the paragraph above the note. It is also simply where a
341    /// reader following a reference wants to land: at the note's first word,
342    /// ready to read or amend it.
343    pub offset: Option<usize>,
344    /// Where the note's body ends, exclusive — so a frontend can ask which
345    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
346    ///
347    /// The rows are the note with its markup resolved: `see *later*` reaches a
348    /// frontend as an italic run, not as asterisks. `text` is the source bytes
349    /// and stays the honest answer for anything that wants the note as written
350    /// (a search index, a copy); this pair of offsets is for anything that wants
351    /// it as *read*. `None` alongside a `None` `offset`.
352    pub end: Option<usize>,
353}
354
355/// A footnote definition and the reference that sends a reader to it — the
356/// answer to [`Doc::footnote_definition_at`], and the other half of the round
357/// trip [`FootnoteRef`] starts.
358///
359/// A note is a place a reader *arrives*, so the useful thing to know while
360/// standing in one is the way back. Without this the jump to a note is a
361/// one-way door: the definitions sit at the foot of the document, so returning
362/// by hand means scrolling back up and finding the sentence again.
363#[derive(Clone, PartialEq, Eq, Debug)]
364pub struct FootnoteDef {
365    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
366    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
367    pub label: String,
368    /// Where the reference's *label* is, for a "back to reference" that moves
369    /// the caret there. `None` for a note nothing refers to — an orphan, which
370    /// is worth being able to say rather than silently doing nothing.
371    ///
372    /// The label rather than the reference's first byte, for
373    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
374    /// and its label is the only part of it the caret can rest on.
375    ///
376    /// The *first* reference, when a label is cited more than once: a repeated
377    /// citation has no one true home, and the first is both the one a reader
378    /// most likely came from and the only choice that doesn't depend on how
379    /// they got here.
380    pub offset: Option<usize>,
381}
382
383/// Where a locator lands — the answer to [`Doc::locate`].
384///
385/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
386/// document, and a place is a span rather than a point: a reader following one
387/// wants the caret at its first byte, and a reader merely *peeking* at one wants
388/// the block it covers drawn. Both are served by carrying the whole span, and
389/// only one of the two can be recovered from an offset alone.
390#[derive(Clone, PartialEq, Eq, Debug)]
391pub struct Landing {
392    /// The first byte of the block the locator names — where a caret goes.
393    pub start: usize,
394    /// One past its last byte, so a frontend can map the pair through
395    /// [`Doc::pos_for_offset`] to the rendered rows the block occupies and draw
396    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
397    pub end: usize,
398}
399
400/// A selection cited out of the source: the text itself, up to a requested
401/// number of characters either side, and the byte range it came from. See
402/// [`Doc::selection_quote`].
403///
404/// The prefix and suffix are what make the quote *re-findable*: the same text
405/// can occur twice, and a little of what surrounded it is how a later reader —
406/// or the same document after an edit — tells the occurrences apart. The Web
407/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
408/// the name.
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct Quote {
411    /// The selected source, verbatim.
412    pub exact: String,
413    /// What immediately preceded it — possibly empty, at the document's start.
414    pub prefix: String,
415    /// What immediately followed it — possibly empty, at the document's end.
416    pub suffix: String,
417    /// Byte offset in the source where the selection begins.
418    pub start: usize,
419    /// Byte offset where it ends (exclusive).
420    pub end: usize,
421}
422
423/// A host-painted range of the source — an annotation's footprint, a search
424/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
425/// glyphs whose source falls inside it) and hands back the `id` when the
426/// reader activates it; what the range *means* is entirely the host's.
427///
428/// Ranges are source bytes, like the caret and the selection, so a host that
429/// anchors quotes against the source ([`Doc::selection_quote`] is the other
430/// half of that loop) can paint what it found without any coordinate
431/// conversion. A range that drifts off the text it meant is the host's to
432/// re-anchor; leaf draws what it is told.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Highlight {
435    /// Byte offset in the source where the wash begins.
436    pub start: usize,
437    /// Byte offset where it ends (exclusive).
438    pub end: usize,
439    /// The host's name for it, handed back on activation. Opaque to leaf.
440    pub id: String,
441    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
442    /// nothing for the theme's default wash.
443    pub color: Option<String>,
444    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
445    /// a marker gets a small glyph in the margin beside its first line, and
446    /// the glyph — not the wash — is what activates it: the wash is ink, the
447    /// marker is the control, which is what lets a reader put a caret in (or
448    /// copy from) annotated text without a card leaping at them. The name is
449    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
450    /// as a class.
451    pub marker: Option<String>,
452}
453
454pub struct Doc {
455    editor: Editor,
456    pub format: Format,
457    pub path: PathBuf,
458    /// Current source, refreshed from the editor after every successful edit.
459    pub source: String,
460    /// The caret, as a byte offset into `source` (always on a char boundary).
461    pub caret: usize,
462    /// The selection's fixed end, if a selection is active; the moving end is
463    /// the caret. `None` means no selection.
464    pub anchor: Option<usize>,
465    pub dirty: bool,
466    pub status: Option<String>,
467    pub view: View,
468    /// Whether the document refuses to change — a *reading* surface over the
469    /// same rendering, selection, and navigation the editor has.
470    ///
471    /// Enforced here rather than by each frontend hiding its input paths,
472    /// because every mutation funnels through three doors —
473    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
474    /// [`redo`](Self::redo) — and three guarded doors are a guarantee where a
475    /// frontend's suppressed keyboard is a hope. A gated splice reports
476    /// exactly like a rolled-back one, a path every caller already handles.
477    read_only: bool,
478    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
479    /// State like the selection rather than like the text: no edit history,
480    /// no dirty bit, redrawn from whatever the host last set.
481    highlights: Vec<Highlight>,
482    /// How much of the source markup the rich view exposes — a frontend preference (see
483    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
484    /// [`reveal_line`](Self::reveal_line), the editing one by
485    /// [`insert`](Self::insert).
486    markup_mode: MarkupMode,
487    /// Whether soft breaks fold into the reflowed paragraph or render where
488    /// they were written (see [`LineFlow`]) — an independent frontend
489    /// preference the WYSIWYG builder consults when it lays out a block.
490    line_flow: LineFlow,
491    /// The kind of the last edit, for coalescing: twig owns the undo *history*
492    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
493    /// call, so leaf decides when a run continues and tells twig to coalesce.
494    last_edit_kind: Option<EditKind>,
495    /// The inline marks the user has toggled *at a collapsed caret* with no
496    /// selection — "start typing bold here". Held as the XOR delta from the marks
497    /// already in force at [`pending_at`](Self::pending_at): a set bit means
498    /// "flip this kind for the next typed text", so it both turns a mark on where
499    /// none is (type into bold) and off where one already covers the caret (type
500    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
501    /// freshly typed text and then clears it — a mark once realised is carried by
502    /// the caret sitting inside the run, not by this delta.
503    pending_marks: InlineMarks,
504    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
505    /// delta is live only while the caret still stands here with no selection;
506    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
507    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
508    pending_at: Option<usize>,
509    /// The source as of the last open/save — `dirty` is `source != clean_source`,
510    /// so undoing back to the saved state correctly clears the modified flag.
511    clean_source: String,
512    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
513    /// while the document has no file behind it. [`Doc::disk_state`] compares
514    /// the file against this to catch an edit made *outside* leaf before a save
515    /// silently overwrites it — `clean_source` only knows what leaf itself did.
516    ///
517    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
518    /// writes inside one filesystem timestamp tick are indistinguishable, a
519    /// clock that steps backwards (or a writer that restores an mtime) hides a
520    /// real change, and a `touch` invents one. The whole point of the watermark
521    /// is to not clobber someone's work, so it reads the bytes and compares what
522    /// is actually there. That costs a file read per question, which is why the
523    /// question is asked on a user event (focus, save) and not every frame.
524    disk_hash: Option<u64>,
525    /// The "sticky" display column vertical motion aims for, in the active
526    /// view's grid. Set on the first `move_up`/`move_down` of a run and
527    /// reused by every subsequent one in that run, so passing through a
528    /// shorter line doesn't permanently forget the original column. Any
529    /// horizontal motion or edit clears it.
530    ///
531    /// A column, not a character index: dropping down a line of `你好` onto one
532    /// of ASCII has to land under the glyph the caret was drawn beneath, which
533    /// is the only thing the user can see to aim by. Where the goal falls inside
534    /// a wide character on the target line, the mapping resolves it to that
535    /// character — the caret lands on it rather than between its cells.
536    goal_col: Option<usize>,
537    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
538    /// and clicks read it to stay in visible space.
539    pub vmap: VisualMap,
540    /// Everything the map is built from, as one number: bumped whenever the
541    /// document's text changes, and never by a motion, a selection, or a save.
542    /// A frontend can hold work against it — see [`Doc::revision`].
543    revision: u64,
544    /// What `vmap` was built from, or `None` before the first build. The map is
545    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
546    /// moved, rebuilding it produces the identical map — see
547    /// [`Doc::build_visual`].
548    ///
549    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
550    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
551    /// text and width alone, and a caret motion still rebuilds nothing.
552    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
553    /// Per-block row cache backing the incremental rebuild: when the text
554    /// changes, only the top-level blocks whose bytes moved are re-rendered and
555    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
556    /// builds; a pure accelerator, so it's never read for correctness.
557    block_cache: wysiwyg::BlockCache,
558    /// How many visual rows each block image reserves, keyed by its destination —
559    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
560    /// measured the pictures. Core does no image I/O, so this is the only way it
561    /// learns a picture's height; a destination not in the map reserves the bare
562    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
563    /// sizes each placeholder, and folded into `vmap_key` so a height change
564    /// rebuilds the map.
565    media_rows: HashMap<String, usize>,
566
567    // View geometry the renderer stamps each frame, so mouse events can map a
568    // screen cell back to a byte offset.
569    pub scroll: usize,
570    pub body_origin: (u16, u16),
571    pub body_height: u16,
572    /// The caret as of the last frame drawn, or `None` before the first.
573    ///
574    /// Scrolling is the viewport's business, not the caret's: the view follows
575    /// the caret when the caret *moves*, but a wheel that doesn't touch the
576    /// caret has to be free to scroll away from it — otherwise the view is
577    /// pinned to the caret and stops dead at the edge of the document you can
578    /// see. Comparing against this is what tells the two apart, and it catches a
579    /// caret set by any route, including a frontend assigning the field itself.
580    pub drawn_caret: Option<usize>,
581}
582
583/// The Markdown extensions every leaf document is parsed with. `html_elements`
584/// and `directives` depart from twig's defaults. `html_elements` promotes
585/// embedded raw HTML (`<img>`, `<picture>`, `<source>`, …) into semantic AST
586/// nodes, so a picture becomes a real `image` node the frontends can frame and
587/// rasterize instead of opaque `raw_block` text. `directives` turns on generic
588/// `:::name{.class}` fenced-div containers (`directive` nodes), which a host
589/// app uses for its own semantics (diaryx's `:::vis{.audience}` visibility
590/// blocks) — core renders any directive as a plain tinted container, agnostic
591/// of `name`. Both flags are inert for non-Markdown formats, so it's safe to
592/// pass them unconditionally. Threading this through every constructor (not
593/// just `open`) keeps `from_source`, `blank`, and `reload` parsing the same
594/// document the same way — twig reparses with these same flags after each edit.
595fn parse_extensions() -> MarkdownExtensions {
596    MarkdownExtensions {
597        html_elements: true,
598        directives: true,
599        ..Default::default()
600    }
601}
602
603/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
604/// mapping twig's error into the `anyhow` context every constructor shares.
605fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
606    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
607}
608
609/// Does `format` spell a table as a **pipe table** — the one grid twig's table
610/// editor knows how to emit?
611///
612/// This is the single capability leaf still has to answer for itself, and the
613/// only hand-maintained format list left in this file. Every other gesture is
614/// [`Format::supports`], which is twig's own answer read across the C ABI — but
615/// twig deliberately leaves the table ops out of that query, because they read
616/// no `Syntax` table at all. They rewrite a grid that is already in the source
617/// and refuse on *position*, never on format. Handed a caret inside an HTML
618/// `<table>`, `table_insert_row` therefore re-emits the whole element as
619/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
620/// `dirty` flag, and nothing downstream able to tell it from a good edit.
621///
622/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
623/// wildcard answers "no" for a format leaf has never heard of: a new twig
624/// language that *does* spell pipe tables loses its grid controls until this
625/// line is updated, which shows up as a missing button. The other default hands
626/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
627fn spells_pipe_tables(format: Format) -> bool {
628    matches!(format, Format::Markdown | Format::Djot)
629}
630
631/// Which of leaf's authoring controls this document's format can actually
632/// spell — one flag per toolbar button, resolved once so a frontend can build
633/// its chrome instead of discovering each refusal on a click.
634///
635/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
636/// the matching [`Doc`] method calls, so this record cannot drift from what the
637/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
638/// export.
639///
640/// **The formats are ragged, and that is the point.** A single per-document
641/// boolean was enough while the two authorable formats were Markdown and djot
642/// and everything else spelled nothing. HTML is neither: it writes seven of the
643/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
644/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
645/// no link — because its versions of those have a different *shape*, not a
646/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
647/// one flag can say that. Markdown and djot differ from each other too:
648/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
649#[derive(Clone, Copy, Debug, Eq, PartialEq)]
650pub struct Capabilities {
651    /// ⌘B — `InlineKind::Strong`.
652    pub bold: bool,
653    /// ⌘I — `InlineKind::Emph`.
654    pub italic: bool,
655    /// Inline code — `InlineKind::Verbatim`.
656    pub code: bool,
657    /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
658    pub mark: bool,
659    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
660    pub underline: bool,
661    /// Strikethrough — `InlineKind::Delete`.
662    pub strike: bool,
663    pub superscript: bool,
664    pub subscript: bool,
665    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
666    pub heading: bool,
667    pub blockquote: bool,
668    pub bullet_list: bool,
669    pub ordered_list: bool,
670    /// The checkbox controls: giving an item a box, and ticking one.
671    pub task: bool,
672    pub link: bool,
673    /// Covers [`Doc::insert_media`] too — see the note there on why the three
674    /// media kinds stand or fall together.
675    pub image: bool,
676    /// The horizontal-rule button. HTML spells this one (`<hr>`).
677    pub thematic_break: bool,
678    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
679    /// the pair; HTML has no footnote of its own, so the button goes away rather
680    /// than writing brackets that would render as brackets.
681    pub footnote: bool,
682    /// Setting a fenced block's language — a control only ever offered with the
683    /// caret already in a fence.
684    pub code_language: bool,
685    /// The grid controls: insert/delete/move a row or column, set a column's
686    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
687    /// question — an HTML `<table>` holds the caret and still can't be edited.
688    pub table: bool,
689    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
690    /// idiomatic in-cell break.
691    pub cell_line_break: bool,
692}
693
694impl Capabilities {
695    /// Resolve every flag for `format`. Pure and cheap — twig computes each from
696    /// a static table — but a frontend that wants to hold them can.
697    pub fn of(format: Format) -> Self {
698        let inline = |k| format.supports(Gesture::ToggleInline(k));
699        let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
700        Self {
701            bold: inline(InlineKind::Strong),
702            italic: inline(InlineKind::Emph),
703            code: inline(InlineKind::Verbatim),
704            mark: inline(InlineKind::Mark),
705            underline: inline(InlineKind::Insert),
706            strike: inline(InlineKind::Delete),
707            superscript: inline(InlineKind::Superscript),
708            subscript: inline(InlineKind::Subscript),
709            heading: format.supports(Gesture::SetBlock),
710            blockquote: container(BlockContainerKind::BlockQuote),
711            bullet_list: container(BlockContainerKind::BulletList),
712            ordered_list: container(BlockContainerKind::OrderedList),
713            // Both halves of the checkbox story, and leaf offers no control that
714            // needs only one: the item gesture mints the box, the checked one
715            // ticks it, and a format spelling a `task_marker` spells both.
716            task: format.supports(Gesture::ToggleTaskItem)
717                && format.supports(Gesture::ToggleTaskChecked),
718            link: format.supports(Gesture::InsertLink),
719            image: format.supports(Gesture::InsertImage),
720            thematic_break: format.supports(Gesture::InsertThematicBreak),
721            footnote: format.supports(Gesture::InsertFootnote),
722            code_language: format.supports(Gesture::SetCodeLanguage),
723            table: spells_pipe_tables(format),
724            cell_line_break: format.supports(Gesture::InsertLineBreak),
725        }
726    }
727}
728
729impl Doc {
730    #[cfg(feature = "fs")]
731    pub fn open(path: PathBuf) -> Result<Self> {
732        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
733        let format = detect_format(&path)?;
734        let editor = new_editor(&bytes, format)?;
735        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
736        let disk_hash = Some(hash_bytes(source.as_bytes()));
737        // Store the document's *absolute* path. A relative one (`leaf README.md`)
738        // has an empty parent, so a frontend can't resolve a relative image
739        // destination (`![](pic.png)`) against the document's directory and the
740        // picture silently falls back to its text placeholder. `absolute` is
741        // purely lexical — it prefixes the current directory and normalizes, but
742        // reads nothing and resolves no symlinks — so `file_name` and save are
743        // unchanged; it only gives `path.parent()` something to join against.
744        let path = std::path::absolute(&path).unwrap_or(path);
745        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
746    }
747
748    /// Build a document from an in-memory string, the format named explicitly —
749    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
750    /// path and sniffs the format from its extension). A wasm or FFI host, which
751    /// has no path to read, uses this: it hands over bytes it fetched however it
752    /// could, and later persists [`Doc::source`] however it can (a browser
753    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
754    ///
755    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
756    /// true) exactly like a [`Doc::blank`] that has been given content.
757    pub fn from_source(source: String, format: Format) -> Result<Self> {
758        let editor = new_editor(source.as_bytes(), format)?;
759        Ok(Doc::from_parts(
760            editor,
761            format,
762            PathBuf::new(),
763            source,
764            None,
765        ))
766    }
767
768    /// An untitled, empty document — the `+` button and a `leaf` launched with
769    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
770    ///
771    /// It is Markdown, because a format has to be chosen before a name exists to
772    /// read one from: `detect_format` reads the extension and an untitled
773    /// document has neither. Markdown is what leaf's own files are, what its
774    /// block markers are already written for (`insert_block_prefix`), and the
775    /// extension a Save As will overwhelmingly pick — a wrong guess here would
776    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
777    /// *doesn't* revisit this: see [`Doc::save_as`].
778    pub fn blank() -> Result<Self> {
779        let format = Format::Markdown;
780        let editor = new_editor(b"", format)?;
781        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
782        // field two frontends already read; making it an `Option` to say this
783        // would break both). `is_untitled` is the question to ask, not the
784        // representation to copy.
785        Ok(Doc::from_parts(
786            editor,
787            format,
788            PathBuf::new(),
789            String::new(),
790            None,
791        ))
792    }
793
794    /// The fields every constructor agrees on, so `open` and `blank` can't drift
795    /// apart in the ones neither of them has an opinion about.
796    fn from_parts(
797        editor: Editor,
798        format: Format,
799        path: PathBuf,
800        source: String,
801        disk_hash: Option<u64>,
802    ) -> Self {
803        Doc {
804            editor,
805            format,
806            path,
807            disk_hash,
808            clean_source: source.clone(),
809            source,
810            caret: 0,
811            anchor: None,
812            dirty: false,
813            status: None,
814            read_only: false,
815            highlights: Vec::new(),
816            // leaf opens in the rich-text (WYSIWYG) view by default — the
817            // markup-resolved surface is leaf's differentiator. Frontends can
818            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
819            // toggles at runtime.
820            view: View::Wysiwyg,
821            // `None` by default — the clean surface Diaryx ships, with typed
822            // syntax kept literal; a markup-fluent frontend can climb the
823            // ladder to `Shortcuts` or `Full`.
824            markup_mode: MarkupMode::default(),
825            // Fold by default — flowing prose that reflows to the viewport, the
826            // behaviour every frontend had before this preference existed.
827            line_flow: LineFlow::default(),
828            last_edit_kind: None,
829            pending_marks: InlineMarks::empty(),
830            pending_at: None,
831            goal_col: None,
832            vmap: VisualMap::default(),
833            revision: 0,
834            // No map yet — the first `build_visual` always builds.
835            vmap_key: None,
836            block_cache: wysiwyg::BlockCache::default(),
837            media_rows: HashMap::new(),
838            scroll: 0,
839            body_origin: (0, 0),
840            body_height: 0,
841            drawn_caret: None,
842        }
843    }
844
845    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
846    /// has never been saved. The question a ⌘S handler asks to know it should
847    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
848    /// header asks to know the name it shows is a placeholder.
849    pub fn is_untitled(&self) -> bool {
850        self.path.as_os_str().is_empty()
851    }
852
853    pub fn toggle_view(&mut self) {
854        self.view = match self.view {
855            View::Source => View::Wysiwyg,
856            View::Wysiwyg => View::Source,
857        };
858        self.scroll = 0;
859        self.status = None;
860        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
861        // lift it to the first rendered offset.
862        self.clamp_caret();
863    }
864
865    /// The current markup-exposure preference (see [`MarkupMode`]).
866    pub fn markup_mode(&self) -> MarkupMode {
867        self.markup_mode
868    }
869
870    /// Set the markup-exposure preference. Both of its axes take effect at
871    /// once: the editing one on the next [`insert`](Self::insert), and the
872    /// rendering one on the next build — which is why this drops the cached
873    /// visual map and the per-block render cache, exactly as
874    /// [`set_line_flow`](Self::set_line_flow) does.
875    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
876        if self.markup_mode == mode {
877            return;
878        }
879        self.markup_mode = mode;
880        // Neither cache is keyed on the mode, and moving between `Full` and the
881        // hidden modes changes every row the caret's line renders to — so
882        // invalidate both explicitly.
883        self.vmap_key = None;
884        self.block_cache = wysiwyg::BlockCache::default();
885    }
886
887    /// The source byte range of the line the caret sits on, when that line
888    /// should render its raw delimiters — `None` in every mode and view that
889    /// hides them, which is what the builder reads as "reveal nothing".
890    ///
891    /// A *source* line (newline to newline), not a visual row: a wrapped
892    /// paragraph and a `LineFlow::Preserve` soft break both split one source
893    /// line across several rows, and revealing half a delimiter pair because the
894    /// other half wrapped would be worse than revealing neither. The range
895    /// excludes the terminating newline and is empty-but-present on a blank
896    /// line, which reveals nothing but still keys the caches correctly.
897    ///
898    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
899    /// there is nothing there to reveal.
900    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
901        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
902            return None;
903        }
904        Some(source_line_range(&self.source, self.caret))
905    }
906
907    /// The current soft-break flow preference (see [`LineFlow`]).
908    pub fn line_flow(&self) -> LineFlow {
909        self.line_flow
910    }
911
912    /// Set the soft-break flow preference. The mode changes how every block lays
913    /// out, so a change drops the cached visual map and the per-block render
914    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
915    ///
916    /// [`build_visual`]: Self::build_visual
917    pub fn set_line_flow(&mut self, mode: LineFlow) {
918        if self.line_flow == mode {
919            return;
920        }
921        self.line_flow = mode;
922        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
923        // so invalidate them explicitly, or the next build would reuse rows laid
924        // out under the old flow.
925        self.vmap_key = None;
926        self.block_cache = wysiwyg::BlockCache::default();
927    }
928
929    pub fn view_name(&self) -> &'static str {
930        match self.view {
931            View::Source => "source",
932            View::Wysiwyg => "wysiwyg",
933        }
934    }
935
936    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
937    /// (called by the renderer each frame it's in the WYSIWYG view).
938    /// Build the WYSIWYG map, wrapped at `width` display columns.
939    ///
940    /// Cheap to call every frame, which is what both frontends do: the map is a
941    /// pure function of the document and the wrap width, so a call that would
942    /// rebuild the same map returns the one already built. Only an edit (or a
943    /// resize) pays.
944    ///
945    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
946    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
947    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
948    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
949    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
950    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
951    pub fn build_visual(&mut self, width: usize) {
952        self.build_map(Some(width));
953    }
954
955    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
956    /// frontend (the GUI) that wraps at its own proportional pixel width rather
957    /// than a fixed character column.
958    pub fn build_visual_unwrapped(&mut self) {
959        self.build_map(None);
960    }
961
962    /// Tell the model how many visual rows each block image should reserve, keyed
963    /// by the image's destination. A terminal frontend calls this once it has
964    /// decoded and measured its pictures — core does no image I/O, so this is the
965    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
966    /// placeholder out that tall (the label row plus blank filler rows the
967    /// frontend paints the raster over). A destination left out of the map falls
968    /// back to the bare one-row placeholder, which is also what a frontend that
969    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
970    /// by never calling this.
971    ///
972    /// Cheap to call every frame with the same map: only a *change* invalidates
973    /// the built map (and the block-row cache, since a height isn't part of a
974    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
975    /// no-op, so a frontend can just hand over its current measurements each frame.
976    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
977        if self.media_rows == rows {
978            return;
979        }
980        self.media_rows = rows;
981        // A height lives outside the block's source bytes, so the content-keyed
982        // block cache would hand back the old-height rows on a hit. Drop it (and
983        // the splice layout it carries) so the next build re-renders every block
984        // at the new heights, and force that build by clearing the map key.
985        self.block_cache = wysiwyg::BlockCache::default();
986        self.vmap_key = None;
987    }
988
989    /// The revision the document's text is at — bumped by every edit, undo,
990    /// redo, and reload, and by nothing else. A frontend caches against this to
991    /// tell a repaint that needs new work from one that doesn't.
992    ///
993    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
994    /// lands on the same text two revisions later. Work is only ever rebuilt
995    /// needlessly, never wrongly reused.
996    pub fn revision(&self) -> u64 {
997        self.revision
998    }
999
1000    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1001    /// runs on every call: the caret moves without the document changing, and
1002    /// keeping it on a legal stop is this function's job either way.
1003    fn build_map(&mut self, wrap: Option<usize>) {
1004        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1005        // as well as the text, so the line joins the key: moving within a line
1006        // still reuses the map, and crossing into another one rebuilds it. In
1007        // every other mode `reveal_line` is `None` and the key is what it was,
1008        // so caret motion goes on costing nothing.
1009        let reveal = self.reveal_line();
1010        let key = (self.revision, wrap, reveal.clone());
1011        if self.vmap_key.as_ref() != Some(&key) {
1012            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1013            // A subtree is pulled only for the block(s) that actually changed, so
1014            // the FFI marshal shrinks from O(document) to O(edited block).
1015            let top = self.top_blocks();
1016
1017            // Fast path: when twig reports a dirty byte range, try to patch the
1018            // previous map in place — a single-block edit moves the prefix,
1019            // shifts the suffix, and re-renders only one block. `build_spliced`
1020            // returns `None` (and we fall back to the always-correct full rebuild)
1021            // whenever the edit reshaped the block structure, hit a table, or
1022            // there's no previous map to patch.
1023            // Preserve soft breaks as written when the flow preference asks for
1024            // it — the builder renders each as its own visual row instead of
1025            // folding it into the reflowed paragraph.
1026            let preserve_soft = self.line_flow == LineFlow::Preserve;
1027            let spliced = match self.editor.dirty_range() {
1028                Some(dirty) => {
1029                    let prev = std::mem::take(&mut self.vmap);
1030                    let source = &self.source;
1031                    let cache = &mut self.block_cache;
1032                    let media_rows = &self.media_rows;
1033                    let editor = &mut self.editor;
1034                    wysiwyg::build_spliced(
1035                        prev,
1036                        source,
1037                        wrap,
1038                        preserve_soft,
1039                        &top,
1040                        dirty,
1041                        media_rows,
1042                        reveal.clone(),
1043                        cache,
1044                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1045                    )
1046                }
1047                None => None,
1048            };
1049            self.vmap = spliced.unwrap_or_else(|| {
1050                let source = &self.source;
1051                let cache = &mut self.block_cache;
1052                let media_rows = &self.media_rows;
1053                let editor = &mut self.editor;
1054                wysiwyg::build_cached(
1055                    &top,
1056                    source,
1057                    wrap,
1058                    preserve_soft,
1059                    media_rows,
1060                    reveal,
1061                    cache,
1062                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1063                )
1064            });
1065            // Acknowledge the dirty range so the next edit's range starts fresh.
1066            self.editor.clear_dirty();
1067            self.vmap_key = Some(key);
1068        }
1069        self.clamp_caret();
1070    }
1071
1072    fn nodes(&mut self) -> Vec<FlatNode> {
1073        self.editor.nodes().unwrap_or_default()
1074    }
1075
1076    /// The document's top-level blocks for the incremental render. See
1077    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1078    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1079        wysiwyg::top_blocks(&mut self.editor)
1080    }
1081
1082    pub fn format_name(&self) -> &'static str {
1083        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1084        // required. It also covers `Asciidoc`, which twig parses but cannot
1085        // serialize — leaf never opens a document in it (see `Doc::open`).
1086        match self.format {
1087            Format::Djot => "djot",
1088            Format::Markdown => "markdown",
1089            Format::Xml => "xml",
1090            Format::Html => "html",
1091            _ => "unknown",
1092        }
1093    }
1094
1095    /// Whether this document's format offers *any* door in — `false` only for a
1096    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1097    /// a frontend may as well open the file read-only.
1098    ///
1099    /// This is a much weaker claim than the name suggests, and driving per-button
1100    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1101    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1102    /// while a heading, a quote, a list, a task box, a link and a code fence all
1103    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1104    /// [`supports`](Self::supports) — per control.
1105    pub fn authorable(&self) -> bool {
1106        self.format.is_authorable()
1107    }
1108
1109    /// Whether this document's format can spell `gesture`, which is twig's own
1110    /// answer rather than a copy of it: `Format::supports` reads the same
1111    /// `Syntax` table the `Editor` method consults before refusing.
1112    ///
1113    /// It is a fact about the *format*, not about the caret. `true` does not
1114    /// promise the gesture succeeds where it is standing — a link over a table
1115    /// border still fails — only that it will not fail with
1116    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1117    /// will work here".
1118    pub fn supports(&self, gesture: Gesture) -> bool {
1119        self.format.supports(gesture)
1120    }
1121
1122    /// Every control's enabled state in one read — what a toolbar builds itself
1123    /// from when a document opens or its format changes. See [`Capabilities`].
1124    pub fn capabilities(&self) -> Capabilities {
1125        Capabilities::of(self.format)
1126    }
1127
1128    /// Refuse a gesture this document's format cannot spell, saying so in the
1129    /// status line. `true` means the caller must return without calling twig.
1130    ///
1131    /// Most of these refusals duplicate one twig would make anyway, and they are
1132    /// made here regardless because a message naming the *document's* format
1133    /// reads better than one naming twig's internals. Two of them are not
1134    /// duplicates and are the reason this is a guard rather than an error
1135    /// translation:
1136    ///
1137    /// - The table family (see [`table_op`](Self::table_op)) consults no
1138    ///   `Syntax` table, so twig does not refuse it at all.
1139    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1140    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1141    ///   could not keep.
1142    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1143        self.refuse_unless(what, self.supports(gesture))
1144    }
1145
1146    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1147    /// answers itself — today only [`spells_pipe_tables`].
1148    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1149        if supported {
1150            return false;
1151        }
1152        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1153        true
1154    }
1155
1156    /// The name to show for this document. An untitled one has no file to name
1157    /// it, and both frontends put this straight on screen — an empty path
1158    /// renders as an empty header, so it says so instead.
1159    pub fn file_name(&self) -> String {
1160        if self.is_untitled() {
1161            return "untitled".into();
1162        }
1163        self.path
1164            .file_name()
1165            .map(|s| s.to_string_lossy().into_owned())
1166            .unwrap_or_else(|| self.path.display().to_string())
1167    }
1168
1169    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1170    /// caret and anchor coincide (an empty selection is no selection).
1171    pub fn selection(&self) -> Option<(usize, usize)> {
1172        self.anchor
1173            .map(|a| (a.min(self.caret), a.max(self.caret)))
1174            .filter(|(s, e)| s != e)
1175    }
1176
1177    /// The selected text, or `None` when there's no selection — the source
1178    /// slice a copy/cut hands to the system clipboard.
1179    pub fn selected_text(&self) -> Option<&str> {
1180        self.selection().map(|(s, e)| &self.source[s..e])
1181    }
1182
1183    /// The selection as a quote with a little of what surrounds it — the shape
1184    /// a host that cites, annotates, or searches for a passage wants, cut from
1185    /// the **source** rather than from anything rendered, so the quote is
1186    /// findable in the document again by plain string search.
1187    ///
1188    /// `context` is a count of characters (not bytes) on each side, clipped at
1189    /// the document's edges; the slices land on char boundaries by
1190    /// construction. `None` when nothing is selected.
1191    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1192        let (start, end) = self.selection()?;
1193        let mut before = start;
1194        for _ in 0..context {
1195            match self.source[..before].chars().next_back() {
1196                Some(c) => before -= c.len_utf8(),
1197                None => break,
1198            }
1199        }
1200        let mut after = end;
1201        for _ in 0..context {
1202            match self.source[after..].chars().next() {
1203                Some(c) => after += c.len_utf8(),
1204                None => break,
1205            }
1206        }
1207        Some(Quote {
1208            exact: self.source[start..end].to_string(),
1209            prefix: self.source[before..start].to_string(),
1210            suffix: self.source[end..after].to_string(),
1211            start,
1212            end,
1213        })
1214    }
1215
1216    /// Whether the document refuses to change — see the field.
1217    pub fn read_only(&self) -> bool {
1218        self.read_only
1219    }
1220
1221    /// Turn the read-only gate on or off. A frontend preference like
1222    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1223    /// itself changes, only what may be done to it from here on.
1224    pub fn set_read_only(&mut self, on: bool) {
1225        self.read_only = on;
1226    }
1227
1228    /// The host-painted ranges, sorted by start — see [`Highlight`].
1229    pub fn highlights(&self) -> &[Highlight] {
1230        &self.highlights
1231    }
1232
1233    /// Replace the host-painted ranges wholesale. The whole set each time,
1234    /// rather than add/remove verbs: the host owns the list (it derives it
1235    /// from its own state — annotations, search hits), and a replace can
1236    /// never leave the two disagreeing about what should be on screen.
1237    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1238        highlights.retain(|h| h.start < h.end);
1239        highlights.sort_by_key(|h| (h.start, h.end));
1240        self.highlights = highlights;
1241    }
1242
1243    /// The highlight covering source `offset`, if one does — first by start
1244    /// when several overlap, which makes overlapping washes resolvable rather
1245    /// than undefined. What a frontend asks when the reader activates a spot.
1246    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1247        self.highlights
1248            .iter()
1249            .find(|h| h.start <= offset && offset < h.end)
1250    }
1251
1252    /// The AST breadcrumb at the caret (root → deepest), e.g.
1253    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1254    pub fn breadcrumb(&mut self) -> String {
1255        match self.editor.ancestors_at(self.caret) {
1256            Ok(chain) => chain
1257                .iter()
1258                .map(|m| m.kind.as_str())
1259                .collect::<Vec<_>>()
1260                .join(" › "),
1261            Err(_) => String::new(),
1262        }
1263    }
1264
1265    // ── editing ──────────────────────────────────────────────────────────────
1266
1267    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1268    /// after it. The public form of the internal splice — a pixel frontend that
1269    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1270    /// edits through this, the same twig `edit_range` the caret ops use.
1271    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1272        self.splice(start, end, text, EditKind::Other);
1273    }
1274
1275    /// Insert typed `text` at the caret, replacing the selection if there is one.
1276    /// A single typed character coalesces with the run of typing before it; a
1277    /// newline or a multi-character insert is its own undo step.
1278    ///
1279    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1280    pub fn insert(&mut self, text: &str) {
1281        // Typing against a block picture would dissolve it — see
1282        // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1283        // what the caret was standing beside stays a picture.
1284        self.open_paragraph_at_block_media(text);
1285        // Armed sticky marks (⌘b with no selection) turn the next typed text
1286        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1287        // the exception: it takes no mark of its own and keeps the delta armed
1288        // for the character behind it — see `insert_space_with_marks`.
1289        let pending = self.pending_here();
1290        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1291            if text.trim().is_empty() {
1292                self.insert_space_with_marks(self.caret, text, pending);
1293            } else {
1294                self.insert_with_marks(self.caret, text, pending);
1295            }
1296            return;
1297        }
1298        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1299        // anything that would open markup, so a Diaryx user never mints
1300        // formatting by keyboard (it comes from commands instead). The other two
1301        // rungs of the ladder author markup from what you type, which is the
1302        // whole difference between them and this one. Only in the rendered view
1303        // (source view is for typing raw markup) and only where the format has a
1304        // literal spelling at all: escaping is a backslash before a byte from the
1305        // format's own alphabet, and a format with no such alphabet (HTML escapes
1306        // with entities, XML spells nothing) would have `\&` written into it,
1307        // which is two literal characters and not an escape. Marks (⌘b) still
1308        // format — that path returned above; and leaf's own structural inserts go
1309        // through `insert_raw`, never here, so a list marker or quote gutter is
1310        // written as the markup it is.
1311        if !self.markup_mode.authors()
1312            && self.view == View::Wysiwyg
1313            && !text.is_empty()
1314            && self.supports(Gesture::InsertLiteral)
1315        {
1316            self.insert_literal_typed(text);
1317            return;
1318        }
1319        self.insert_raw(text);
1320    }
1321
1322    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1323    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1324    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1325    /// markup by design and must not be escaped.
1326    fn insert_raw(&mut self, text: &str) {
1327        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1328        self.splice(s, e, text, typed_edit_kind(text));
1329    }
1330
1331    /// Open a paragraph for text about to be inserted at one of a block media's
1332    /// two caret stops, and leave the caret standing in it.
1333    ///
1334    /// A block image is a paragraph whose entire content is the picture, and the
1335    /// caret's only homes on it are in front of it and just past it (see
1336    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1337    /// *that* paragraph — and a paragraph holding anything besides the image is
1338    /// no longer a block image but a line of text with an inline one in it. The
1339    /// frontend that was painting a photo there paints a text run instead; the
1340    /// picture is still in the file, and nothing said a word. Those two offsets
1341    /// are also exactly where a click on the picture lands, so the whole accident
1342    /// is one tap and one keystroke.
1343    ///
1344    /// So the break goes in first and the text lands in the new empty paragraph —
1345    /// what pressing Return before typing would have done, which is a habit no
1346    /// one should have to learn from losing a photo. A no-op everywhere else, and
1347    /// over a selection (which is replaced, not joined into).
1348    ///
1349    /// A picture inside a quote or a list leaves its container, because `\n\n`
1350    /// ends the block. The alternative is worse: the `\n> ` / next-item
1351    /// continuation [`newline`](Self::newline) writes stays in the same
1352    /// *paragraph*, which is the thing being prevented.
1353    ///
1354    /// Only in the rendered view. Source view is for typing raw markup, where
1355    /// putting a character against an image is exactly what it looks like.
1356    fn open_paragraph_at_block_media(&mut self, text: &str) {
1357        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1358            return;
1359        }
1360        if self.selection().is_some() {
1361            return;
1362        }
1363        // The map may be a revision behind (nothing has drawn since the last
1364        // edit), and this asks it about offsets — a stale answer would splice a
1365        // break into the wrong place. Free when it is already current, which it
1366        // is whenever a frontend drew a frame between keystrokes.
1367        self.rebuild_map();
1368        let at = self.caret;
1369        let Some((side, _)) = self.vmap.block_media_stop(at) else {
1370            return;
1371        };
1372        if !self.splice(at, at, "\n\n", EditKind::Other) {
1373            return;
1374        }
1375        // The break is part of the keystroke, not an edit of its own: leave the
1376        // run marked as typing so the character about to arrive folds into it and
1377        // one undo puts the document back the way it was found. (A paste, or a
1378        // multi-character insert, is `EditKind::Other` and stays its own step —
1379        // as it would have been anywhere else in the document.)
1380        self.last_edit_kind = Some(EditKind::Insert);
1381        if side == MediaStop::Before {
1382            // The break went in above the picture and the caret rode to the end
1383            // of it — which is still hard against the picture. Step back onto the
1384            // blank line it opened, so the text lands above rather than in front.
1385            self.caret = at;
1386        }
1387    }
1388
1389    /// A delete key pressed at one of a block picture's two caret stops, handled
1390    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1391    /// the key was consumed.
1392    ///
1393    /// The caret rests in front of a block image and just past it, never inside
1394    /// its markup — which the rendered view doesn't show. So the byte a delete
1395    /// key nominally takes there is one the writer cannot see, and taking it
1396    /// leaves the picture as broken markup rather than as anything anyone asked
1397    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1398    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1399    /// missing from a document with nobody having touched it — the same
1400    /// dissolution [`open_paragraph_at_block_media`](Self::open_paragraph_at_block_media)
1401    /// prevents from the typing side, and it cost this repository's own test vault
1402    /// a photo before it was found.
1403    ///
1404    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1405    /// when it is behind the caret, Delete when it is in front — which is what
1406    /// every editor does with an embed, and one undo away. The key aimed *away*
1407    /// from it would otherwise delete the paragraph break and merge a neighbour
1408    /// into the picture's own paragraph, which dissolves it just as surely; it
1409    /// steps the caret over the boundary instead and leaves the
1410    /// next press to delete in the block it has reached — the same "first press
1411    /// steps out of the atom, second press deletes" every delete key here gets,
1412    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1413    /// above, and reaches it on the second press rather than taking the break and
1414    /// the picture with it on the first).
1415    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1416        // The map answers about offsets, so it has to be this revision's — see
1417        // the same call in `open_paragraph_at_block_media`.
1418        self.rebuild_map();
1419        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1420            return false;
1421        };
1422        let aimed_at_it = side
1423            == if forward {
1424                MediaStop::Before
1425            } else {
1426                MediaStop::After
1427            };
1428        if !aimed_at_it {
1429            let over = if forward {
1430                self.vmap.stop_after(self.caret)
1431            } else {
1432                self.vmap.stop_before(self.caret)
1433            };
1434            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1435                self.caret = off;
1436                self.anchor = None;
1437                self.goal_col = None;
1438            }
1439            return true;
1440        }
1441        // Take the break that held the picture apart from its neighbour with it,
1442        // so the delete doesn't leave a blank paragraph standing where the
1443        // picture was. The last arm is a picture that is the whole document.
1444        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1445            (span.start - 2, span.end)
1446        } else if self.source[span.end..].starts_with("\n\n") {
1447            (span.start, span.end + 2)
1448        } else {
1449            (span.start, span.end)
1450        };
1451        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1452        true
1453    }
1454
1455    /// The Hidden-mode typing path: replace any selection, then insert `text`
1456    /// escaped so it stays literal. When it replaces a selection the two edits
1457    /// fold into one undo step, so an overwrite undoes atomically (and restores
1458    /// the selection) exactly as a plain one does.
1459    fn insert_literal_typed(&mut self, text: &str) {
1460        let kind = typed_edit_kind(text);
1461        match self.selection() {
1462            Some((s, e)) => {
1463                if !self.splice(s, e, "", EditKind::Other) {
1464                    return;
1465                }
1466                // Typing over a whole marked run takes its delimiters with it
1467                // (the empty content couldn't hold them — see
1468                // `repair_mark_edges`) and leaves its marks armed at the caret.
1469                // The text taking the run's place inherits them, exactly as it
1470                // would have by landing inside a run that survived.
1471                let pending = self.pending_here();
1472                if !pending.is_empty() && !text.trim().is_empty() {
1473                    self.insert_with_marks(self.caret, text, pending);
1474                    return;
1475                }
1476                self.insert_literal_at(self.caret, text, kind, true);
1477            }
1478            None => {
1479                self.insert_literal_at(self.caret, text, kind, false);
1480            }
1481        }
1482    }
1483
1484    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1485    /// at a collapsed caret, but only while the caret still stands where they
1486    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1487    /// never styles text it wasn't meant for.
1488    fn pending_here(&self) -> InlineMarks {
1489        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1490            self.pending_marks
1491        } else {
1492            InlineMarks::empty()
1493        }
1494    }
1495
1496    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1497    /// this, so "start bold here" only ever applies at the exact spot it was
1498    /// asked for.
1499    fn clear_pending(&mut self) {
1500        self.pending_marks = InlineMarks::empty();
1501        self.pending_at = None;
1502    }
1503
1504    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1505    /// force is wrapped around the freshly typed text; a mark the caret already
1506    /// stands inside is *shed* — the text is inserted past the run's end so it
1507    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1508    /// added runs, so continued typing inherits the marks with no re-wrapping,
1509    /// and the delta is cleared: the marks now live in the document, not here.
1510    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1511        let base = self.mark_spans_at(at);
1512        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1513        // Nothing to shed, and a run of exactly these marks standing just behind
1514        // the caret: carry on writing *that* run rather than opening a second
1515        // one beside it.
1516        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1517            return;
1518        }
1519        // Shed the marks we're turning off: step the insertion point past the
1520        // end of each run the caret sits in, so the new text falls outside it.
1521        let mut ins_at = at;
1522        for (kind, span) in &base {
1523            if marks.contains(*kind) {
1524                ins_at = ins_at.max(span.end);
1525            }
1526        }
1527        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1528            return;
1529        }
1530        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1531        // is the content every added mark wraps.
1532        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1533        for kind in marks.iter() {
1534            if !base_set.contains(kind) {
1535                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1536                cs = ncs;
1537                ce = nce;
1538            }
1539        }
1540        self.caret = ce.min(self.source.len());
1541        self.anchor = None;
1542        self.last_edit_kind = None;
1543        // Realised: the marks are in the document now, and the caret sits inside
1544        // them, so there is no delta left to carry. Arm nothing, but remember the
1545        // spot so a *further* toggle before typing starts a clean delta here.
1546        self.pending_marks = InlineMarks::empty();
1547        self.pending_at = Some(self.caret);
1548        self.clamp_caret();
1549        self.record_caret();
1550    }
1551
1552    /// Carry on the marked run just behind `at` — moving its closing delimiters
1553    /// out past the new text — instead of opening a second run of the same marks
1554    /// beside it. Returns whether it did.
1555    ///
1556    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1557    /// A space typed after a bold word steps the caret out of the run, because
1558    /// `**bold **` is not bold; the next character has to step back *in*, or the
1559    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1560    /// runs that read the same to a reader but spell the file in a way nobody
1561    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1562    /// words it isn't marking), and the marks behind it must be exactly the ones
1563    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1564    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1565        if text.is_empty() || text.trim() != text {
1566            return false;
1567        }
1568        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1569        // Walk in through the delimiters stacked at that point, innermost last:
1570        // `***both*** ` closes two runs with one `***`, and rejoining means
1571        // getting behind all of them.
1572        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1573        while let Some((kind, content_end)) = self
1574            .editor
1575            .ancestors_at(prev_boundary(&self.source, cut))
1576            .unwrap_or_default()
1577            .into_iter()
1578            .filter(|m| m.span.end == cut)
1579            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1580        {
1581            if content_end >= cut {
1582                break; // a mark with no closing delimiter to step behind
1583            }
1584            kinds.insert(kind);
1585            cut = content_end;
1586        }
1587        if cut == gap_at || kinds != marks {
1588            return false;
1589        }
1590        // Re-spell the tail: the gap, then the new text, then the delimiters that
1591        // used to close in front of them — read out of the document rather than
1592        // written from a table, so whatever twig spells them with is what moves.
1593        let tail = format!(
1594            "{}{text}{}",
1595            &self.source[gap_at..at],
1596            &self.source[cut..gap_at]
1597        );
1598        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1599            return false;
1600        }
1601        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1602        self.anchor = None;
1603        self.last_edit_kind = None;
1604        self.pending_marks = InlineMarks::empty();
1605        self.pending_at = Some(self.caret);
1606        self.clamp_caret();
1607        self.record_caret();
1608        true
1609    }
1610
1611    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1612    /// never itself wrapped: a mark around a space draws nothing a reader can
1613    /// see, and in Markdown and Djot it draws its own delimiters instead
1614    /// (`** **`). So the space goes in unmarked — outside any run the armed
1615    /// marks are shedding — and the marks stay armed for the character after it,
1616    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1617    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1618        let base = self.mark_spans_at(at);
1619        // What the *next* character carries: the armed delta resolved against the
1620        // marks in force here, which the space must not quietly drop.
1621        let want = base
1622            .iter()
1623            .map(|(k, _)| *k)
1624            .collect::<InlineMarks>()
1625            .xor(marks);
1626        let mut ins_at = at;
1627        for (kind, span) in &base {
1628            if marks.contains(*kind) {
1629                ins_at = ins_at.max(span.end);
1630            }
1631        }
1632        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1633            return;
1634        }
1635        self.rearm(want);
1636        self.record_caret();
1637    }
1638
1639    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1640    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1641    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1642    /// added split evenly around the content — half the growth on each side.
1643    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1644        match self.editor.toggle_inline(s, e, kind) {
1645            Ok(change) => {
1646                self.last_edit_kind = None;
1647                self.refresh();
1648                self.dirty = self.source != self.clean_source;
1649                let added = (change.new.end - change.new.start).saturating_sub(e - s);
1650                let half = added / 2;
1651                (change.new.start + half, change.new.end - half)
1652            }
1653            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1654            // rather than lose the keystroke.
1655            Err(e2) => {
1656                self.status = Some(format!("{kind:?}: {e2}"));
1657                (s, e)
1658            }
1659        }
1660    }
1661
1662    /// The safe offset to splice a block-level break at, given a caret that may
1663    /// sit exactly between an inline mark's content and its own closing
1664    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1665    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1666    /// with nothing following it on the line: the closing `**` renders no
1667    /// glyph of its own, so the caret's "end of line" offset lands right
1668    /// before it). Splicing a paragraph/list/quote break at `off` itself would
1669    /// sever the delimiter from its content, stranding it alone on the new
1670    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1671    /// nested marks closing at the same point (`**_x_**`) all clear together.
1672    /// A no-op everywhere else — mid-run, or past real trailing content, no
1673    /// mark's `content_span` ends exactly at `off`.
1674    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1675        let off = off.min(self.source.len());
1676        self.editor
1677            .ancestors_at(off)
1678            .unwrap_or_default()
1679            .into_iter()
1680            .filter(|m| inline_kind(&m.kind).is_some())
1681            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1682            .map(|m| m.span.end)
1683            .max()
1684            .unwrap_or(off)
1685    }
1686
1687    /// The offset a *delete* aimed at the character before `off` should stop at,
1688    /// when `off` is the start of a run's text and the bytes behind it are that
1689    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1690    /// byte behind the caret at the start of a bold word is not a character the
1691    /// writer can see, let alone one they aimed Backspace at: taking it leaves
1692    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1693    /// delete steps over the whole delimiter to the visible character in front of
1694    /// it instead. Walks out to the *outermost* mark opening there, so
1695    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1696    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1697        let off = off.min(self.source.len());
1698        self.editor
1699            .ancestors_at(off)
1700            .unwrap_or_default()
1701            .into_iter()
1702            .filter(|m| inline_kind(&m.kind).is_some())
1703            .filter(|m| {
1704                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
1705            })
1706            .map(|m| m.span.start)
1707            .min()
1708            .unwrap_or(off)
1709    }
1710
1711    /// `off` moved *inside* the run whose closing delimiters end there — the
1712    /// other offset the rich view draws in the same place, since a `**` renders
1713    /// no glyph of its own. `**bold**` has a caret home on each side of its
1714    /// closing delimiter, one column apart on screen and eight bytes and a whole
1715    /// run apart in the file, and a plain ← lands on the outer one whenever a
1716    /// space follows the phrase. The inner one is what the writer is pointing at
1717    /// there: the end of their bold word. Walks in through every mark closing at
1718    /// that point, innermost last, so `***both***` lands inside both. A no-op
1719    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1720    fn step_inside_close_delims(&mut self, off: usize) -> usize {
1721        let mut off = off.min(self.source.len());
1722        loop {
1723            let inner = self
1724                .editor
1725                .ancestors_at(prev_boundary(&self.source, off))
1726                .unwrap_or_default()
1727                .into_iter()
1728                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1729                .filter_map(|m| m.content_span.clone().map(|c| c.end))
1730                .filter(|&end| end < off)
1731                .max();
1732            match inner {
1733                Some(end) => off = end,
1734                None => return off,
1735            }
1736        }
1737    }
1738
1739    /// The mirror at the opening edge: `off` moved inside the run whose
1740    /// delimiters *start* there, onto the first character of its text. See
1741    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1742    fn step_inside_open_delims(&mut self, off: usize) -> usize {
1743        let mut off = off.min(self.source.len());
1744        loop {
1745            let inner = self
1746                .editor
1747                .ancestors_at(off)
1748                .unwrap_or_default()
1749                .into_iter()
1750                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
1751                .filter_map(|m| m.content_span.clone().map(|c| c.start))
1752                .filter(|&start| start > off)
1753                .min();
1754            match inner {
1755                Some(start) => off = start,
1756                None => return off,
1757            }
1758        }
1759    }
1760
1761    /// The inline mark kinds whose span covers `off`, each with that span — the
1762    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
1763    /// ids instead. Used to shed a mark by stepping past the end of its run.
1764    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
1765        let off = off.min(self.source.len());
1766        self.editor
1767            .ancestors_at(off)
1768            .unwrap_or_default()
1769            .into_iter()
1770            .filter(|m| off < m.span.end)
1771            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
1772            .collect()
1773    }
1774
1775    /// Insert clipboard `text` at the caret, replacing the selection if there is
1776    /// one — always its own undo step, whatever its length.
1777    ///
1778    /// Provenance is the whole point, and only the caller has it. `insert` reads
1779    /// a lone character as a keystroke and folds it into the run around it,
1780    /// which is right for typing and wrong for a one-character paste: that paste
1781    /// would vanish mid-run on an undo it was never part of, and the characters
1782    /// the user actually typed would go with it. Length can't tell the two
1783    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
1784    /// caller comes through is what says which happened.
1785    pub fn paste(&mut self, text: &str) {
1786        // Pasting against a block picture dissolves it exactly as typing does,
1787        // and for the same reason — see `open_paragraph_at_block_media`.
1788        self.open_paragraph_at_block_media(text);
1789        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1790        self.splice(s, e, text, EditKind::Other);
1791    }
1792
1793    /// Replace `[start, end)` with `text` as one step of an IME composition —
1794    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
1795    /// folds into a single undo.
1796    ///
1797    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
1798    /// dozen calls here, each replacing the last one's provisional bytes, and an
1799    /// undo step per call means undoing a word means pressing ⌘Z until the reading
1800    /// unspools backwards through kana — the intermediate states were never text
1801    /// the user wrote. Only the frontend knows a call is provisional (the bytes
1802    /// look like any other edit), so the door the caller comes through is what
1803    /// says so, exactly as it is for [`paste`](Self::paste) versus
1804    /// [`insert`](Self::insert).
1805    ///
1806    /// Pair with [`end_composition`](Self::end_composition), or the *next*
1807    /// composition folds into this one.
1808    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
1809        self.splice(start, end, text, EditKind::Compose);
1810    }
1811
1812    /// Close the open composition run, so the next one is its own undo step.
1813    /// Call when the IME commits or withdraws a composition.
1814    ///
1815    /// Only clears a *composition* run: a frontend that reports an end it never
1816    /// began (some IMEs unmark unprompted) would otherwise split the run of
1817    /// typing around it into two undo steps for no reason the user can see.
1818    pub fn end_composition(&mut self) {
1819        if self.last_edit_kind == Some(EditKind::Compose) {
1820            self.last_edit_kind = None;
1821        }
1822    }
1823
1824    // ── the clipboard's rich flavor ──────────────────────────────────────────
1825
1826    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
1827    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
1828    /// nothing is selected, or when the selection doesn't render (the caller
1829    /// still has [`selected_text`](Self::selected_text), which is what to publish
1830    /// as `text/plain` either way).
1831    ///
1832    /// **The fragment is a source substring, and that is the honest limit here.**
1833    /// It's parsed standalone, so a selection whose meaning depends on its
1834    /// surroundings converts as what it literally says rather than what it looks
1835    /// like on screen: half a list item is a paragraph, a row torn out of a table
1836    /// is the text of a row, the `**` of a bold run selected without its closing
1837    /// `**` is two asterisks. Every one of those still *renders* — there's no
1838    /// error to report — it just renders as the fragment and not as the document.
1839    /// Widening the range to whole blocks would publish text the user didn't
1840    /// select, which is a worse lie than a fragment being a fragment; the plain
1841    /// flavor has the same substring, so the two flavors at least agree.
1842    pub fn selection_html(&mut self) -> Option<String> {
1843        let (start, end) = self.selection()?;
1844        let inline = self.selection_is_inline(start, end);
1845        let html = html::render_fragment(&self.source[start..end], self.format)?;
1846        Some(match inline {
1847            true => html::strip_sole_paragraph(html),
1848            false => html,
1849        })
1850    }
1851
1852    /// Paste the clipboard's `text/html` flavor, converting it to this document's
1853    /// format first. Its own undo step, like any [`paste`](Self::paste).
1854    ///
1855    /// Returns whether it landed. `false` means the HTML didn't convert to
1856    /// anything worth pasting — the caller should fall back to the plain flavor
1857    /// rather than treat it as an error. The `html` module has the full list of
1858    /// what that covers: a table twig won't build, markup it doesn't recognise,
1859    /// an empty result.
1860    pub fn paste_html(&mut self, html: &str) -> bool {
1861        match html::parse_fragment(html, self.format) {
1862            Some(source) => {
1863                self.paste(&source);
1864                true
1865            }
1866            None => false,
1867        }
1868    }
1869
1870    /// Does the selection live *inside* a single top-level block?
1871    ///
1872    /// The question [`selection_html`](Self::selection_html) needs and the
1873    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
1874    /// whether the user selected one word of a sentence or a whole paragraph, and
1875    /// only the document knows which. Selecting a word and pasting into Docs
1876    /// should extend the line you paste into; selecting the paragraph should make
1877    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
1878    /// is an artifact of standalone parsing), and one that covers a whole block —
1879    /// or spans two — keeps its structure.
1880    ///
1881    /// Reads the block from twig rather than guessing from the bytes:
1882    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
1883    /// block containing an offset, and two ends inside the same one cannot have
1884    /// crossed a block boundary.
1885    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
1886        // The last *character*, not `end - 1`: the selection's end is exclusive
1887        // and may sit mid-codepoint's-worth of bytes past the last char.
1888        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
1889            return false;
1890        };
1891        let (Some(head), Some(tail)) =
1892            (self.top_block_span(start), self.top_block_span(start + off))
1893        else {
1894            return false;
1895        };
1896        head == tail && !(start <= head.start && end >= head.end)
1897    }
1898
1899    /// The byte span of the top-level block containing `offset`, or `None` at an
1900    /// offset that belongs to no block (the blank line between two of them).
1901    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
1902        self.editor
1903            .ancestors_at(offset)
1904            .ok()?
1905            .get(1)
1906            .map(|m| m.span.clone())
1907    }
1908
1909    // ── indentation ──────────────────────────────────────────────────────────
1910
1911    /// One indent level.
1912    ///
1913    /// Two spaces, not the four both frontends type for Tab today, because in a
1914    /// markdown document four columns isn't a width — it's a *meaning*. Four
1915    /// spaces at the head of a line is markdown's indented-code-block marker, so
1916    /// one Tab on a paragraph would reparse it into code and style it as such;
1917    /// two cannot, and the line stays the prose it was. Two is also exactly
1918    /// where a `- ` bullet's content starts, so an indented line lands under its
1919    /// parent item's text instead of beside it — the column a list-aware indent
1920    /// has to hit anyway, which keeps this width from being relitigated later.
1921    const INDENT: &'static str = "  ";
1922
1923    /// Indent the selected lines — or the caret's line, with no selection — by
1924    /// one level (Tab).
1925    pub fn indent(&mut self) {
1926        self.reindent(true);
1927        // Nesting changes an ordered list's numbering (the nested item restarts,
1928        // its old siblings resume) — keep the source markers in step.
1929        self.renumber_here();
1930        // Nesting an empty `-` item under a text line reparses that text as a
1931        // setext heading; swap the dash for a `*` before it can (a no-op unless
1932        // the collapse actually happened).
1933        self.avoid_setext_collapse();
1934    }
1935
1936    /// Take one indent level back off the selected lines, or the caret's line
1937    /// (Shift+Tab). A line with no indentation is left exactly as it is.
1938    ///
1939    /// A line with *less* than a full level gives back what it has rather than
1940    /// refusing: outdent's job is to walk a line left, and real documents — hand
1941    /// written, or reflowed by some other editor — are full of indentation that
1942    /// was never a clean multiple of anything. Refusing there would strand the
1943    /// line at a depth Shift+Tab couldn't undo.
1944    pub fn outdent(&mut self) {
1945        self.reindent(false);
1946        self.renumber_here();
1947    }
1948
1949    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
1950    ///
1951    /// One splice across the whole line range, never one per line: a Tab is one
1952    /// thing the user did, so it has to be one undo step and one reparse. Per
1953    /// line, twig would reparse the document once per line and leave a stack of
1954    /// steps that Shift+⌘Z walks back one line at a time.
1955    fn reindent(&mut self, add: bool) {
1956        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
1957        let start = source_line_range(&self.source, sel_start).start;
1958        let end = source_line_range(&self.source, sel_end).end;
1959        let region = self.source[start..end].to_string();
1960        let lines: Vec<&str> = region.split('\n').collect();
1961        // A blank line has no text to move, and padding it would leave nothing
1962        // but trailing whitespace — but Tab on a blank line *is* a request for
1963        // indentation to type into, so the skip only applies where the op has
1964        // other lines to do real work on.
1965        let skip_blank = add && lines.len() > 1;
1966
1967        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
1968        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
1969        let mut line_off = start;
1970        for (i, full) in lines.iter().enumerate() {
1971            if i > 0 {
1972                out.push('\n');
1973            }
1974            // A list item moves by having its whole leading prefix *replaced*,
1975            // never by having spaces pushed in front of the line. twig spells
1976            // both prefixes, so the quote markers, the parent's indent and an
1977            // ordered marker's extra column all come out right without leaf
1978            // measuring any of them — and a line that only looks like an item
1979            // (a Djot continuation) reports no marker and is left to the plain
1980            // path, where a Tab is just a Tab.
1981            let marker = self.list_marker_on_line(line_off);
1982            let own = marker
1983                .as_ref()
1984                .map(|m| m.marker_start - m.line_start)
1985                .unwrap_or(0);
1986            let delta = if add {
1987                if skip_blank && full.trim().is_empty() {
1988                    out.push_str(full);
1989                    0
1990                } else if marker.is_some() && self.first_item_of_list(line_off) {
1991                    // The first item of a list has no preceding sibling to nest
1992                    // under, so a Tab here can't spell a sub-list — twig would
1993                    // reparse the shoved-over marker as the same list, only
1994                    // indented, which Shift+Tab then can't cleanly undo. Leave the
1995                    // item where it is, the way every list editor refuses to
1996                    // over-indent a list's first line.
1997                    out.push_str(full);
1998                    0
1999                } else if marker.is_some() {
2000                    // Nesting means standing where a *continuation* of this line
2001                    // would stand: past the parent's marker, inside its content
2002                    // column. That is `continuation_prefix`, less a checkbox.
2003                    let new = self.nesting_prefix_at(line_off);
2004                    let delta = new.len() as isize - own as isize;
2005                    out.push_str(&new);
2006                    out.push_str(&full[own..]);
2007                    delta
2008                } else {
2009                    out.push_str(Self::INDENT);
2010                    out.push_str(full);
2011                    Self::INDENT.len() as isize
2012                }
2013            } else if marker.is_some() {
2014                // Unnesting is the mirror: stand where the parent item's own
2015                // line starts, which drops exactly the level it contributed.
2016                let new = self.outdent_prefix_at(line_off);
2017                let delta = new.len() as isize - own as isize;
2018                out.push_str(&new);
2019                out.push_str(&full[own..]);
2020                delta
2021            } else {
2022                // A plain line gives back the ordinary step.
2023                let strip = outdent_width(full, Self::INDENT.len());
2024                out.push_str(&full[strip..]);
2025                -(strip as isize)
2026            };
2027            deltas.push(delta);
2028            line_off += full.len() + 1;
2029        }
2030        // Nothing to give back. Returning before the splice keeps an outdent at
2031        // column zero from spending an undo step on a document it never changed.
2032        if deltas.iter().all(|d| *d == 0) {
2033            return;
2034        }
2035
2036        // Every line's text keeps its offset *within the line*, so the caret is
2037        // remapped by its column, not by its byte offset — which the prefixes on
2038        // the lines above it have already invalidated.
2039        let remap = |off: usize| -> usize {
2040            let (mut old_ls, mut new_ls) = (start, start);
2041            for (line, delta) in lines.iter().zip(&deltas) {
2042                let old_le = old_ls + line.len();
2043                let new_len = (line.len() as isize + delta) as usize;
2044                if off <= old_le {
2045                    let col = (off - old_ls) as isize;
2046                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2047                }
2048                old_ls = old_le + 1;
2049                new_ls += new_len + 1;
2050            }
2051            start + out.len()
2052        };
2053        let placed = match self.selection() {
2054            // Keep the rewritten region selected, the way a container toggle
2055            // keeps its own: it leaves a second Tab aimed at the same lines
2056            // rather than at whatever the shifted offsets now happen to cover.
2057            Some(_) => (start + out.len(), Some(start)),
2058            None => (remap(self.caret), None),
2059        };
2060
2061        // A rolled-back splice leaves the old source in place, where every offset
2062        // computed above addresses text that was never written.
2063        if !self.splice(start, end, &out, EditKind::Other) {
2064            return;
2065        }
2066        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2067        // rewrite is the last line's end — nowhere the caret was. Place it, then
2068        // re-record the caret so this is the state redo restores, not the one
2069        // `splice` left behind from the `Change`.
2070        self.caret = placed.0.min(self.source.len());
2071        self.anchor = placed.1;
2072        self.clamp_caret();
2073        self.record_caret();
2074    }
2075
2076    /// The Enter key.
2077    ///
2078    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2079    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2080    /// caret is in decides what actually gets written.
2081    ///
2082    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2083    ///                            block at the caret and reopens its container
2084    ///   - list item            → likewise: the next item, its indent, quote
2085    ///                            prefix and `[ ]` box all reproduced by twig —
2086    ///                            except an *empty* item, which exits the list
2087    ///   - block quote          → likewise: a new paragraph inside the quote
2088    ///   - heading              → a new *paragraph*, not another heading
2089    ///   - code block           → a literal newline (stay in the block)
2090    ///   - blank line           → a literal newline (one Backspace undoes it)
2091    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2092    ///                            visible line
2093    ///
2094    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2095    /// and it is better at it: it drops the whitespace the caret was sitting in
2096    /// front of instead of stranding it at the head of the second half, and it
2097    /// knows continuations leaf's marker scan never covered — a checklist item
2098    /// continues as an *unchecked* checklist item rather than a plain bullet.
2099    ///
2100    /// The exceptions above are exceptions because `split_block` is either wrong
2101    /// there or refuses: parting a fence yields two fences with the code split
2102    /// between them, parting a heading yields a second heading where every editor
2103    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2104    /// table all report an error rather than a split.
2105    pub fn newline(&mut self) {
2106        if self.view == View::Source {
2107            self.insert_raw("\n");
2108            return;
2109        }
2110        // Enter over a selection replaces it with a paragraph break.
2111        if let Some((s, e)) = self.selection() {
2112            self.splice(s, e, "\n\n", EditKind::Other);
2113            return;
2114        }
2115        // A caret resting exactly between an inline mark's content and its own
2116        // closing delimiter (`**bold**` with nothing after it on the line —
2117        // the WYSIWYG caret's natural end-of-line position) must not splice a
2118        // block break there: every path below eventually does via
2119        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2120        // delimiter would strand it alone on the new line.
2121        self.caret = self.skip_trailing_close_delims(self.caret);
2122        // The block the caret is in. `block_offset_for_caret` nudges off a line
2123        // end (where the caret sits at the doc level); on a bare line (e.g. an
2124        // empty list item) fall back to the caret so the enclosing list/quote is
2125        // still visible in the ancestors.
2126        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2127        let kinds: Vec<Kind> = self
2128            .editor
2129            .ancestors_at(off)
2130            .map(|c| c.into_iter().map(|m| m.kind).collect())
2131            .unwrap_or_default();
2132        let has = |k: Kind| kinds.contains(&k);
2133
2134        if has(Kind::CodeBlock) {
2135            self.insert_raw("\n");
2136            return;
2137        }
2138        // An *empty* list item exits the list — the standard double-Enter — which
2139        // `split_block` reports as an error rather than a split (there is no
2140        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2141        // the AST gate — it answers from the tree, so a `- ` that reads as a
2142        // marker byte-for-byte but opens no item (a setext underline, a Djot
2143        // continuation line) never reaches here.
2144        if let Some(marker) = self.list_marker_on_line(self.caret)
2145            && self.item_is_empty(&marker)
2146        {
2147            self.exit_list(&marker);
2148            return;
2149        }
2150        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2151        // not another full paragraph break — so it moves down one line and one
2152        // Backspace undoes it, not two. (`split_block` errors here too.)
2153        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2154        let line_end = self.source[self.caret..]
2155            .find('\n')
2156            .map_or(self.source.len(), |i| self.caret + i);
2157        if self.source[line_start..line_end].trim().is_empty() {
2158            self.insert_raw("\n");
2159            return;
2160        }
2161        // In `Preserve` flow a soft break is a *visible* line the author means to
2162        // make, so Enter writes a single `\n` and typing continues the same
2163        // paragraph on the next line — the behaviour of an ordinary text editor.
2164        // A second Enter then lands on the blank line above and takes the
2165        // empty-line branch, so double-Enter still promotes to a full paragraph
2166        // break; and Backspace, which deletes a lone `\n` over a soft break,
2167        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2168        // render as an invisible space, so Enter keeps making the paragraph break
2169        // that actually shows.
2170        //
2171        // Only in running prose. A list or a quote has a continuation of its own
2172        // to write, and a `\n` there is not a soft line but a lost container.
2173        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2174        if self.line_flow == LineFlow::Preserve && !in_container {
2175            self.insert_raw("\n");
2176            return;
2177        }
2178        // A heading gets a *paragraph*, never a second heading: Enter at the end
2179        // of a title is how every editor is asked for the body under it, and
2180        // `split_block` would repeat the `#` instead. Whitespace at the split
2181        // point goes with the break rather than opening the new paragraph, which
2182        // is what `split_block` does everywhere else.
2183        if has(Kind::Heading) {
2184            let mut end = self.caret;
2185            while self.source.as_bytes().get(end) == Some(&b' ') {
2186                end += 1;
2187            }
2188            self.splice(self.caret, end, "\n\n", EditKind::Other);
2189            return;
2190        }
2191        self.split_block_here();
2192    }
2193
2194    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2195    /// the caret in the second half.
2196    ///
2197    /// twig reopens whatever the first half was inside of — the bullet with its
2198    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2199    /// reason this replaced the markup leaf used to spell from the line's bytes.
2200    /// It renumbers nothing, though: a new item mid-list is written with its
2201    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2202    /// behind it, folded into the same undo step.
2203    ///
2204    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2205    /// shape still moves the caret down rather than swallowing the keystroke.
2206    fn split_block_here(&mut self) {
2207        match self.editor.split_block(self.caret) {
2208            Ok(change) => {
2209                self.last_edit_kind = None;
2210                self.refresh();
2211                self.anchor = None;
2212                self.caret = change.new.end;
2213                self.dirty = self.source != self.clean_source;
2214                self.status = None;
2215                self.clamp_caret();
2216                self.record_caret();
2217                // Aimed at the new block's *start*: the caret twig leaves is one
2218                // past the marker it wrote, where there is no list in reach.
2219                self.renumber_at(change.new.start);
2220            }
2221            Err(_) => self.insert_raw("\n\n"),
2222        }
2223    }
2224
2225    /// Whether the item on the marker's line carries no content — the shape
2226    /// double-Enter reads as "I'm done with this list."
2227    fn item_is_empty(&self, line: &ListMarker) -> bool {
2228        let content_start = line.content_start().min(self.source.len());
2229        let line_end = self.source[self.caret..]
2230            .find('\n')
2231            .map(|i| self.caret + i)
2232            .unwrap_or(self.source.len());
2233        self.source[content_start..line_end.max(content_start)]
2234            .trim()
2235            .is_empty()
2236    }
2237
2238    /// Leave the list: replace the empty item's marker with a blank line, so the
2239    /// caret lands in a fresh paragraph below it.
2240    ///
2241    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2242    /// quote), and the caret's new line keeps the `> ` it was already behind —
2243    /// leaving the list without also leaving the quote.
2244    fn exit_list(&mut self, line: &ListMarker) {
2245        let prefix = self.quote_prefix_at(line.marker_start);
2246        let blank = prefix.trim_end();
2247        self.splice(
2248            line.line_start,
2249            self.caret,
2250            &format!("{blank}\n{prefix}"),
2251            EditKind::Other,
2252        );
2253    }
2254
2255    /// What a line continuing the containers at `off` has to open with — the
2256    /// quote markers reproduced, each enclosing item's marker as its width in
2257    /// spaces. Also the column a nested item's marker stands in, which is what
2258    /// makes it Tab's answer.
2259    fn continuation_prefix_at(&mut self, off: usize) -> String {
2260        self.editor
2261            .document()
2262            .and_then(|mut d| d.continuation_prefix(off))
2263            .map(|p| p.text)
2264            .unwrap_or_default()
2265    }
2266
2267    /// The column a *nested list* may open at inside the item at `off` — which
2268    /// is not always where the item's own text continues.
2269    ///
2270    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2271    /// it is markup a rich view hides, and the item's own wrapped text does
2272    /// stand past it. But a nested list may only open at the *list* marker's
2273    /// column, and four columns further in is an indented continuation of the
2274    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2275    /// So the box's own width goes back.
2276    ///
2277    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2278    /// reports the list marker's column apart from the box; `checked` is what
2279    /// says a box is there at all, so only its width is being measured here.
2280    fn nesting_prefix_at(&mut self, off: usize) -> String {
2281        let cont = self.continuation_prefix_at(off);
2282        let Some(item) = self.innermost_list_item(off) else {
2283            return cont;
2284        };
2285        if item.checked.is_none() {
2286            return cont;
2287        }
2288        let box_width = item
2289            .marker_span
2290            .and_then(|m| self.source.get(m))
2291            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2292            .unwrap_or(0);
2293        // The trailing columns are the ones the item's own marker contributed,
2294        // so trimming from the end leaves any quote prefix standing.
2295        cont[..cont.len().saturating_sub(box_width)].to_string()
2296    }
2297
2298    /// Where the line of the item *containing* the item at `off` begins — the
2299    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2300    /// parent contributed. The quote prefix alone for a top-level item, which
2301    /// has no level left to give.
2302    fn outdent_prefix_at(&mut self, off: usize) -> String {
2303        let items: Vec<usize> = self
2304            .editor
2305            .document()
2306            .and_then(|mut d| d.ancestors_at_caret(off))
2307            .map(|c| {
2308                c.into_iter()
2309                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2310                    .map(|m| m.span.start)
2311                    .collect()
2312            })
2313            .unwrap_or_default();
2314        // The second-innermost item is the parent; its own line's indent is the
2315        // target. `list_marker_on_line` gives that line's prefix directly.
2316        let parent = items.len().checked_sub(2).map(|i| items[i]);
2317        match parent.and_then(|p| self.list_marker_on_line(p)) {
2318            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2319            None => self.quote_prefix_at(off),
2320        }
2321    }
2322
2323    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2324    /// inside one, `"> > "` inside two.
2325    ///
2326    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2327    /// the `>` and the space after it are twig's spelling rather than leaf's.
2328    /// The whole line prefix can't answer this: it also carries the indent of
2329    /// whatever the quote holds, which a blank separator line must *not* repeat.
2330    fn quote_prefix_at(&mut self, off: usize) -> String {
2331        let Ok(chain) = self
2332            .editor
2333            .document()
2334            .and_then(|mut d| d.ancestors_at_caret(off))
2335        else {
2336            return String::new();
2337        };
2338        let quotes: Vec<usize> = chain
2339            .iter()
2340            .filter(|m| m.kind == Kind::BlockQuote)
2341            .map(|m| m.node_id as usize)
2342            .collect();
2343        let Ok(nodes) = self.editor.nodes() else {
2344            return String::new();
2345        };
2346        quotes
2347            .iter()
2348            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2349            .filter_map(|s| self.source.get(s))
2350            .collect()
2351    }
2352
2353    /// Whether the item at `off` sits inside another one — the test Backspace
2354    /// uses to choose between outdenting and dropping the marker.
2355    ///
2356    /// Counted from the AST rather than from the line's leading whitespace,
2357    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2358    fn item_is_nested(&mut self, off: usize) -> bool {
2359        self.editor
2360            .document()
2361            .and_then(|mut d| d.ancestors_at_caret(off))
2362            .map(|c| {
2363                c.into_iter()
2364                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2365                    .count()
2366                    > 1
2367            })
2368            .unwrap_or(false)
2369    }
2370
2371    /// The innermost list item containing `probe`, under twig's **caret**
2372    /// containment rule — a block's end is inside it.
2373    ///
2374    /// Half-open containment can't answer this. An empty item's span is exactly
2375    /// its marker, so the caret sitting after `- ` is one past the end and the
2376    /// item it is plainly in tests as out of reach; that is the shape
2377    /// double-Enter has to recognise to leave the list.
2378    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2379        let chain = self
2380            .editor
2381            .document()
2382            .and_then(|mut d| d.ancestors_at_caret(probe))
2383            .ok()?;
2384        let id = chain
2385            .iter()
2386            .rev()
2387            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2388            .node_id as usize;
2389        self.editor.nodes().ok()?.get(id).cloned()
2390    }
2391
2392    /// The list marker opening `off`'s line, per twig — `None` when that line
2393    /// opens no list item.
2394    ///
2395    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2396    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2397    /// and it is `None` on a *continuation* line, which opens nothing. That last
2398    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2399    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2400    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2401    /// parser knows which document it is looking at.
2402    ///
2403    /// The item's own marker is separated out via its
2404    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2405    /// the containers around it contribute and what the item does.
2406    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2407        let off = off.min(self.source.len());
2408        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2409        // The prefix belongs to a list only when an item's marker closes it —
2410        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2411        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2412        let marker = item.marker_span.clone()?;
2413        if marker.end != prefix.end {
2414            return None;
2415        }
2416        Some(ListMarker {
2417            line_start: prefix.start,
2418            marker_start: marker.start,
2419            text: self.source.get(prefix)?.to_string(),
2420        })
2421    }
2422
2423    /// Whether the list item on `line_start`'s line is the **first item** of its
2424    /// list — the one Tab must not nest, because nesting needs a preceding
2425    /// sibling to become the new parent and a first item has none. `false` for a
2426    /// line that isn't a list item, and for an item with a sibling above it (the
2427    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2428    /// the same in a setext underline that opens no list at all.
2429    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2430        let Some(marker) = self.list_marker_on_line(line_start) else {
2431            return false;
2432        };
2433        // Probe just inside the marker, where the item's own node is in reach —
2434        // the marker offset itself can resolve to the enclosing list, not the
2435        // `list_item`, whose span starts at the marker.
2436        let probe = marker.content_start().min(self.source.len());
2437        let Some(item) = self.innermost_list_item(probe) else {
2438            return false;
2439        };
2440        let Ok(nodes) = self.editor.nodes() else {
2441            return false;
2442        };
2443        match item.parent {
2444            // First when the parent list opens with this very item.
2445            Some(pid) => nodes
2446                .get(pid.0 as usize)
2447                .is_some_and(|p| p.first_child == Some(item.id)),
2448            // A parentless item is trivially the first (and only) one.
2449            None => true,
2450        }
2451    }
2452
2453    pub fn backspace(&mut self) {
2454        if let Some((s, e)) = self.selection() {
2455            self.splice(s, e, "", EditKind::Other);
2456            return;
2457        }
2458        // WYSIWYG: Backspace at the very start of a list item's content is a
2459        // structural key, not a character delete — it walks the "un-indent, then
2460        // un-list" ladder every list editor gives that keystroke (outdent a
2461        // nested item, strip a top-level one's marker to a paragraph). In source
2462        // view the `- ` is visible text the user is deleting a byte of, so it
2463        // keeps its literal meaning there, like Enter does.
2464        if self.view != View::Source && self.backspace_list_start() {
2465            return;
2466        }
2467        // WYSIWYG: and the same at the start of a heading's content — the `# `
2468        // there is markup the rich view hides, not text the user typed.
2469        if self.view != View::Source && self.backspace_heading_start() {
2470            return;
2471        }
2472        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2473        // the markup apart under a caret that cannot see it — see
2474        // `delete_around_block_media`.
2475        if self.view != View::Source && self.delete_around_block_media(false) {
2476            return;
2477        }
2478        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2479        // stop, not a single newline. On a line with no text of its own, the byte
2480        // before the caret is a `\n` that spells part of a block boundary — the gap
2481        // between two blocks, drawn but never a caret home. Removing just it strands
2482        // the caret in that gap and leaves an odd blank line the eye reads as one
2483        // separator but the caret can't land on: the "extra newline" left behind
2484        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2485        // Deleting to the previous stop instead collapses the whole break at once,
2486        // landing the caret at the end of the block above. Two blank lines in a row
2487        // are one stop apart, so this still removes exactly one — the lone-Enter /
2488        // lone-Backspace symmetry the empty-line case is built on is untouched.
2489        if self.view != View::Source
2490            && self.caret > self.caret_floor()
2491            && self.caret_on_blank_line()
2492            && let Some(stop) = self.vmap.stop_before(self.caret)
2493        {
2494            let stop = stop.max(self.caret_floor());
2495            if stop < self.caret {
2496                self.splice(stop, self.caret, "", EditKind::Delete);
2497                return;
2498            }
2499        }
2500        if self.caret > self.caret_floor() {
2501            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2502            // takes the whole tag — a single-byte step would leave a broken `<br`
2503            // showing in the cell. Rich view only (source view edits the literal).
2504            if self.view != View::Source
2505                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2506            {
2507                let start = start.max(self.caret_floor());
2508                if start < end {
2509                    self.splice(start, end, "", EditKind::Delete);
2510                    return;
2511                }
2512            }
2513            // Aim the delete at the character the writer can *see* behind the
2514            // caret, never at a delimiter the rich view drew nothing for. Two
2515            // steps, and either can apply: from the far side of a run's closing
2516            // `**` step back into the run (the caret is drawn at the end of its
2517            // word), and at the start of a run's text step out past its opening
2518            // `**` to the character in front of it, leaving the run standing.
2519            // Without them a plain Backspace unspells the phrase it is editing
2520            // and leaves a literal asterisk on screen.
2521            let end = if self.view == View::Source {
2522                self.caret
2523            } else {
2524                let inside = self.step_inside_close_delims(self.caret);
2525                self.skip_leading_open_delims(inside)
2526                    .max(self.caret_floor())
2527            };
2528            // Never delete back across the floor — that would eat hidden
2529            // frontmatter the WYSIWYG caret can't even see.
2530            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2531            // Take a hidden escape backslash with the char it escapes: the rich
2532            // view draws `\*` as a single `*`, so Backspace over it must delete
2533            // both bytes, never strand the `\` as a lone visible backslash (the
2534            // mirror of the Hidden-mode typing that wrote the escape). Source view
2535            // shows the `\`, so there it is an ordinary character.
2536            if self.view != View::Source
2537                && prev > self.caret_floor()
2538                && self.is_hidden_escape(prev - 1)
2539            {
2540                prev -= 1;
2541            }
2542            if prev < end {
2543                self.splice(prev, end, "", EditKind::Delete);
2544            }
2545        }
2546    }
2547
2548    /// Whether the caret's own source line holds nothing but whitespace — an
2549    /// empty paragraph, or the blank line a block boundary is spelled with. The
2550    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2551    /// no text of its own, so the newline before the caret belongs to the gap
2552    /// between blocks rather than to any word the caret is editing.
2553    fn caret_on_blank_line(&self) -> bool {
2554        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2555        let line_end = self.source[self.caret..]
2556            .find('\n')
2557            .map_or(self.source.len(), |i| self.caret + i);
2558        self.source[line_start..line_end].trim().is_empty()
2559    }
2560
2561    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2562    /// `edge` side — the byte range to delete whole. A table row is one source
2563    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2564    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2565    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2566    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2567    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2568    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2569    /// apart — no ancestor walk needed. Rich view only; source view shows the
2570    /// literal tag and deletes it a byte at a time.
2571    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2572        let caret = self.caret;
2573        let nodes = self.nodes();
2574        let src = self.source.as_bytes();
2575        nodes
2576            .iter()
2577            .find(|n| {
2578                n.kind == Kind::HardBreak
2579                    && n.span.start < n.span.end
2580                    && src.get(n.span.start) == Some(&b'<')
2581                    && match edge {
2582                        BreakEdge::Backward => n.span.end == caret,
2583                        BreakEdge::Forward => n.span.start == caret,
2584                    }
2585            })
2586            .map(|n| (n.span.start, n.span.end))
2587    }
2588
2589    /// Whether the source byte at `off` is a backslash twig consumed as an escape
2590    /// (hidden in the rich view), as against a literal backslash (drawn). A
2591    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2592    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2593    /// round-trip needed.
2594    fn is_hidden_escape(&self, off: usize) -> bool {
2595        let b = self.source.as_bytes();
2596        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2597    }
2598
2599    /// Backspace's list behaviour: when the caret sits exactly at the start of a
2600    /// list item's content (right after its marker), outdent the item if it's
2601    /// nested, else strip the marker so it becomes a paragraph. Returns whether
2602    /// it acted — `false` leaves Backspace its ordinary character delete.
2603    fn backspace_list_start(&mut self) -> bool {
2604        let Some(marker) = self.list_marker_on_line(self.caret) else {
2605            return false;
2606        };
2607        // Only right after the marker. That the line opens a real item is
2608        // already settled: `list_marker_on_line` answers from the tree.
2609        if self.caret != marker.content_start() {
2610            return false;
2611        }
2612        if self.item_is_nested(marker.marker_start) {
2613            // Nested: give back one level, keeping the marker and carrying the
2614            // caret with it.
2615            self.outdent();
2616        } else {
2617            // Top level: drop the marker, leaving a paragraph, then renumber the
2618            // siblings the removed item was counted among. Only the marker goes —
2619            // a quote prefix in front of it still has a quote to hold up.
2620            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2621            self.renumber_here();
2622        }
2623        true
2624    }
2625
2626    /// Backspace's heading behaviour: with the caret exactly at the start of an
2627    /// ATX heading's content — right after the `#` marker the rich view hides —
2628    /// strip the marker so the line becomes a paragraph. The peer of
2629    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2630    /// reasoning: hidden block markup is structure, so the keystroke over it is
2631    /// structural.
2632    ///
2633    /// Without this the ordinary delete takes the space out of `# Title` and
2634    /// leaves `#Title`, which is no longer a heading at all — the hash the view
2635    /// had been hiding surfaces as literal text the user has to delete a second
2636    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2637    /// other end) goes with the marker for the same reason.
2638    ///
2639    /// Returns whether it acted; `false` leaves Backspace its character delete.
2640    fn backspace_heading_start(&mut self) -> bool {
2641        let caret = self.caret;
2642        // The heading whose content opens exactly at the caret. A bare `#` has no
2643        // content span at all — its content starts (and ends) where the line does.
2644        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2645            let (start, end) = match &n.content_span {
2646                Some(c) => (c.start, c.end),
2647                None => (n.span.end, n.span.end),
2648            };
2649            (n.kind == Kind::Heading && start == caret)
2650                .then(|| (n.span.clone(), end, n.marker_span.clone()))
2651        }) else {
2652            return false;
2653        };
2654        // twig reports the marker's own extent, so there is nothing to walk back
2655        // over and no `#` in this file. A setext heading has no marker — its
2656        // content opens the line — so it falls through to the ordinary delete,
2657        // as does anything else sitting at a content start.
2658        // `m.end == caret` is what excludes a setext heading, whose marker is the
2659        // underline *after* the content rather than a prefix before it.
2660        let Some(marker) = marker.filter(|m| m.end == caret) else {
2661            return false;
2662        };
2663        let start = marker.start;
2664        // A closing `#` sequence is hidden too, so it can't be left behind. Only
2665        // when the tail really is one: trailing spaces alone are nothing to strip.
2666        let tail = &self.source[content_end..span.end];
2667        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2668            let kept = self.source[caret..content_end].to_string();
2669            self.splice(start, span.end, &kept, EditKind::Other);
2670            // The splice leaves the caret past the text it re-wrote; the caret
2671            // belongs where the content now starts, which is where it already was.
2672            self.caret = start;
2673            self.record_caret();
2674        } else {
2675            self.splice(start, caret, "", EditKind::Other);
2676        }
2677        true
2678    }
2679
2680    pub fn delete_forward(&mut self) {
2681        if let Some((s, e)) = self.selection() {
2682            self.splice(s, e, "", EditKind::Other);
2683        } else if self.caret < self.source.len() {
2684            // The mirror of Backspace's: forward-delete in front of a picture
2685            // would eat the `!` off its markup and leave a link where a photo was.
2686            if self.view != View::Source && self.delete_around_block_media(true) {
2687                return;
2688            }
2689            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2690            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2691            // strands a broken `<br` in the cell.
2692            if self.view != View::Source
2693                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2694            {
2695                self.splice(start, end, "", EditKind::Delete);
2696                return;
2697            }
2698            // The mirror of Backspace's two steps: from in front of a run's
2699            // opening `**` step into it, onto the first letter of its text, and
2700            // at the end of a run's text step out past its closing `**` to the
2701            // character beyond. Either way Delete takes the character it looks
2702            // like it is pointing at, and never a delimiter drawn as nothing.
2703            // The caret then settles back inside the run it was standing in —
2704            // see `settle_inside_close_delims`.
2705            let from = if self.view == View::Source {
2706                self.caret
2707            } else {
2708                let inside = self.step_inside_open_delims(self.caret);
2709                self.skip_trailing_close_delims(inside)
2710            };
2711            let next = next_boundary(&self.source, from);
2712            if from < next {
2713                self.splice(from, next, "", EditKind::Delete);
2714            }
2715        }
2716    }
2717
2718    /// Delete from the caret back to the start of the previous word (⌥⌫ /
2719    /// Ctrl+⌫). Deletes the selection instead when one is active.
2720    pub fn delete_word_back(&mut self) {
2721        if let Some((s, e)) = self.selection() {
2722            self.splice(s, e, "", EditKind::Other);
2723        } else {
2724            // A word back from just past a picture is a word *of its markup*, and
2725            // a word back from in front of one runs through the paragraph break
2726            // into the prose above — dissolving the picture either way. See
2727            // `delete_around_block_media`.
2728            if self.view != View::Source && self.delete_around_block_media(false) {
2729                return;
2730            }
2731            let start = self.word_left_from(self.caret).max(self.caret_floor());
2732            if start < self.caret {
2733                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2734                self.splice(s, e, "", EditKind::Delete);
2735            }
2736        }
2737    }
2738
2739    /// Delete from the caret forward to the end of the next word (⌥⌦ /
2740    /// Ctrl+Del). Deletes the selection instead when one is active.
2741    pub fn delete_word_forward(&mut self) {
2742        if let Some((s, e)) = self.selection() {
2743            self.splice(s, e, "", EditKind::Other);
2744        } else {
2745            // The mirror: a word forward from in front of a picture is its markup.
2746            if self.view != View::Source && self.delete_around_block_media(true) {
2747                return;
2748            }
2749            let end = self.word_right_from(self.caret);
2750            if end > self.caret {
2751                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2752                self.splice(s, e, "", EditKind::Delete);
2753            }
2754        }
2755    }
2756
2757    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
2758    /// selection instead when one is active, as every other delete here does.
2759    ///
2760    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
2761    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
2762    /// stops at the first character and this takes the indentation with it, the
2763    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
2764    /// leave an indent behind that nothing can then ask to delete, where a caret
2765    /// left at column 0 is one press of Home away from either.
2766    pub fn delete_to_line_start(&mut self) {
2767        if let Some((s, e)) = self.selection() {
2768            self.splice(s, e, "", EditKind::Other);
2769            return;
2770        }
2771        // Never back across the floor: hidden frontmatter isn't on this line, or
2772        // on any line the WYSIWYG caret can see.
2773        let (start, _) = self.line_span();
2774        let start = start.max(self.caret_floor());
2775        if start < self.caret {
2776            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2777            self.splice(s, e, "", EditKind::Delete);
2778        }
2779    }
2780
2781    /// Kill from the caret to the end of its line (^K). Deletes the selection
2782    /// instead when one is active.
2783    ///
2784    /// At the end of the line it does nothing, rather than pulling the line
2785    /// below up into this one. Joining has no meaning to give it in both views
2786    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
2787    /// there is nothing there to delete, while the newline a *source* line ends
2788    /// with is only half of the blank line that separates two paragraphs —
2789    /// deleting one leaves a soft break, which is not the join it looks like.
2790    /// The views agreeing is worth more than emacs' second press, and Delete is
2791    /// already the key that joins.
2792    pub fn delete_to_line_end(&mut self) {
2793        if let Some((s, e)) = self.selection() {
2794            self.splice(s, e, "", EditKind::Other);
2795            return;
2796        }
2797        let (_, end) = self.line_span();
2798        if end > self.caret {
2799            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2800            self.splice(s, e, "", EditKind::Delete);
2801        }
2802    }
2803
2804    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
2805    ///
2806    /// A glyph-space range covers what the user can see, which for `**bold**` is
2807    /// the word and never the delimiters around it — so deleting the word on its
2808    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
2809    /// word, and the styling was the word's; the two go together. Only the
2810    /// node's delimiters are taken, and those are hidden here anyway, so nothing
2811    /// visible outside the range is lost.
2812    ///
2813    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
2814    /// the strong, and only then is the strong empty too.
2815    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
2816        if self.view == View::Source {
2817            return (start, end);
2818        }
2819        let nodes = self.nodes();
2820        let (mut s, mut e) = (start, end);
2821        loop {
2822            let mut grew = false;
2823            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
2824                let Some(text) = inline_content_span(n, &self.source) else {
2825                    continue;
2826                };
2827                // Some of its text survives, so the node still has a job.
2828                if text.start < s || text.end > e {
2829                    continue;
2830                }
2831                if n.span.start < s || n.span.end > e {
2832                    s = s.min(n.span.start);
2833                    e = e.max(n.span.end);
2834                    grew = true;
2835                }
2836            }
2837            if !grew {
2838                return (s, e);
2839            }
2840        }
2841    }
2842
2843    /// One splice of document text, keeping the **mark-edge rule**: an inline
2844    /// mark's content never begins or ends with whitespace. In Markdown and Djot
2845    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
2846    /// is four literal asterisks around a word, and a rich view drawing the
2847    /// document faithfully has no choice but to show them. That is correct
2848    /// rendering of what the file says, and nobody typing a space after a bold
2849    /// word meant to say it.
2850    ///
2851    /// So the space goes *outside* the run instead — `**bold** ` — which is the
2852    /// same document to a reader and a live one to a parser. The caret follows it
2853    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
2854    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
2855    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
2856    ///
2857    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
2858    /// through here, so the rule holds however the whitespace arrives at the
2859    /// edge. The repair is decided *after* the plain edit, by asking whether the
2860    /// mark actually died: a code span's backticks aren't whitespace-sensitive
2861    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
2862    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2863        let fix = self.mark_edge_fix(start, end, text);
2864        if !self.splice_exact(start, end, text, kind) {
2865            return false;
2866        }
2867        if let Some(fix) = fix {
2868            self.repair_mark_edges(fix);
2869        }
2870        if text.is_empty() && end > start {
2871            self.settle_inside_close_delims();
2872        }
2873        true
2874    }
2875
2876    /// After a delete, take a caret left standing past a run's closing delimiters
2877    /// back inside the run.
2878    ///
2879    /// A delete leaves the caret where the deleted bytes began, and when those
2880    /// bytes were the last thing after a marked phrase — the space the mark-edge
2881    /// rule pushed out of `**bold** `, say — that spot is the far side of the
2882    /// closing `**`. The rich view has nothing to draw there: the delimiters are
2883    /// hidden, so the caret shows at the end of the word either way, and the two
2884    /// offsets are one place on screen with two different meanings. Typing at the
2885    /// outer one lands past the run, so the writer who backspaced a space out of
2886    /// their bold phrase watches the next character come out plain, and the
2887    /// toolbar button go dark, with the caret never appearing to move.
2888    ///
2889    /// The end of the run's text is the caret's home there — a delete that took
2890    /// away everything after a phrase leaves the caret at the end of that phrase,
2891    /// which is inside it — so it settles onto that
2892    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
2893    /// walk, through every mark closing at the point): the word stays bold, the
2894    /// button stays lit, and the next character carries on the phrase.
2895    ///
2896    /// Rich view only, and only where a mark really closes at the caret — mid-run
2897    /// or in plain prose no span ends there and the caret stays put. The opening
2898    /// edge is left alone on purpose: a caret in front of a run inherits from the
2899    /// text on its left, which is the plain text outside.
2900    fn settle_inside_close_delims(&mut self) {
2901        if self.view != View::Wysiwyg {
2902            return;
2903        }
2904        let at = self.step_inside_close_delims(self.caret);
2905        if at != self.caret {
2906            self.caret = at;
2907            self.clear_pending();
2908            self.record_caret();
2909        }
2910    }
2911
2912    /// The splice exactly as asked, with no mark-edge repair — for the callers
2913    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
2914    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
2915    /// the bytes they inserted.
2916    ///
2917    /// One `edit_range` through twig, then re-anchor the caret from the returned
2918    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
2919    /// Markdown/Djot) leaves the document untouched and reports.
2920    ///
2921    /// Returns whether the edit landed — for a caller that has offsets of its
2922    /// own to place afterwards, which a rolled-back splice would leave pointing
2923    /// into text that never came to exist.
2924    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2925        // The read-only gate, for every edit at once — see the field.
2926        if self.read_only {
2927            return false;
2928        }
2929        // twig records an undo step for every edit; when this one continues a
2930        // run of the same kind (typing, deleting), tell twig to fold it into the
2931        // step before it so the whole run undoes at once.
2932        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
2933        // Hand twig the pre-edit caret before the splice, so the undo step it
2934        // retires carries where the caret was standing.
2935        self.record_caret();
2936        match self.editor.edit_range(start, end, text) {
2937            Ok(change) => {
2938                if coalesce {
2939                    let _ = self.editor.coalesce_last_undo();
2940                }
2941                self.last_edit_kind = Some(kind);
2942                self.refresh();
2943                self.caret = change.new.end;
2944                self.anchor = None;
2945                self.goal_col = None;
2946                self.clear_pending();
2947                self.dirty = self.source != self.clean_source;
2948                self.status = None;
2949                // And the post-edit caret, so a later redo restores it.
2950                self.record_caret();
2951                true
2952            }
2953            // The edit was rolled back, so twig's history did not move and
2954            // neither may ours: pushing here would leave a step with no edit
2955            // under it and shift every later undo onto the wrong caret.
2956            Err(e) => {
2957                self.status = Some(format!("edit: {e}"));
2958                false
2959            }
2960        }
2961    }
2962
2963    /// The re-spelling that would keep the mark-edge rule for the edit
2964    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
2965    /// against a delimiter and the plain splice is already right. Computed
2966    /// *before* the edit, while the run's spans and delimiters can still be read
2967    /// off the document; applied afterwards, and only if the mark really died —
2968    /// see [`repair_mark_edges`](Self::repair_mark_edges).
2969    ///
2970    /// Rich view only. Source view is for typing raw markup, where a space put
2971    /// against a `**` is exactly the character it looks like.
2972    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
2973        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
2974            return None;
2975        }
2976        // Every inline mark standing over the edit, outermost first, with the
2977        // content span that says where its delimiters are.
2978        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
2979            .editor
2980            .ancestors_at(start)
2981            .unwrap_or_default()
2982            .into_iter()
2983            .filter_map(|m| {
2984                let kind = inline_kind(&m.kind)?;
2985                let content = m.content_span.clone()?;
2986                Some((kind, m.span.clone(), content))
2987            })
2988            .collect();
2989        // The innermost run whose *content* holds the whole edit: the one whose
2990        // text is being changed, rather than one the edit merely sits under.
2991        let (kind, span, content) = chain
2992            .iter()
2993            .rev()
2994            .find(|(_, _, c)| c.start <= start && end <= c.end)?
2995            .clone();
2996        // What that content becomes. Whitespace at either end of it is what
2997        // would put out the mark.
2998        let body = format!(
2999            "{}{text}{}",
3000            &self.source[content.start..start],
3001            &self.source[end..content.end]
3002        );
3003        let (lead, trail) = if body.trim().is_empty() {
3004            // Nothing but whitespace left: there is no content to mark at all,
3005            // and the delimiters go with it rather than closing on a space.
3006            (body.len(), 0)
3007        } else {
3008            (
3009                body.len() - body.trim_start().len(),
3010                body.len() - body.trim_end().len(),
3011            )
3012        };
3013        // Nothing against a delimiter, and something still between them: the
3014        // plain edit stands. An emptied run is broken just as surely (`**b**`
3015        // with the `b` deleted is the literal `****`) and is re-spelt as the
3016        // nothing it now says.
3017        if lead == 0 && trail == 0 && !body.is_empty() {
3018            return None;
3019        }
3020        // Marks that open or close exactly where this one does — `***both***` is
3021        // two runs sharing an edge — spell their delimiters as one run of bytes,
3022        // so the whitespace has to clear all of them together.
3023        let (mut open_at, mut close_at) = (span.start, span.end);
3024        for _ in 0..chain.len() {
3025            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3026                Some((_, s, _)) => open_at = s.start,
3027                None => break,
3028            }
3029        }
3030        for _ in 0..chain.len() {
3031            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3032                Some((_, s, _)) => close_at = s.end,
3033                None => break,
3034            }
3035        }
3036        let open = &self.source[open_at..content.start];
3037        let close = &self.source[content.end..close_at];
3038        let core = &body[lead..body.len() - trail];
3039        let respelt = if core.is_empty() {
3040            body.clone()
3041        } else {
3042            format!(
3043                "{}{open}{core}{close}{}",
3044                &body[..lead],
3045                &body[body.len() - trail..]
3046            )
3047        };
3048        // The caret sits just past the inserted text within the new content —
3049        // which, when that lands in the whitespace, is now outside the delimiters.
3050        let pos = (start - content.start) + text.len();
3051        let caret = if core.is_empty() || pos <= lead {
3052            open_at + pos
3053        } else if pos >= lead + core.len() {
3054            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3055        } else {
3056            open_at + lead + open.len() + (pos - lead)
3057        };
3058        Some(MarkEdgeFix {
3059            kind,
3060            probe: content.start,
3061            start: open_at,
3062            end: close_at + text.len() - (end - start),
3063            text: respelt,
3064            caret,
3065            // The marks in force here, resolved against any armed sticky delta —
3066            // what the writer is typing in, and so what has to still be true on
3067            // the far side of the delimiter the caret just stepped over.
3068            want: chain
3069                .iter()
3070                .filter(|(_, s, _)| start < s.end)
3071                .map(|(k, _, _)| *k)
3072                .collect::<InlineMarks>()
3073                .xor(self.pending_here()),
3074        })
3075    }
3076
3077    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3078    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3079    /// format's business, not leaf's: `**bold **` is no longer strong, while
3080    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3081    /// don't care either. Asking the parser afterwards settles it for every kind
3082    /// and format at once, and costs a re-spelling only where one is due.
3083    ///
3084    /// The repair rides along with the edit that caused it — one undo step puts
3085    /// back what the writer typed, not a delimiter shuffle they never saw.
3086    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3087        if fix.end > self.source.len() {
3088            return;
3089        }
3090        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3091            return; // still a mark: these delimiters don't mind the whitespace
3092        }
3093        let resumed = self.last_edit_kind;
3094        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3095            return;
3096        }
3097        let _ = self.editor.coalesce_last_undo();
3098        // The keystroke owns the undo step, so the run of typing it belongs to
3099        // keeps coalescing over the repair rather than breaking in two here.
3100        self.last_edit_kind = resumed;
3101        self.caret = fix.caret.min(self.source.len());
3102        self.anchor = None;
3103        self.goal_col = None;
3104        self.rearm(fix.want);
3105        self.clamp_caret();
3106        self.record_caret();
3107    }
3108
3109    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3110    /// writer is typing in, carried across an edit that moved the caret out of
3111    /// the run holding them. Arms nothing when the caret already stands in
3112    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3113    /// a clean delta here (see [`toggle`](Self::toggle)).
3114    fn rearm(&mut self, want: InlineMarks) {
3115        let here: InlineMarks = self
3116            .marks_at(self.caret)
3117            .into_iter()
3118            .map(|(k, _)| k)
3119            .collect();
3120        self.pending_marks = want.xor(here);
3121        self.pending_at = Some(self.caret);
3122    }
3123
3124    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3125    /// which backslash-escapes any character that would otherwise open markup in
3126    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3127    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3128    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3129    /// a collapsed point — a selection is deleted by the caller first, since
3130    /// `insert_literal` inserts rather than replaces.
3131    fn insert_literal_at(
3132        &mut self,
3133        at: usize,
3134        text: &str,
3135        kind: EditKind,
3136        force_coalesce: bool,
3137    ) -> bool {
3138        // `force_coalesce` folds this into the immediately preceding edit (the
3139        // selection-delete of an overwrite) so the pair is one undo step; else it
3140        // coalesces only when it continues a run of the same-kind typing.
3141        let coalesce =
3142            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3143        // The mark-edge rule holds for typed text however it is spelled — see
3144        // `splice`. Only an insert twig passed through unchanged can use it,
3145        // since a fix is measured in the bytes that actually land, and an escape
3146        // adds bytes this couldn't have counted.
3147        let fix = self.mark_edge_fix(at, at, text);
3148        self.record_caret();
3149        match self.editor.insert_literal(at, text) {
3150            Ok(change) => {
3151                if coalesce {
3152                    let _ = self.editor.coalesce_last_undo();
3153                }
3154                self.last_edit_kind = Some(kind);
3155                self.refresh();
3156                self.caret = change.new.end;
3157                self.anchor = None;
3158                self.goal_col = None;
3159                self.clear_pending();
3160                self.dirty = self.source != self.clean_source;
3161                self.status = None;
3162                self.record_caret();
3163                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3164                    self.repair_mark_edges(fix);
3165                }
3166                true
3167            }
3168            Err(e) => {
3169                self.status = Some(format!("edit: {e}"));
3170                false
3171            }
3172        }
3173    }
3174
3175    /// After a structural list edit (a new item, a nest/unnest), renumber the
3176    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3177    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3178    /// renumber as its own edit; fold it into the edit that triggered it so the
3179    /// two undo as one, and only when it actually changed the source (a no-op or
3180    /// a caret outside any ordered list must not coalesce the real edit into the
3181    /// step before it).
3182    fn renumber_here(&mut self) {
3183        self.renumber_at(self.caret);
3184    }
3185
3186    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3187    /// caret — for an edit that leaves the caret one past the item it just wrote,
3188    /// where twig resolves no list to renumber.
3189    fn renumber_at(&mut self, off: usize) {
3190        let before = self.source.clone();
3191        if self.editor.renumber_ordered_lists(off).is_err() {
3192            return; // not inside an ordered list — nothing to renumber
3193        }
3194        self.refresh();
3195        if self.source != before {
3196            let _ = self.editor.coalesce_last_undo();
3197            self.dirty = self.source != self.clean_source;
3198            self.clamp_caret();
3199            self.record_caret();
3200        }
3201    }
3202
3203    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3204    /// sub-item written directly beneath a text line reparses that text as a
3205    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3206    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3207    /// bullets can't underline anything, so swap the dash for a `*`: the item
3208    /// stays an empty nested bullet, the parent stays prose, and the source
3209    /// round-trips instead of hiding a heading the user never asked for. Folded
3210    /// into the triggering edit's undo step, the way renumbering is.
3211    ///
3212    /// Gated on the collapse having actually happened (the swapped dash was
3213    /// swallowed into a `heading`), so a real setext heading the author wrote —
3214    /// or a `- x` with content, which can't underline anything — is never
3215    /// touched. This has to live in the *edit*, not the renderer: leaving the
3216    /// hazardous bytes on disk and only painting over them would ship a file
3217    /// every other CommonMark tool reads as a heading.
3218    ///
3219    /// This one keeps its own byte scan, and has to: the hazard is precisely
3220    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3221    /// which asks twig which lines open an item — reports nothing here. There is
3222    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3223    /// purpose rather than for want of an answer; once twig spells continuations
3224    /// itself, avoiding the trap becomes twig's, and this goes.
3225    ///
3226    /// [`list_marker_on_line`]: Self::list_marker_on_line
3227    fn avoid_setext_collapse(&mut self) {
3228        let caret = self.caret.min(self.source.len());
3229        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3230        let bytes = self.source.as_bytes();
3231        let mut dash = line_start;
3232        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3233            dash += 1;
3234        }
3235        // A dash bullet is the only marker that doubles as a setext underline.
3236        if bytes.get(dash) != Some(&b'-') {
3237            return;
3238        }
3239        // Only an *empty* item is a bare underline; `- x` carries content and
3240        // can't fold the line above into a heading.
3241        let line_end = self.source[dash..]
3242            .find('\n')
3243            .map_or(self.source.len(), |i| dash + i);
3244        if !self.source[dash + 1..line_end].trim().is_empty() {
3245            return;
3246        }
3247        // The tell: that dash was swallowed into a `heading`. A properly nested
3248        // empty item sits under a `list_item`, with no heading in reach. Probe
3249        // the dash byte itself (well inside the heading), not the caret, whose
3250        // end-of-line offset can fall on the half-open span boundary.
3251        let collapsed = self
3252            .editor
3253            .ancestors_at(dash)
3254            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3255            .unwrap_or(false);
3256        if !collapsed {
3257            return;
3258        }
3259        let caret = self.caret;
3260        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3261            // Same width, so the caret keeps its column; fold into the edit that
3262            // triggered this so Tab stays one undo step.
3263            let _ = self.editor.coalesce_last_undo();
3264            self.caret = caret.min(self.source.len());
3265            self.clamp_caret();
3266            self.record_caret();
3267        }
3268    }
3269
3270    fn snapshot(&self) -> CaretState {
3271        CaretState {
3272            caret: self.caret,
3273            anchor: self.anchor,
3274        }
3275    }
3276
3277    /// Hand twig the current caret and selection as the blob for the live
3278    /// document state. Called before an edit — so the step twig retires records
3279    /// where the caret was, and undo can restore it — and again once the op has
3280    /// placed the caret, so redo restores where the edit left it.
3281    ///
3282    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3283    /// caret through its own history, so coalescing falls out for free (folding
3284    /// two twig steps into one drops the intermediate blob, keeping the run's
3285    /// first) and the parallel stacks that had to march in lockstep — and could
3286    /// silently drift out of it — are gone.
3287    fn record_caret(&mut self) {
3288        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3289    }
3290
3291    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3292    /// the toggled region selected so a second press cleanly reverses it.
3293    pub fn toggle(&mut self, kind: InlineKind) {
3294        // Ahead of the no-selection branch below: arming a mark for text not yet
3295        // typed is a promise `insert` cannot keep in a format with no delimiters
3296        // to spell it with. Per *kind*, not per format — Markdown spells three
3297        // of the eight marks, djot all eight, HTML seven.
3298        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3299            return;
3300        }
3301        let Some((s, e)) = self.selection() else {
3302            // No selection: arm the mark for the next text typed here, the way a
3303            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3304            // in the flow of typing without ever selecting anything — the delta
3305            // is realised onto the freshly typed text by `insert`. A fresh caret
3306            // position starts the delta over from the marks actually in force.
3307            if self.pending_at != Some(self.caret) {
3308                self.pending_marks = InlineMarks::empty();
3309                self.pending_at = Some(self.caret);
3310            }
3311            self.pending_marks.flip(kind);
3312            self.status = None;
3313            return;
3314        };
3315        // Whitespace at the edge of a selection is not part of what was chosen —
3316        // a double-click takes the space after the word with it — and a mark
3317        // cannot close against one anyway: `**word **` is four literal asterisks
3318        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3319        let picked = &self.source[s..e];
3320        let (s, e) = (
3321            s + (picked.len() - picked.trim_start().len()),
3322            e - (picked.len() - picked.trim_end().len()),
3323        );
3324        if s >= e {
3325            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3326            return;
3327        }
3328        // Styling a selection is a one-shot act, not a sticky mode.
3329        self.clear_pending();
3330        self.record_caret();
3331        match self.editor.toggle_inline(s, e, kind) {
3332            Ok(change) => {
3333                self.last_edit_kind = None; // structural edit is its own undo step
3334                self.refresh();
3335                self.anchor = Some(change.new.start);
3336                self.caret = change.new.end;
3337                self.dirty = self.source != self.clean_source;
3338                self.status = None;
3339                self.record_caret();
3340            }
3341            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3342        }
3343    }
3344
3345    /// Convert the block at the caret to a heading level or paragraph.
3346    pub fn set_block(&mut self, kind: BlockKind) {
3347        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3348            return;
3349        }
3350        self.record_caret();
3351        // A blank line has no node to convert, and twig opens a block there
3352        // rather than declining — so the caret's own offset is the right thing
3353        // to hand it when `block_offset_for_caret` finds nothing.
3354        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3355        match self.editor.set_block(offset, kind) {
3356            Ok(change) => {
3357                self.last_edit_kind = None;
3358                self.refresh();
3359                // Opening a block on a blank line writes a marker the caret
3360                // belongs *after*; converting an existing one moves nothing.
3361                self.caret = self.caret.max(change.new.end);
3362                self.clamp_caret();
3363                self.anchor = None;
3364                self.dirty = self.source != self.clean_source;
3365                self.status = None;
3366                self.record_caret();
3367            }
3368            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3369        }
3370    }
3371
3372    /// Whether `off` is inside a text block (paragraph, heading, code block…).
3373    fn has_block_at(&mut self, off: usize) -> bool {
3374        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3375            chain
3376                .iter()
3377                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3378        })
3379    }
3380
3381    /// The offset to hand twig's `set_block`: the caret when it is already inside
3382    /// a block, otherwise nudged onto the previous character (a caret at a line
3383    /// end sits at the doc level, outside the block). `None` when the caret is on
3384    /// a blank line — a new paragraph with no block node to convert.
3385    fn block_offset_for_caret(&mut self) -> Option<usize> {
3386        let caret = self.caret.min(self.source.len());
3387        if self.has_block_at(caret) {
3388            return Some(caret);
3389        }
3390        // Nudge to the previous character — but never across a newline: that would
3391        // target the previous block, and a blank line genuinely has no block.
3392        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3393            && ch != '\n'
3394            && self.has_block_at(i)
3395        {
3396            return Some(i);
3397        }
3398        None
3399    }
3400
3401    /// The heading level of the text block at the caret, or `None` when that
3402    /// block is not a heading.
3403    pub fn current_heading_level(&mut self) -> Option<u32> {
3404        let caret = self.caret;
3405        self.nodes()
3406            .into_iter()
3407            .filter(|n| n.kind == Kind::Heading)
3408            .find(|n| n.span.start <= caret && caret <= n.span.end)
3409            .and_then(|n| n.level)
3410    }
3411
3412    /// The inline marks in force at the caret (or over the selection) — what a
3413    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3414    /// inline counterpart. Cheap enough to call every frame: one twig
3415    /// `ancestors_at` query per caret (two with a selection), each walking root
3416    /// → deepest node at one offset. It never snapshots the tree the way
3417    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3418    /// the only allocation is twig's own small ancestor `Vec`.
3419    ///
3420    /// **A selection reports a mark only when the mark covers *all* of it.**
3421    /// That's what every real toolbar means by an active button — Bold lit over
3422    /// a half-bold selection would claim a press turns bold *off*, when
3423    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3424    /// Whole-coverage is asked as "is the same mark node standing over both the
3425    /// first and the last character?": inline nodes are contiguous, so one node
3426    /// covering both ends covers every byte between them. Two touching runs
3427    /// (`**a****b**`) are two nodes, and correctly light nothing.
3428    ///
3429    /// At a bare caret a mark is active when the caret stands inside the mark's
3430    /// span — `span.start <= caret < span.end`, delimiters included, which is
3431    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3432    /// opening `*` (2) through the last byte of the closing `**` (9) are all
3433    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3434    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3435    /// typing would actually land inside the marked run. The offset one past the
3436    /// mark (10) is the text after it and reports nothing, at the end of the
3437    /// buffer exactly as in the middle.
3438    pub fn active_inline_marks(&mut self) -> InlineMarks {
3439        let Some((start, end)) = self.selection() else {
3440            // The marks actually in force at the caret, flipped by any armed
3441            // sticky delta — so `⌘b` at a bare caret lights the Bold button
3442            // immediately, before a single character is typed.
3443            let base: InlineMarks = self
3444                .marks_at(self.caret)
3445                .into_iter()
3446                .map(|(k, _)| k)
3447                .collect();
3448            return base.xor(self.pending_here());
3449        };
3450        // The selection's *last character*, not its exclusive end: `end` is the
3451        // offset one past the selection, which for a selection ending exactly at
3452        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3453        // entirely bold, but offset 10 is the space after).
3454        let last = prev_boundary(&self.source, end);
3455        let head = self.marks_at(start);
3456        let tail = self.marks_at(last);
3457        head.into_iter()
3458            .filter(|m| tail.contains(m))
3459            .map(|(k, _)| k)
3460            .collect()
3461    }
3462
3463    /// The inline marks whose span covers `off`, each with the id of the node
3464    /// carrying it — the id is what lets a selection tell one mark node from
3465    /// another of the same kind.
3466    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3467        let off = off.min(self.source.len());
3468        self.editor
3469            .ancestors_at(off)
3470            .unwrap_or_default()
3471            .into_iter()
3472            // `span.end` is the offset one *past* the mark, so it isn't in it.
3473            // twig already resolves a boundary to whatever starts there — in
3474            // `**bold** x` offset 8 is the following text, not the strong — but
3475            // when nothing follows, the tie has nobody to break for and the
3476            // chain still ends at the mark. That would make the answer at the
3477            // last offset of the document depend on whether the file happens to
3478            // end in a newline; the rule is `span.start <= off < span.end`, and
3479            // it's the same rule at the end of a buffer as in the middle.
3480            .filter(|m| off < m.span.end)
3481            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3482            .collect()
3483    }
3484
3485    /// Toggle a heading at the caret: if the block is already this heading level,
3486    /// revert it to a paragraph; otherwise convert it to this heading level.
3487    /// This gives the heading commands the same toggle feel as bold/italic/code —
3488    /// re-applying a heading a line already has turns it back into body text.
3489    pub fn toggle_heading(&mut self, level: u32) {
3490        if self.current_heading_level() == Some(level) {
3491            self.set_block(BlockKind::Paragraph);
3492        } else {
3493            self.set_block(BlockKind::Heading(level));
3494        }
3495    }
3496
3497    /// Toggle a block quote around the selection, or around the block at the
3498    /// caret — the toolbar's Quote button.
3499    pub fn toggle_blockquote(&mut self) {
3500        self.toggle_container(BlockContainerKind::BlockQuote);
3501    }
3502
3503    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3504    /// over the block at the caret — one op with the kind as a flag, the way
3505    /// `toggle_heading` takes its level, so a frontend needs no twig type to
3506    /// name the two buttons.
3507    ///
3508    /// Pressing the *other* list's button while in a list converts in place
3509    /// rather than nesting, so the pair reads as one three-state control
3510    /// (bulleted / numbered / neither) rather than two independent wrappers.
3511    pub fn toggle_list(&mut self, ordered: bool) {
3512        self.toggle_container(if ordered {
3513            BlockContainerKind::OrderedList
3514        } else {
3515            BlockContainerKind::BulletList
3516        });
3517    }
3518
3519    // ── Task list items ──────────────────────────────────────────────────────
3520    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3521    // inline content of the item's first paragraph rather than part of its
3522    // marker, so adding or removing one must leave the item's continuation
3523    // indentation alone, and an item inside a quote is found past the quote
3524    // markers. leaf names the gesture and the offset; the spelling is twig's.
3525
3526    /// Whether the list item at the caret carries a checkbox, and which way it
3527    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3528    /// item or no item at all. What a toolbar reads to light its checkbox button.
3529    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3530        self.task_checked_at(self.caret)
3531    }
3532
3533    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3534    /// offset — what a frontend asks before deciding a click landed on a box.
3535    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3536        self.innermost_list_item(offset.min(self.source.len()))?
3537            .checked
3538    }
3539
3540    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3541    /// A no-op with a reported reason when the caret is in no task item — minting
3542    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3543    pub fn toggle_task_checked(&mut self) {
3544        self.toggle_task_at(self.caret);
3545    }
3546
3547    /// Tick or untick the task item covering `offset` — what a *click* on a
3548    /// rendered checkbox is. Separate from the caret form because a click carries
3549    /// its own offset and must not first move the caret there: ticking a box
3550    /// three paragraphs away should not take the cursor with it.
3551    pub fn toggle_task_at(&mut self, offset: usize) {
3552        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3553            return;
3554        }
3555        let offset = offset.min(self.source.len());
3556        self.record_caret();
3557        match self.editor.toggle_task_checked(offset) {
3558            Ok(_) => self.after_task_edit(),
3559            Err(e) => self.status = Some(format!("task: {e}")),
3560        }
3561    }
3562
3563    /// Give the list item at the caret a checkbox, or take its checkbox away —
3564    /// the gesture that converts between a plain bullet and a task. A new box
3565    /// arrives unticked.
3566    pub fn toggle_task_item(&mut self) {
3567        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3568            return;
3569        }
3570        let caret = self.caret.min(self.source.len());
3571        self.record_caret();
3572        match self.editor.toggle_task_item(caret) {
3573            Ok(_) => self.after_task_edit(),
3574            Err(e) => self.status = Some(format!("task: {e}")),
3575        }
3576    }
3577
3578    /// Settle after a task gesture. The caret rides its old byte offset and is
3579    /// clamped back in: a box is three or four bytes on the item's first line, so
3580    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3581    /// real stop either way.
3582    fn after_task_edit(&mut self) {
3583        self.last_edit_kind = None;
3584        self.refresh();
3585        self.anchor = None;
3586        self.dirty = self.source != self.clean_source;
3587        self.status = None;
3588        self.clamp_caret();
3589        self.record_caret();
3590    }
3591
3592    // ── Tables ───────────────────────────────────────────────────────────────
3593    // A table is a grid, and twig edits it as one — add/remove/move a row or
3594    // column, set a column's alignment — re-spelling the whole table in a single
3595    // splice. Every gesture is anchored at the caret's cell. leaf just names the
3596    // gesture and re-reads the result; the whole table's numbering, borders, and
3597    // delimiter are twig's to keep straight.
3598
3599    /// Whether the caret is inside a table — what a frontend asks to enable or
3600    /// disable its table controls.
3601    ///
3602    /// An HTML `<table>` still answers `true`: the caret really is in a table,
3603    /// and the reason the grid controls stay dark there is
3604    /// [`Capabilities::table`], which is a fact about the document's format
3605    /// rather than about the caret. A frontend needs both.
3606    pub fn caret_in_table(&mut self) -> bool {
3607        let caret = self.caret.min(self.source.len());
3608        self.editor
3609            .ancestors_at(caret)
3610            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3611            .unwrap_or(false)
3612    }
3613
3614    /// One grid op, guarded and settled — the shared body of the seven below.
3615    ///
3616    /// The guard is why this exists rather than seven copies of the same three
3617    /// lines, and it is the one guard leaf cannot delegate to twig. The table
3618    /// editor is the gesture family that consults no `Syntax` table (it spells a
3619    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3620    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3621    /// grid as a *pipe table* and reports success, swapping the element out for
3622    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3623    /// downstream could tell that from a successful edit — the splice is real,
3624    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3625    /// stopping at the door rather than detecting after the fact. See
3626    /// [`spells_pipe_tables`].
3627    fn table_op(
3628        &mut self,
3629        what: &str,
3630        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3631    ) {
3632        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3633            return;
3634        }
3635        self.record_caret();
3636        let at = self.caret;
3637        let r = op(&mut self.editor, at);
3638        self.apply_table(r, what);
3639    }
3640
3641    /// Insert an empty row below (`below`) or above the caret's row.
3642    pub fn table_insert_row(&mut self, below: bool) {
3643        self.table_op("table row", |e, at| e.table_insert_row(at, below));
3644    }
3645
3646    /// Delete the caret's row (not the header, not the last body row).
3647    pub fn table_delete_row(&mut self) {
3648        self.table_op("table row", |e, at| e.table_delete_row(at));
3649    }
3650
3651    /// Insert an empty column right (`right`) or left of the caret's column.
3652    pub fn table_insert_column(&mut self, right: bool) {
3653        self.table_op("table column", |e, at| e.table_insert_column(at, right));
3654    }
3655
3656    /// Delete the caret's column (unless it is the only one).
3657    pub fn table_delete_column(&mut self) {
3658        self.table_op("table column", |e, at| e.table_delete_column(at));
3659    }
3660
3661    /// Set the caret's column to `alignment`.
3662    pub fn table_set_alignment(&mut self, alignment: Alignment) {
3663        self.table_op("table alignment", |e, at| {
3664            e.table_set_alignment(at, alignment)
3665        });
3666    }
3667
3668    /// Move the caret's row one place down (`down`) or up, within the body rows.
3669    pub fn table_move_row(&mut self, down: bool) {
3670        self.table_op("table row", |e, at| e.table_move_row(at, down));
3671    }
3672
3673    /// Move the caret's column one place right (`right`) or left.
3674    pub fn table_move_column(&mut self, right: bool) {
3675        self.table_op("table column", |e, at| e.table_move_column(at, right));
3676    }
3677
3678    /// Settle the caret and document flags after a table op (or report its
3679    /// error). twig re-spells the whole table, so the caret rides its old byte
3680    /// offset and is clamped back into the rebuilt bytes — near enough to where
3681    /// it was, since the op preserves the cells' content and order around it.
3682    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3683        match result {
3684            Ok(()) => {
3685                self.last_edit_kind = None;
3686                self.refresh();
3687                self.anchor = None;
3688                self.clamp_caret();
3689                self.dirty = self.source != self.clean_source;
3690                self.status = None;
3691                self.record_caret();
3692            }
3693            Err(e) => self.status = Some(format!("{what}: {e}")),
3694        }
3695    }
3696
3697    /// One `toggle_block_container` over the block-level target.
3698    ///
3699    /// leaf says *where*; twig decides everything else — which blocks the range
3700    /// covers, whether that means wrapping, unwrapping, nesting or converting,
3701    /// and how this document's format spells the prefix. The rule that a
3702    /// container only comes off when the range covers every block it holds is
3703    /// what the re-anchoring below is built around.
3704    fn toggle_container(&mut self, kind: BlockContainerKind) {
3705        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3706            return;
3707        }
3708        let selected = self.selection();
3709        // A blank line holds no block, and twig opens an *empty* container on one
3710        // — since 3.2.0; it used to decline the range with `NotFound`, which is
3711        // why this used to lend it a scratch paragraph to wrap. Worth knowing
3712        // here because the line-for-line caret mapping below cannot describe it:
3713        // opening one under a paragraph writes the blank line the format needs
3714        // above the marker too, so the rewritten region has a line the old one
3715        // didn't, and "the same line, the same distance from its end" lands on
3716        // that new blank instead of in the container.
3717        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
3718        // Without a selection the target is the caret's own block, resolved the
3719        // way `set_block` resolves it — a caret at a line end sits at the doc
3720        // level and has to be nudged back onto the block it looks like it's in.
3721        // An empty range is enough: twig widens to the whole lines it touches.
3722        let (start, end) = match selected {
3723            Some(range) => range,
3724            None => {
3725                let off = self.block_offset_for_caret().unwrap_or(self.caret);
3726                (off, off)
3727            }
3728        };
3729        self.record_caret();
3730        match self.editor.toggle_block_container(start, end, kind) {
3731            Ok(change) => {
3732                // Read the caret's place out of the *pre-edit* source, before
3733                // `refresh` swaps that source out from under it.
3734                let place = (selected.is_none() && !opened_empty)
3735                    .then(|| self.caret_line_tail(&change.old));
3736                self.last_edit_kind = None; // structural edit is its own undo step
3737                self.refresh();
3738                match place {
3739                    // Both land the caret at the far end of what twig wrote, and
3740                    // differ only in what they leave selected.
3741                    //
3742                    // From a selection: select what the container now holds, the
3743                    // way `toggle` keeps its marked region selected — and for a
3744                    // stronger reason than symmetry: a container comes *off* only
3745                    // a range covering every block it holds, so a selection left
3746                    // on its old bytes (now short by a prefix per line) would nest
3747                    // on the second press instead of reversing the first.
3748                    //
3749                    // From a blank line: nothing to select, and the end of the
3750                    // region is exactly past the bare `> ` / `- ` twig wrote —
3751                    // the caret standing inside the container that was asked for.
3752                    None => {
3753                        self.anchor = (!opened_empty).then_some(change.new.start);
3754                        self.caret = change.new.end;
3755                    }
3756                    Some(place) => {
3757                        self.anchor = None;
3758                        self.caret = self.line_tail_offset(&change.new, place);
3759                    }
3760                }
3761                self.dirty = self.source != self.clean_source;
3762                self.status = None;
3763                self.clamp_caret();
3764                self.record_caret();
3765            }
3766            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3767        }
3768    }
3769
3770    /// The caret's place inside the region a container toggle is rewriting, in
3771    /// the only terms the rewrite preserves: which of the region's lines it sits
3772    /// on, and how many bytes of that line lie ahead of it.
3773    ///
3774    /// A container's markup goes in at column 0 and never touches what follows
3775    /// on the line, so that pair survives the edit exactly where a byte offset
3776    /// does not — a caret left on its old offset slides back by one prefix per
3777    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
3778    /// `> ` it just asked for.
3779    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
3780        let caret = self.caret.clamp(old.start, old.end);
3781        let line = self.source[old.start..caret].matches('\n').count();
3782        let end = self.source[caret..old.end]
3783            .find('\n')
3784            .map_or(old.end, |i| caret + i);
3785        (line, end - caret)
3786    }
3787
3788    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
3789    /// region: the offset `tail` bytes back from the end of the region's `line`.
3790    ///
3791    /// Both walks are clamped rather than trusted, because the one op that does
3792    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
3793    /// the items back apart with blank lines between them — and a caret landing
3794    /// on the nearest line of the right item beats one landing out of the region
3795    /// entirely.
3796    fn line_tail_offset(
3797        &self,
3798        new: &std::ops::Range<usize>,
3799        (line, tail): (usize, usize),
3800    ) -> usize {
3801        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3802        let mut start = 0;
3803        for _ in 0..line {
3804            match region[start..].find('\n') {
3805                Some(i) => start += i + 1,
3806                None => break,
3807            }
3808        }
3809        let end = region[start..]
3810            .find('\n')
3811            .map_or(region.len(), |i| start + i);
3812        new.start + end.saturating_sub(tail).max(start)
3813    }
3814
3815    /// Link the selection to `destination` — the toolbar's Link button. With no
3816    /// selection it acts at the caret, which re-points a link the caret is
3817    /// already standing in (twig replaces an existing link's destination and
3818    /// keeps its text) and otherwise spells a link that has no text of its own:
3819    /// an autolink (`<https://x.dev>`) where the destination is one, and
3820    /// `[destination](destination)` where it isn't.
3821    ///
3822    /// `destination` reaches twig raw. Escaping it is format knowledge and the
3823    /// two formats genuinely disagree — Markdown ends a destination at the first
3824    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
3825    /// itself — so the side holding the document is the side that gets to spell
3826    /// it. A destination twig can't carry at all (one with a newline) comes back
3827    /// as an error rather than a quietly rewritten URL.
3828    pub fn insert_link(&mut self, destination: &str) {
3829        if self.refuse_unsupported("link", Gesture::InsertLink) {
3830            return;
3831        }
3832        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3833        self.record_caret();
3834        match self.editor.insert_link(start, end, destination) {
3835            Ok(change) => {
3836                self.last_edit_kind = None;
3837                self.refresh();
3838                match self.link_text_span(change.new.start) {
3839                    // A link with text of its own: select it, so typing replaces
3840                    // a `[dest](dest)`'s stand-in label and a second press
3841                    // re-points what the first one linked.
3842                    Some(text) => {
3843                        self.anchor = (text.start != text.end).then_some(text.start);
3844                        self.caret = text.end;
3845                    }
3846                    // An autolink is finished the moment it's written — its text
3847                    // *is* the URL. Leaving it selected would aim the next press
3848                    // at the one shape twig still wraps instead of re-points.
3849                    None => {
3850                        self.anchor = None;
3851                        self.caret = change.new.end;
3852                    }
3853                }
3854                self.dirty = self.source != self.clean_source;
3855                self.status = None;
3856                self.clamp_caret();
3857                self.record_caret();
3858            }
3859            Err(e) => self.status = Some(format!("link: {e}")),
3860        }
3861    }
3862
3863    /// Insert a block-level image at the caret: `![alt](destination)`. Any
3864    /// selection becomes the alt text (so "select a caption, insert image" labels
3865    /// it); with no selection, `alt` is used — empty for none. The caret lands
3866    /// just past the inserted image.
3867    ///
3868    /// Both halves go through twig (`insert_literal` for the alt text,
3869    /// `insert_image` for the image), so neither is spelled here. That used to be a
3870    /// `format!`, and it was wrong the first time an app inserted a real filename:
3871    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
3872    /// not an image at all — and the fix is per-format, since moving into the
3873    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
3874    pub fn insert_image(&mut self, destination: &str, alt: &str) {
3875        if self.refuse_unsupported("image", Gesture::InsertImage) {
3876            return;
3877        }
3878        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3879        self.record_caret();
3880        // With no selection and an explicit `alt`, the alt text has to exist in the
3881        // document before it can be the image's — and it is raw caller input, so
3882        // it goes in through `insert_literal`, which escapes it for the format
3883        // rather than letting a `]` in someone's caption close the image early.
3884        let (start, end) = if start == end && !alt.is_empty() {
3885            match self.editor.insert_literal(start, alt) {
3886                Ok(change) => (change.new.start, change.new.end),
3887                Err(e) => {
3888                    self.status = Some(format!("image: {e}"));
3889                    return;
3890                }
3891            }
3892        } else {
3893            (start, end)
3894        };
3895        match self.editor.insert_image(start, end, destination) {
3896            Ok(change) => {
3897                self.last_edit_kind = None;
3898                self.refresh();
3899                // Just past the image, nothing selected — where a caret belongs
3900                // after inserting one.
3901                self.anchor = None;
3902                self.caret = change.new.end;
3903                self.dirty = self.source != self.clean_source;
3904                self.status = None;
3905                self.clamp_caret();
3906                self.record_caret();
3907            }
3908            Err(e) => self.status = Some(format!("image: {e}")),
3909        }
3910    }
3911
3912    /// Insert a block-level image, video, or audio at the caret. The image case
3913    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
3914    /// HTML elements, which is the only spelling Markdown and Djot have for them:
3915    ///
3916    /// ```text
3917    /// <video src="clip.mp4" controls>alt</video>
3918    /// <audio src="take.mp3" controls>alt</audio>
3919    /// ```
3920    ///
3921    /// HTML rather than a `::video{…}` directive deliberately. A directive means
3922    /// something only to an app that knows the vocabulary, so the document would
3923    /// read as literal punctuation everywhere else; `<video>` is what every other
3924    /// renderer already understands, and what leaf's own reader picks back up
3925    /// through `html_elements` promotion (see [`parse_extensions`]).
3926    ///
3927    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
3928    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
3929    /// `html_elements`. Before that only the multi-line form parsed as a block at
3930    /// all, and this wrote three lines to work around it.
3931    ///
3932    /// `controls` is always written: a player with no transport is a still frame
3933    /// the reader can't do anything with. Any selection becomes the element's
3934    /// fallback text, exactly as it becomes an image's alt.
3935    ///
3936    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
3937    /// applies, and bites harder here: a `"` in `destination` closes the
3938    /// attribute. A frontend taking these from a file picker is fine; one taking
3939    /// them from free text should keep them tame.
3940    ///
3941    /// [`MediaInfo`]: crate::MediaInfo
3942    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
3943        if kind == MediaKind::Image {
3944            return self.insert_image(destination, alt);
3945        }
3946        // Gated on the *image* gesture, not on one of its own — there isn't one,
3947        // since the bytes below are spelled here rather than by twig, and an HTML
3948        // document would in fact parse them. The button is one control with three
3949        // kinds behind it, and two of them working in a format where the third
3950        // cannot is a worse surface than three that agree — especially as
3951        // `insert_image` is the kind anyone reaches for first.
3952        if self.refuse_unsupported("media", Gesture::InsertImage) {
3953            return;
3954        }
3955        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3956        let alt_text = self
3957            .selected_text()
3958            .map(str::to_string)
3959            .unwrap_or_else(|| alt.to_string());
3960        let tag = match kind {
3961            MediaKind::Audio => "audio",
3962            _ => "video",
3963        };
3964        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
3965        self.edit(start, end, &markup);
3966    }
3967
3968    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
3969    /// button. Spelling and placement are both twig's; leaf used to write `---`
3970    /// itself, which was the Markdown spelling in a djot document too.
3971    ///
3972    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
3973    /// mid-paragraph and lands it after the caret's whole block. To get a rule
3974    /// *at* the caret — the paragraph parted in two around it, which is what a
3975    /// rule button is understood to do — the paragraph is first divided with
3976    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
3977    /// the offset `split_block` returns puts the rule after the *second* half
3978    /// instead, which is a rule in the right document and the wrong place.
3979    ///
3980    /// Only a plain paragraph is split. Everywhere else the rule simply lands
3981    /// after the block, which is both twig's own answer and the better one:
3982    /// splitting a fenced code block would leave two fences with a rule between
3983    /// them, and splitting a list item would mint an item nobody asked for on the
3984    /// way to a rule that lands after the list regardless. A table and a setext
3985    /// heading refuse the split outright, so they take the same path by
3986    /// themselves.
3987    pub fn insert_thematic_break(&mut self) {
3988        if self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak) {
3989            return;
3990        }
3991        self.caret = self.skip_trailing_close_delims(self.caret);
3992        // A selection is replaced by the rule, so collapse it first and let the
3993        // split-and-rule below run from the caret it leaves behind.
3994        if let Some((s, e)) = self.selection() {
3995            self.splice(s, e, "", EditKind::Other);
3996        }
3997        self.anchor = None;
3998        self.record_caret();
3999        let at = self.caret;
4000        if self.caret_in_bare_paragraph() {
4001            // A failure here is not fatal: the rule still lands after the block,
4002            // which is exactly what this call was trying to improve on.
4003            let _ = self.editor.split_block(at);
4004        }
4005        match self.editor.insert_thematic_break(at) {
4006            Ok(change) => {
4007                self.last_edit_kind = None;
4008                self.refresh();
4009                self.anchor = None;
4010                self.caret = change.new.end;
4011                self.dirty = self.source != self.clean_source;
4012                self.status = None;
4013                self.clamp_caret();
4014                self.record_caret();
4015            }
4016            Err(e) => self.status = Some(format!("thematic break: {e}")),
4017        }
4018    }
4019
4020    /// Whether the caret sits in a paragraph and nothing else — no list item, no
4021    /// quote, no fence, no table. The one shape where parting the block around
4022    /// the caret is unambiguously what a rule button means; see
4023    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4024    /// container is left to take the rule after itself.
4025    fn caret_in_bare_paragraph(&mut self) -> bool {
4026        let caret = self.caret.min(self.source.len());
4027        let Ok(chain) = self.editor.ancestors_at(caret) else {
4028            return false;
4029        };
4030        let mut in_para = false;
4031        for m in chain {
4032            match m.kind {
4033                Kind::Para => in_para = true,
4034                Kind::ListItem
4035                | Kind::TaskListItem
4036                | Kind::BlockQuote
4037                | Kind::CodeBlock
4038                | Kind::Table => return false,
4039                _ => {}
4040            }
4041        }
4042        in_para
4043    }
4044
4045    /// The destination of the link under the caret — what a Link prompt shows so
4046    /// ⌘K on an existing link edits its URL instead of asking for it again.
4047    /// `None` when the caret stands in no link.
4048    ///
4049    /// An autolink carries no separate destination: its text *is* the URL, so
4050    /// that's what comes back for one.
4051    pub fn link_destination_at_caret(&mut self) -> Option<String> {
4052        self.link_destination_at(self.caret)
4053    }
4054
4055    /// The destination of the link at `off`.
4056    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4057    /// the caret isn't.
4058    ///
4059    /// The offset form exists for the same reason
4060    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4061    /// the document somewhere else — a footnote's text in a popover, say — has
4062    /// rows and runs but no caret in them, and still needs to know which of those
4063    /// runs a reader can follow.
4064    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4065        self.nodes()
4066            .into_iter()
4067            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4068            .filter(|n| n.span.start <= off && off < n.span.end)
4069            .max_by_key(|n| n.span.start)
4070            .and_then(|n| n.destination.or(n.text))
4071    }
4072
4073    /// Where the locator `id` lands in this document — the `#v2` half of a
4074    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4075    /// answers to it.
4076    ///
4077    /// The other end of a link, and the reason this exists: without it a
4078    /// destination has only file granularity, so following a citation into a
4079    /// chapter drops the reader at the top of it to hunt for the verse. Which is
4080    /// also why it is a *document* query rather than a caret one — the document
4081    /// being asked is usually not the one the reader is in.
4082    ///
4083    /// Three readings, tried in order, because the same `#some-heading` is
4084    /// written three ways across the formats leaf opens:
4085    ///
4086    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4087    ///    the auto-ids djot mints for its headings. The only exact answer, so it
4088    ///    goes first — a document that says `{#v1}` has settled the question.
4089    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4090    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
4091    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
4092    ///    authored anywhere land on a djot heading.
4093    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4094    ///    none and `{#custom}` is literal text in a Markdown heading — so for
4095    ///    the format most vaults are written in, the heading's own words are the
4096    ///    only thing a fragment can name. This is the rule every Markdown
4097    ///    renderer already follows, which is what makes `#a-heading` mean in
4098    ///    diaryx what it means on the web.
4099    ///
4100    /// Ties go to the earliest match, then to the widest: a duplicated id is the
4101    /// document's mistake and the first one is the answer every anchor
4102    /// implementation gives, while preferring the wider span picks the section
4103    /// over the heading that opens it — more for a peek to show, same place to
4104    /// land.
4105    pub fn locate(&mut self, id: &str) -> Option<Landing> {
4106        let id = id.trim();
4107        if id.is_empty() {
4108            return None;
4109        }
4110        let nodes = self.nodes();
4111
4112        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4113        // is picking, among nodes that start together, the one that ends last.
4114        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4115            matches
4116                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4117                .map(|n| Landing {
4118                    start: n.span.start,
4119                    end: n.span.end,
4120                })
4121        };
4122
4123        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4124            return Some(landing);
4125        }
4126        let want = slug(id);
4127        if want.is_empty() {
4128            return None;
4129        }
4130        if let Some(landing) = pick(
4131            &mut nodes
4132                .iter()
4133                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4134        ) {
4135            return Some(landing);
4136        }
4137
4138        // A heading by its words. Its span is one line, so the end comes from
4139        // where the *section* it opens gives out — the next heading that is not
4140        // under it, or the end of the document. A Markdown heading has no
4141        // section node to ask (twig only builds those for djot), and a peek that
4142        // showed the heading alone would answer "what does that say" with the
4143        // title of the thing it says.
4144        let heading = nodes
4145            .iter()
4146            .filter(|n| n.kind == Kind::Heading)
4147            .filter(|n| {
4148                n.content_span
4149                    .clone()
4150                    .and_then(|s| self.source.get(s))
4151                    .is_some_and(|text| slug(text) == want)
4152            })
4153            .min_by_key(|n| n.span.start)?;
4154        let level = heading.level.unwrap_or(u32::MAX);
4155        let end = nodes
4156            .iter()
4157            .filter(|n| n.kind == Kind::Heading)
4158            .filter(|n| n.span.start > heading.span.start)
4159            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4160            .map(|n| n.span.start)
4161            .min()
4162            .unwrap_or(self.source.len());
4163        Some(Landing {
4164            start: heading.span.start,
4165            end,
4166        })
4167    }
4168
4169    /// Write a footnote at the caret — the toolbar's Footnote button, and the
4170    /// one gesture in the footnote story that *authors* rather than follows.
4171    ///
4172    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4173    /// the `[^1]:` definition at the end of the document. Half a footnote is not
4174    /// a footnote — a bare reference with nothing defining it renders as literal
4175    /// brackets — so a single button that wrote only the reference would leave
4176    /// the author to hand-spell the other half in a document that had just
4177    /// stopped showing them what the first half meant. One edit also means one
4178    /// undo takes both back.
4179    ///
4180    /// The definition's body is left empty and **the caret lands in it**, which
4181    /// is the whole point of pressing the button: nobody wants a reference to a
4182    /// note they have not written yet. Getting back to where they were writing
4183    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4184    /// the same return leg a reader following a reference already uses, so the
4185    /// author is left standing on the near end of a round trip that works.
4186    ///
4187    /// A selection collapses to its *end* rather than being replaced: a
4188    /// reference annotates the words before it, so "select the claim, add a
4189    /// footnote" should mark that claim, not consume it.
4190    pub fn insert_footnote(&mut self) {
4191        if self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4192            return;
4193        }
4194        let at = self.selection().map_or(self.caret, |(_, end)| end);
4195        self.anchor = None;
4196        self.caret = at;
4197        self.record_caret();
4198        let label = self.next_footnote_label();
4199        match self.editor.insert_footnote(at, &label) {
4200            Ok(change) => {
4201                self.last_edit_kind = None;
4202                self.refresh();
4203                self.anchor = None;
4204                // `change.new` runs from the reference to the end of the
4205                // document, so its start is the `[^1]` just written and
4206                // `footnote_at` resolves it to the note the same way a reader's
4207                // tap does — and to the note's *body*, which is already a caret
4208                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4209                // and has none), so this needs no snap on top. The fallback is
4210                // the reference's own offset: a format that spelled the pair some
4211                // way leaf can't read back should still leave the caret on the
4212                // edit rather than at the far end of a document it just grew.
4213                self.caret = self
4214                    .footnote_at(change.new.start)
4215                    .and_then(|note| note.offset)
4216                    .unwrap_or(change.new.start);
4217                self.dirty = self.source != self.clean_source;
4218                self.status = None;
4219                self.clamp_caret();
4220                self.record_caret();
4221            }
4222            Err(e) => self.status = Some(format!("footnote: {e}")),
4223        }
4224    }
4225
4226    /// The label to give a footnote the author has not named: the lowest counting
4227    /// number no footnote in the document is already wearing.
4228    ///
4229    /// twig takes the label rather than minting one, because it holds no opinion
4230    /// about what a document's footnotes should be called — and it is right not
4231    /// to. Numbering them is what every author of a numbered note expects, and
4232    /// re-using a taken number would silently point the new reference at somebody
4233    /// else's note (twig reuses an existing definition rather than appending a
4234    /// second one, which is the right rule for citing a note twice on purpose and
4235    /// exactly the wrong accident to have by default).
4236    ///
4237    /// *References* are counted alongside definitions, not just definitions: a
4238    /// document carrying a dangling `[^2]` has a 2 that means something to
4239    /// whoever wrote it, and minting a definition for it here would answer a
4240    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4241    /// count entirely — they take no number, so they block none.
4242    fn next_footnote_label(&mut self) -> String {
4243        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4244            .into_iter()
4245            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4246            .filter_map(|label| label.parse().ok())
4247            .collect();
4248        taken.extend(
4249            self.nodes()
4250                .into_iter()
4251                .filter(|n| n.kind == Kind::FootnoteReference)
4252                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4253                .filter_map(|label| label.parse::<u32>().ok()),
4254        );
4255        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4256    }
4257
4258    /// The footnote reference under the caret, resolved to the note it names.
4259    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4260    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4261        self.footnote_at(self.caret)
4262    }
4263
4264    /// The footnote reference at `off`, resolved to the note it names — what a
4265    /// frontend shows when a reader activates a `[^1]`.
4266    ///
4267    /// A reference is not a link node, so
4268    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4269    /// (and should not) answer for one: a link names a destination to leave for,
4270    /// a reference names a note that is already in this document. Following one
4271    /// is a move within the page, which is why this hands back an `offset`
4272    /// rather than something to open.
4273    ///
4274    /// Offset-based rather than caret-only because the gesture that wants this
4275    /// most is the one that must not move the caret: a pointer hovering a `[1]`
4276    /// asks what note it names without disturbing where the reader was typing.
4277    /// The caret is just the offset a click already placed —
4278    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4279    ///
4280    /// `None` when `off` stands in no reference. A reference whose note the
4281    /// document never defines is *not* `None` — it answers with the label it
4282    /// looked for and no text, which is what lets a frontend say so instead of
4283    /// silently doing nothing.
4284    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4285        // Innermost-wins by latest start, the rule its link sibling uses.
4286        let span = self
4287            .nodes()
4288            .into_iter()
4289            .filter(|n| n.kind == Kind::FootnoteReference)
4290            .filter(|n| n.span.start <= off && off < n.span.end)
4291            .max_by_key(|n| n.span.start)?
4292            .span;
4293        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4294
4295        // The note itself. Definitions are roots beside `doc` rather than
4296        // children of it, so they're asked for directly — see
4297        // `wysiwyg::footnote_definitions`.
4298        let note = wysiwyg::footnote_definitions(&mut self.editor)
4299            .into_iter()
4300            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4301        let Some(note) = note else {
4302            return Some(FootnoteRef {
4303                label,
4304                text: None,
4305                offset: None,
4306                end: None,
4307            });
4308        };
4309        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4310        Some(FootnoteRef {
4311            label,
4312            text: body
4313                .clone()
4314                .and_then(|b| self.source.get(b))
4315                .map(str::to_string),
4316            // The body's start, not the definition's — see `FootnoteRef::offset`.
4317            offset: body.clone().map(|b| b.start),
4318            end: body.map(|b| b.end),
4319        })
4320    }
4321
4322    /// The footnote *definition* the caret stands in, and where the reference
4323    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4324    /// at the caret's offset.
4325    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4326        self.footnote_definition_at(self.caret)
4327    }
4328
4329    /// The footnote definition spanning `off`, and where the reference that
4330    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4331    ///
4332    /// The mirror image, deliberately: the same gesture that takes a reader from
4333    /// `[1]` down to the note takes them from the note back up to `[1]`, so
4334    /// following a footnote is a round trip rather than a fall. It needs no
4335    /// memory of how the reader arrived — the document says where the reference
4336    /// is — which is what makes it work for a reader who scrolled to the notes
4337    /// themselves, and what keeps it right after an edit moves either end.
4338    ///
4339    /// `None` when `off` stands in no definition. A definition nothing cites is
4340    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4341    /// its label and no offset, so a frontend can say "nothing refers to this"
4342    /// rather than offer a jump that goes nowhere.
4343    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4344        // Definitions are roots beside `doc`, so `nodes()` — which walks the
4345        // document body — never reports one. They're asked for directly, the way
4346        // `footnote_at` asks for the note it resolves to.
4347        //
4348        // Closed at the end, unlike the half-open test its neighbours use. A
4349        // definition's span stops at its last content byte — the newline ending
4350        // the line is outside it — so `span.end` is the caret stop at the end of
4351        // the note's own row, not the first byte of anything after. Excluding it
4352        // meant the one caret an author is guaranteed to have, the one left
4353        // sitting at the end of the note they just typed, was in no definition at
4354        // all: writing a note and then asking to go back to its reference
4355        // answered nothing. Two definitions in a row still can't both match —
4356        // there is a blank line between them — and `max_by_key` decides anyway.
4357        let note = wysiwyg::footnote_definitions(&mut self.editor)
4358            .into_iter()
4359            .filter(|m| m.span.start <= off && off <= m.span.end)
4360            .max_by_key(|m| m.span.start)?;
4361        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4362
4363        // The earliest reference carrying this label. `min` rather than a `find`,
4364        // because `nodes()` reports a flattened walk whose order is twig's
4365        // business, not document order. Bound first: the walk needs `&mut self`
4366        // and reading the labels back out needs `&self.source`.
4367        let nodes = self.nodes();
4368        let offset = nodes
4369            .into_iter()
4370            .filter(|n| n.kind == Kind::FootnoteReference)
4371            .filter(|n| {
4372                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4373            })
4374            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4375            .map(|n| n.span.start + 2)
4376            .min();
4377        Some(FootnoteDef { label, offset })
4378    }
4379
4380    /// The destination of the image under the caret — what an image prompt shows
4381    /// so editing an existing image starts from its current URL instead of blank,
4382    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4383    /// `None` when the caret stands in no image. A caret resting just after a
4384    /// block image (its trailing stop) is still "in" it — the half-open span test
4385    /// excludes that offset, which is the intended precision: past the image is
4386    /// past it.
4387    pub fn image_destination_at_caret(&mut self) -> Option<String> {
4388        let off = self.caret;
4389        self.nodes()
4390            .into_iter()
4391            .filter(|n| n.kind == Kind::Image)
4392            .filter(|n| n.span.start <= off && off < n.span.end)
4393            .max_by_key(|n| n.span.start)
4394            .and_then(|n| n.destination)
4395    }
4396
4397    /// The language of the fenced code block the caret stands in — what a
4398    /// language prompt shows so editing it starts from the current value rather
4399    /// than blank. `None` when the caret is in no code block, or in one whose
4400    /// fence carries no language (or an indented block, which has no fence).
4401    pub fn code_language_at_caret(&mut self) -> Option<String> {
4402        let start = self.code_block_start_at_caret()?;
4403        wysiwyg::code_language(&self.source, start)
4404    }
4405
4406    /// Whether the caret stands in a fenced code block — the one a language
4407    /// prompt could edit. A frontend gates its "set language" affordance on this
4408    /// (an indented block, which can't carry a language, reports `false`).
4409    pub fn caret_in_fenced_code(&mut self) -> bool {
4410        self.code_block_start_at_caret()
4411            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4412    }
4413
4414    /// Set (or clear, with `""`) the language of the fenced code block the caret
4415    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4416    /// block, and a reported error for a language the format's fence cannot
4417    /// carry.
4418    ///
4419    /// twig rewrites the info string, so the fence's own width — measured
4420    /// against a body neither side touches — is kept, and a language holding a
4421    /// space, a line end or the fence character is refused rather than written
4422    /// out to reparse as something else. Leaf used to splice over the info span
4423    /// itself and `trim()` the input, which handled the one bad case it had
4424    /// thought of.
4425    pub fn set_code_language(&mut self, lang: &str) {
4426        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4427            return;
4428        }
4429        if self.code_block_start_at_caret().is_none() {
4430            return;
4431        }
4432        let lang = lang.trim();
4433        // `None` clears the info string; `Some("")` asks for an empty one. Both
4434        // write a bare fence, and the prompt's empty value means "clear".
4435        let want = (!lang.is_empty()).then_some(lang);
4436        self.record_caret();
4437        match self.editor.set_code_language(self.caret, want) {
4438            Ok(_) => {
4439                self.last_edit_kind = None;
4440                self.refresh();
4441                self.anchor = None;
4442                self.dirty = self.source != self.clean_source;
4443                self.status = None;
4444                self.clamp_caret();
4445                self.record_caret();
4446            }
4447            Err(e) => self.status = Some(format!("code language: {e}")),
4448        }
4449    }
4450
4451    /// The `span.start` of the code block covering the caret — the anchor
4452    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4453    /// in none.
4454    fn code_block_start_at_caret(&mut self) -> Option<usize> {
4455        let off = self.caret;
4456        self.nodes()
4457            .into_iter()
4458            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4459            .max_by_key(|n| n.span.start)
4460            .map(|n| n.span.start)
4461    }
4462
4463    /// The source range of the text inside the link covering `off` — what sits
4464    /// between its `[` and `]`. `None` when twig reports no link there.
4465    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4466        self.nodes()
4467            .into_iter()
4468            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4469            // the other's `span.start`; the link that starts latest at or before
4470            // `off` is the one `off` is actually in.
4471            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4472            .max_by_key(|n| n.span.start)
4473            .and_then(|n| n.content_span)
4474    }
4475
4476    // ── undo / redo ───────────────────────────────────────────────────────────
4477    // twig owns the history of *bytes* (it owns the buffer) and now carries the
4478    // caret through it too: `record_caret` stashes each state's caret in twig's
4479    // opaque per-step blob, and undo/redo hand it back with the source they
4480    // restore. So leaf keeps no history of its own — no parallel stacks to march
4481    // in lockstep and silently drift out of it.
4482
4483    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4484    /// where they were when that step began.
4485    pub fn undo(&mut self) {
4486        if self.read_only {
4487            return;
4488        }
4489        match self.editor.undo() {
4490            Ok(Some(change)) => self.after_history(change),
4491            Ok(None) => self.status = Some("nothing to undo".into()),
4492            Err(e) => self.status = Some(format!("undo: {e}")),
4493        }
4494    }
4495
4496    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4497    /// selection back where that step originally left them.
4498    pub fn redo(&mut self) {
4499        if self.read_only {
4500            return;
4501        }
4502        match self.editor.redo() {
4503            Ok(Some(change)) => self.after_history(change),
4504            Ok(None) => self.status = Some("nothing to redo".into()),
4505            Err(e) => self.status = Some(format!("redo: {e}")),
4506        }
4507    }
4508
4509    /// Refresh the cached source and put the caret back where the step being
4510    /// undone/redone had it, clearing any active run.
4511    ///
4512    /// The caret comes from twig's blob for the restored state (what
4513    /// `record_caret` stored). `change` is only the fallback for a state with no
4514    /// blob — a caret at the end of the restored text, which is where this always
4515    /// landed before the blobs were kept. It is the edit site, not where the user
4516    /// was standing, so it's a floor and not the behaviour: undoing should hand
4517    /// back the document *and* the place you were working, which for an edit made
4518    /// anywhere but under the caret are two different places.
4519    fn after_history(&mut self, change: Change) {
4520        self.refresh();
4521        match self
4522            .editor
4523            .caret_blob()
4524            .ok()
4525            .and_then(|b| CaretState::from_blob(&b))
4526        {
4527            Some(state) => {
4528                self.caret = state.caret.min(self.source.len());
4529                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4530            }
4531            None => {
4532                self.caret = change.new.end.min(self.source.len());
4533                self.anchor = None;
4534            }
4535        }
4536        self.goal_col = None;
4537        self.last_edit_kind = None;
4538        self.dirty = self.source != self.clean_source;
4539        self.status = None;
4540        self.clamp_caret();
4541    }
4542
4543    // ── the file ──────────────────────────────────────────────────────────────
4544
4545    #[cfg(feature = "fs")]
4546    pub fn save(&mut self) {
4547        if self.is_untitled() {
4548            // No path to write and no name to invent: ⌘S on an untitled document
4549            // is a Save As, and only a frontend has a picker to ask with. Say so
4550            // rather than failing at the filesystem with an empty path.
4551            self.status = Some("untitled — save as…".into());
4552            return;
4553        }
4554        let path = self.path.clone();
4555        if self.write(&path) {
4556            self.mark_saved();
4557        }
4558    }
4559
4560    /// Save As: write the document to `path` and *move* it there — `self.path`
4561    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4562    /// what Save As means; a copy would leave the user editing a document whose
4563    /// name is no longer where their keystrokes go.
4564    ///
4565    /// The move only happens if the bytes actually landed. A failed write leaves
4566    /// the path, `dirty`, and the disk watermark exactly as they were, with the
4567    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4568    /// must never come away believing it was saved.
4569    ///
4570    /// An existing `path` is overwritten, and the caller is the one that knows
4571    /// whether to ask first: a Save As picker has already run that prompt, and a
4572    /// second confirmation from down here would be the same question twice.
4573    ///
4574    /// `format` does **not** follow the new extension. The buffer is parsed as
4575    /// the format it was opened with, and re-reading it as another one is a
4576    /// conversion — a different, lossy operation that would throw away the undo
4577    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4578    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4579    /// until it's reopened.
4580    #[cfg(feature = "fs")]
4581    pub fn save_as(&mut self, path: PathBuf) {
4582        if !self.write(&path) {
4583            return;
4584        }
4585        self.path = path;
4586        self.mark_saved();
4587    }
4588
4589    /// Put `source` on disk at `path`, reporting whether it got there. The one
4590    /// place leaf writes a document, so a save and a Save As can't disagree
4591    /// about what a failure looks like.
4592    #[cfg(feature = "fs")]
4593    fn write(&mut self, path: &Path) -> bool {
4594        match std::fs::write(path, self.source.as_bytes()) {
4595            Ok(()) => true,
4596            Err(e) => {
4597                self.status = Some(format!("save failed: {e}"));
4598                false
4599            }
4600        }
4601    }
4602
4603    /// Re-base the document's saved watermark to the current bytes: clears
4604    /// `dirty`, records `source` as the new clean state (so undoing back to here
4605    /// clears the flag again), and re-stamps the on-disk hash.
4606    ///
4607    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4608    /// the hook a **filesystem-free host** calls itself once it has persisted
4609    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4610    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4611    /// are already where that host wants them, and this just tells the model they
4612    /// are safe.
4613    pub fn mark_saved(&mut self) {
4614        self.clean_source = self.source.clone();
4615        self.dirty = false;
4616        // The bytes on disk are now ours, so this is the new watermark: without
4617        // re-stamping it, every save would report its own work as an external
4618        // change forever after.
4619        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4620        self.status = Some(format!("saved {}", self.file_name()));
4621    }
4622
4623    /// What the file looks like now against the bytes leaf last read or wrote.
4624    ///
4625    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4626    /// so this is a filesystem round-trip, not a per-frame question — ask it
4627    /// when a window regains focus, on a timer, or before a save.
4628    ///
4629    /// This *only* reports the file. Whether the document also has unsaved edits
4630    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4631    /// [`DiskState::Changed`] means a save overwrites someone's work and a
4632    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4633    /// it has no way to ask — so it hands a frontend both halves and lets it put
4634    /// the question to the person who can answer it.
4635    #[cfg(feature = "fs")]
4636    pub fn disk_state(&self) -> DiskState {
4637        let Some(want) = self.disk_hash else {
4638            return DiskState::Untitled;
4639        };
4640        match std::fs::read(&self.path) {
4641            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4642            Ok(_) => DiskState::Changed,
4643            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4644            Err(_) => DiskState::Unreadable,
4645        }
4646    }
4647
4648    /// Re-read the file and replace the document with what's there — the other
4649    /// answer to a [`DiskState::Changed`].
4650    ///
4651    /// **Discards unsaved changes and the undo history, unconditionally.** It
4652    /// doesn't check `dirty` first: a frontend that wants to protect unsaved
4653    /// work asks (`dirty` + [`Doc::disk_state`]) *before* calling this, and one
4654    /// reloading a clean document shouldn't have to argue with a guard. The
4655    /// history goes because twig's undo stack belongs to the buffer, and these
4656    /// are different bytes — replaying a step recorded against the old ones onto
4657    /// them would corrupt the document, and nothing here can honestly rebase it.
4658    ///
4659    /// The caret keeps its byte offset, clamped to the new length; the selection
4660    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4661    /// file changed, so it can't know where the caret "still" is. Clamping keeps
4662    /// it where the user left it in the common case (a change further down the
4663    /// file, or none in the text they're sitting in), and never puts it
4664    /// somewhere invalid. A selection has two such offsets and no such excuse —
4665    /// silently reinterpreting one over changed bytes would arm the *next*
4666    /// keystroke to delete something the user never selected.
4667    ///
4668    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4669    /// document alone with a status.
4670    #[cfg(feature = "fs")]
4671    pub fn reload(&mut self) {
4672        if self.is_untitled() {
4673            self.status = Some("no file to reload".into());
4674            return;
4675        }
4676        let bytes = match std::fs::read(&self.path) {
4677            Ok(b) => b,
4678            Err(e) => {
4679                self.status = Some(format!("reload failed: {e}"));
4680                return;
4681            }
4682        };
4683        let Ok(source) = String::from_utf8(bytes) else {
4684            self.status = Some("reload failed: file is not UTF-8".into());
4685            return;
4686        };
4687        // Reparse rather than splice the difference in: leaf doesn't know what
4688        // changed, and `format` is the format this document is, not what the
4689        // (unchanged) name now says — see `save_as`.
4690        let editor = match new_editor(source.as_bytes(), self.format) {
4691            Ok(ed) => ed,
4692            Err(e) => {
4693                self.status = Some(format!("reload failed: {e}"));
4694                return;
4695            }
4696        };
4697        self.editor = editor;
4698        self.disk_hash = Some(hash_bytes(source.as_bytes()));
4699        self.clean_source = source.clone();
4700        self.source = source;
4701        // Reload replaces the text without going through `refresh`, so it has to
4702        // move the revision itself or every frontend would keep painting the old
4703        // file from cache.
4704        self.revision += 1;
4705        self.caret = self.caret.min(self.source.len());
4706        self.anchor = None;
4707        self.goal_col = None;
4708        self.last_edit_kind = None;
4709        self.dirty = false;
4710        self.status = Some(format!("reloaded {}", self.file_name()));
4711        self.clamp_caret();
4712    }
4713
4714    /// Re-read the source from twig after it has changed the document. The one
4715    /// funnel every edit, undo, and redo comes through — so it's where the
4716    /// revision moves, and anything cached against the text dies here.
4717    fn refresh(&mut self) {
4718        if let Ok(s) = self.editor.source_str() {
4719            self.source = s;
4720        }
4721        self.revision += 1;
4722        self.clamp_caret();
4723    }
4724
4725    // ── caret movement ─────────────────────────────────────────────────────────
4726    // `extend` grows the selection (Shift+motion): it pins the anchor on the
4727    // first extended step and moves only the caret; an un-extended motion drops
4728    // the selection.
4729
4730    /// Place the caret at byte `offset` (clamped to a char boundary), extending
4731    /// the selection when `extend` is set. The public form of `move_to`, for a
4732    /// frontend that hit-tests pixels straight to a source offset.
4733    pub fn place_caret(&mut self, offset: usize, extend: bool) {
4734        self.goal_col = None;
4735        let before = self.caret;
4736        // A pixel hit-test can land between the visible caret stops — in the
4737        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
4738        // Snap to the nearest real stop so the caret can't come to rest where it
4739        // would draw in one place and type in another. The `(row, col)` click
4740        // path (`click`) already snaps this way through `offset_of_pos`; the
4741        // source view reaches every byte, so it snaps to nothing.
4742        let target = match self.view {
4743            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
4744            // The source view reaches every byte, so there is no stop to snap
4745            // to — but "every byte" still means every *character* boundary. A
4746            // caret resting inside a multi-byte character draws nowhere real
4747            // and panics the next time anything slices there.
4748            View::Source => {
4749                let mut o = offset.min(self.source.len());
4750                while o > 0 && !self.source.is_char_boundary(o) {
4751                    o -= 1;
4752                }
4753                o
4754            }
4755        };
4756        self.move_to(target, extend);
4757        self.clamp_caret();
4758        self.debug_assert_on_a_stop(before);
4759    }
4760
4761    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
4762    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
4763    /// grab the metadata) while the source view still selects the literal whole.
4764    pub fn select_all(&mut self) {
4765        self.anchor = Some(self.caret_floor());
4766        self.caret = self.source.len();
4767        self.goal_col = None;
4768        self.last_edit_kind = None;
4769        self.status = None;
4770    }
4771
4772    /// Select the word (or whitespace / punctuation run) at `offset` — the
4773    /// double-click gesture. Anchors on the run's start with the caret at its
4774    /// end so a following Shift-motion extends from the far edge.
4775    pub fn select_word_at(&mut self, offset: usize) {
4776        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
4777        self.anchor = Some(s);
4778        self.caret = e;
4779        self.goal_col = None;
4780        self.last_edit_kind = None;
4781        self.status = None;
4782        self.clamp_caret();
4783    }
4784
4785    /// Select the whole enclosing text block (paragraph, heading, list item's
4786    /// text…) at `offset` — the triple-click gesture. Reads the range straight
4787    /// from the AST (twig's `content_span`), so it selects the entire *logical*
4788    /// paragraph even when that paragraph soft-wraps across several visual rows —
4789    /// where a visual-row-based select breaks down, because one source offset at
4790    /// a wrap boundary belongs to two rows at once.
4791    pub fn select_block_at(&mut self, offset: usize) {
4792        let off = offset.min(self.source.len());
4793        let range = self
4794            .editor
4795            .ancestors_at(off)
4796            .ok()
4797            .and_then(|chain| {
4798                // Ancestors run root → deepest; the deepest node that is neither
4799                // an inline span nor a multi-block container is the text block
4800                // the caret sits in (a paragraph, a heading, a code block…).
4801                chain
4802                    .into_iter()
4803                    .rev()
4804                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4805                    .map(|m| m.content_span.unwrap_or(m.span))
4806            })
4807            .unwrap_or_else(|| source_line_range(&self.source, off));
4808        self.anchor = Some(range.start.min(self.source.len()));
4809        self.caret = range.end.min(self.source.len());
4810        self.goal_col = None;
4811        self.last_edit_kind = None;
4812        self.status = None;
4813        self.clamp_caret();
4814    }
4815
4816    /// The lowest source offset the caret may occupy in the active view. In
4817    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
4818    /// the first rendered offset; the source view reaches everything, so it's 0.
4819    fn caret_floor(&self) -> usize {
4820        match self.view {
4821            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
4822            View::Source => 0,
4823        }
4824    }
4825
4826    /// Land in a table cell with its whole content selected — the anchor at the
4827    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
4828    /// like tabbing into a form field: the text comes up selected, so typing
4829    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
4830    /// end`) collapses to a plain caret home (an empty selection is no selection).
4831    fn select_cell(&mut self, start: usize, end: usize) {
4832        let floor = self.caret_floor();
4833        self.anchor = Some(start.min(self.source.len()).max(floor));
4834        self.caret = end.min(self.source.len()).max(floor);
4835        self.goal_col = None;
4836        self.status = None;
4837        self.last_edit_kind = None;
4838        self.clear_pending();
4839    }
4840
4841    fn move_to(&mut self, offset: usize, extend: bool) {
4842        if extend {
4843            if self.anchor.is_none() {
4844                self.anchor = Some(self.caret);
4845            }
4846        } else {
4847            self.anchor = None;
4848        }
4849        self.caret = offset.min(self.source.len()).max(self.caret_floor());
4850        self.status = None;
4851        // A caret move ends the current typing/deletion run, so the next edit
4852        // starts a fresh undo group rather than coalescing across the gap.
4853        self.last_edit_kind = None;
4854        // Moving away disarms any sticky mark — "start bold" applies only where
4855        // it was asked for, not wherever the caret next lands.
4856        self.clear_pending();
4857    }
4858
4859    // In the source view, motion walks source bytes / source lines. In the
4860    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
4861    // what steps the caret cleanly over hidden delimiters.
4862
4863    pub fn move_left(&mut self, extend: bool) {
4864        self.goal_col = None;
4865        if !extend && let Some((s, _e)) = self.selection() {
4866            self.move_to(s, false);
4867            return;
4868        }
4869        let target = match self.view {
4870            View::Source => {
4871                if self.caret > 0 {
4872                    prev_boundary(&self.source, self.caret)
4873                } else {
4874                    0
4875                }
4876            }
4877            // Walks caret *stops*, not columns: decoration (a table border, a
4878            // cell's padding) is stepped over in one press, and a hidden
4879            // delimiter never holds the caret up.
4880            View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
4881        };
4882        let before = self.caret;
4883        self.move_to(target, extend);
4884        self.debug_assert_on_a_stop(before);
4885    }
4886
4887    pub fn move_right(&mut self, extend: bool) {
4888        self.goal_col = None;
4889        if !extend && let Some((_s, e)) = self.selection() {
4890            self.move_to(e, false);
4891            return;
4892        }
4893        let target = match self.view {
4894            View::Source => {
4895                if self.caret < self.source.len() {
4896                    next_boundary(&self.source, self.caret)
4897                } else {
4898                    self.caret
4899                }
4900            }
4901            View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
4902        };
4903        let before = self.caret;
4904        self.move_to(target, extend);
4905        self.debug_assert_on_a_stop(before);
4906    }
4907
4908    /// Move to the start of the previous word (⌥← / Ctrl+←).
4909    pub fn move_word_left(&mut self, extend: bool) {
4910        self.goal_col = None;
4911        let before = self.caret;
4912        let target = self.word_left_from(self.caret);
4913        self.move_to(target, extend);
4914        self.debug_assert_on_a_stop(before);
4915    }
4916
4917    /// Move to the end of the next word (⌥→ / Ctrl+→).
4918    pub fn move_word_right(&mut self, extend: bool) {
4919        self.goal_col = None;
4920        let before = self.caret;
4921        let target = self.word_right_from(self.caret);
4922        self.move_to(target, extend);
4923        self.debug_assert_on_a_stop(before);
4924    }
4925
4926    // Word boundaries are found in the space the *view* is in. The source view
4927    // walks the source, because there the source is what's rendered. WYSIWYG
4928    // walks the rendered text instead: `**` is invisible to the user, so it has
4929    // to be invisible to word motion too — a caret parked inside one draws in
4930    // the column after `bold` and types two bytes earlier, and a word-delete
4931    // that stops there shreds the markup into `a ** c`.
4932
4933    /// The word boundary to the left of `off` in the active view's space.
4934    fn word_left_from(&self, off: usize) -> usize {
4935        match self.view {
4936            View::Source => prev_word(&self.source, off),
4937            View::Wysiwyg => self.glyph_word_left(off),
4938        }
4939    }
4940
4941    /// The word boundary to the right of `off` in the active view's space.
4942    fn word_right_from(&self, off: usize) -> usize {
4943        match self.view {
4944            View::Source => next_word(&self.source, off),
4945            View::Wysiwyg => self.glyph_word_right(off),
4946        }
4947    }
4948
4949    /// The character class of the glyph drawn at stop `off`.
4950    ///
4951    /// Read from the source, because a stop points at the source byte its glyph
4952    /// came from — the source *is* where the rendered character is written. What
4953    /// makes the walk glyph space rather than source space is that it only ever
4954    /// visits stops, and the hidden bytes between them have none.
4955    fn class_at(&self, off: usize) -> Class {
4956        self.source
4957            .get(off..)
4958            .and_then(|s| s.chars().next())
4959            .map_or(Class::Space, classify)
4960    }
4961
4962    /// [`next_word`] in glyph space: skip any leading separators, then consume
4963    /// the following word run, with the stop table standing in for the source's
4964    /// characters.
4965    fn glyph_word_right(&self, from: usize) -> usize {
4966        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
4967            return from;
4968        };
4969        let mut in_word = false;
4970        loop {
4971            match self.class_at(off) {
4972                Class::Word => in_word = true,
4973                _ if in_word => return off,
4974                _ => {}
4975            }
4976            match self.vmap.stop_after(off) {
4977                Some(next) => off = next,
4978                None => return off,
4979            }
4980        }
4981    }
4982
4983    /// [`prev_word`] in glyph space: skip separators walking left, then consume
4984    /// the preceding word run.
4985    fn glyph_word_left(&self, from: usize) -> usize {
4986        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
4987            return from;
4988        };
4989        let mut in_word = false;
4990        while let Some(prev) = self.vmap.stop_before(off) {
4991            match self.class_at(prev) {
4992                Class::Word => in_word = true,
4993                _ if in_word => return off,
4994                _ => {}
4995            }
4996            off = prev;
4997        }
4998        off
4999    }
5000
5001    /// After a motion that walks the visual map, the caret must be *on* the map.
5002    /// A stop is the only offset where the caret draws and edits in the same
5003    /// place, and it's the invariant both a caret parked inside an emoji and one
5004    /// parked inside a `**` were quietly breaking.
5005    ///
5006    /// Only when the caret actually moved: a walk with nowhere to go leaves it
5007    /// where it was, which is wherever the floor or a frontend put it rather
5008    /// than somewhere this motion chose.
5009    fn debug_assert_on_a_stop(&self, before: usize) {
5010        debug_assert!(
5011            self.view != View::Wysiwyg
5012                || self.vmap.num_rows() == 0
5013                || self.caret == before
5014                || self.vmap.is_stop(self.caret),
5015            "motion left the caret at {}, which is not a caret stop: it would draw in \
5016             one place and type in another",
5017            self.caret
5018        );
5019    }
5020
5021    // Up and Down run off the ends of the document rather than stopping dead at
5022    // them: Up from the first row lands at the document's start, Down from the
5023    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5024    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5025    // reaching the end of the text is what a reader means by it.
5026    //
5027    // The views used to disagree here by accident rather than by decision: the
5028    // source view fell into the edge behaviour through `row_col_to_offset`
5029    // clamping an out-of-range row to the end of the string, while WYSIWYG had
5030    // no row below to walk to and did nothing at all. They share the rule now,
5031    // each in its own space — the source view reaches every byte, WYSIWYG only
5032    // the offsets it draws.
5033
5034    pub fn move_up(&mut self, extend: bool) {
5035        let (row, col) = self.caret_pos();
5036        let goal = self.goal_col.unwrap_or(col);
5037        let target = match self.view {
5038            View::Source => match row.checked_sub(1) {
5039                Some(r) => row_col_to_offset(&self.source, r, goal),
5040                None => self.reachable_start(),
5041            },
5042            // A table's border rules are drawn but hold no caret, so Up steps
5043            // over them to the row that does.
5044            View::Wysiwyg => match self.vmap.navigable_above(row) {
5045                Some(r) => self.row_target(r, goal),
5046                None => self.reachable_start(),
5047            },
5048        };
5049        self.step_vertical(target, goal, extend);
5050    }
5051
5052    pub fn move_down(&mut self, extend: bool) {
5053        let (row, col) = self.caret_pos();
5054        let goal = self.goal_col.unwrap_or(col);
5055        let target = match self.view {
5056            View::Source => match self.source_row_below(row) {
5057                Some(r) => row_col_to_offset(&self.source, r, goal),
5058                None => self.reachable_end(),
5059            },
5060            View::Wysiwyg => match self.vmap.navigable_below(row) {
5061                Some(r) => self.row_target(r, goal),
5062                None => self.reachable_end(),
5063            },
5064        };
5065        self.step_vertical(target, goal, extend);
5066    }
5067
5068    /// Land a vertical motion at `target`, latching the `goal` column it aimed
5069    /// with so the rest of the run keeps aiming there.
5070    ///
5071    /// A motion with nowhere to go changes *nothing*, the goal column included:
5072    /// the latch used to run before the early return at the top of the document,
5073    /// so an Up that did nothing still armed a column, and the next Down aimed
5074    /// at one the caret had never been in.
5075    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5076        let before = self.caret;
5077        if target == before {
5078            return;
5079        }
5080        self.goal_col = Some(goal);
5081        self.move_to(target, extend);
5082        self.debug_assert_on_a_stop(before);
5083    }
5084
5085    /// The source line below `row`, or `None` when `row` is the last one. Lines
5086    /// are counted by newline, so a trailing one leaves a real, empty last line
5087    /// for the caret to sit on — the document ends below it, not on it.
5088    fn source_row_below(&self, row: usize) -> Option<usize> {
5089        let last = self.source.bytes().filter(|&b| b == b'\n').count();
5090        (row < last).then_some(row + 1)
5091    }
5092
5093    /// Where a vertical motion aiming at the `goal` column lands on visual row
5094    /// `r`: the column clamped to the row, mapped to its offset, then held
5095    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5096    /// column belongs to the row below, and a gutter's column 0 points at the
5097    /// block rather than at this row.
5098    fn row_target(&self, r: usize, goal: usize) -> usize {
5099        let (start, end) = self.row_bounds(r);
5100        self.vmap
5101            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5102            .clamp(start, end)
5103    }
5104
5105    /// The first and last offsets the caret can reach in the active view.
5106    ///
5107    /// Not the same span in both: the source view shows every byte, so it can
5108    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5109    /// sits below the first stop, and a document's trailing newline is drawn
5110    /// nowhere and so sits past the last.
5111    fn reachable_start(&self) -> usize {
5112        match self.view {
5113            View::Source => 0,
5114            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5115        }
5116    }
5117
5118    fn reachable_end(&self) -> usize {
5119        match self.view {
5120            View::Source => self.source.len(),
5121            View::Wysiwyg => self
5122                .vmap
5123                .stop_at_or_before(self.source.len())
5124                .unwrap_or(self.caret),
5125        }
5126    }
5127
5128    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5129    /// including the space a soft wrap ate off its end, which is drawn on this
5130    /// row however much the offset past it belongs to the next one.
5131    fn row_span(&self, r: usize) -> (usize, usize) {
5132        let start = self
5133            .vmap
5134            .row_start(r)
5135            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5136        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5137        (start.min(end), end)
5138    }
5139
5140    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5141    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5142    /// this row's last position is the one before it — the offset before the
5143    /// space the wrap ate, where the caret draws just past the row's last word
5144    /// and types there too.
5145    ///
5146    /// Aiming at the shared offset instead is what stalled End: it is the row's
5147    /// last *column*, so End pressed on the row reached it and then read back as
5148    /// the row below's start, where a second press ran on to that row's end and
5149    /// the next to the one after — End walking down the paragraph a row a press.
5150    fn row_bounds(&self, r: usize) -> (usize, usize) {
5151        let (start, end) = self.row_span(r);
5152        let wraps = self
5153            .vmap
5154            .navigable_below(r)
5155            .and_then(|b| self.vmap.row_start(b))
5156            .is_some_and(|off| off == end);
5157        match wraps {
5158            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5159            false => (start, end),
5160        }
5161    }
5162
5163    /// The `[start, end]` of the line Home and End aim at: the visual row in
5164    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5165    ///
5166    /// A soft-wrapped row is a line here, because it is one to the eye and the
5167    /// eye is what these keys are aimed by — a reader pressing End means the end
5168    /// of the line they can see. (`select_block_at` wants the opposite and reads
5169    /// the AST for it: a triple-click grabs the whole paragraph, however many
5170    /// rows it folds into.)
5171    fn line_bounds(&self) -> (usize, usize) {
5172        let (row, _) = self.caret_pos();
5173        match self.view {
5174            View::Source => {
5175                let start = line_start(&self.source, row);
5176                (start, line_end_from(&self.source, start))
5177            }
5178            View::Wysiwyg => self.row_bounds(row),
5179        }
5180    }
5181
5182    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5183    /// *drawn* — what a kill takes.
5184    ///
5185    /// The two part only at a soft wrap, over the space the wrap ate: the caret
5186    /// can't stand after it (that offset opens the row below, and End stopping
5187    /// there would walk), but it is on this row, and a kill that spared it would
5188    /// leave a double space behind where the row's text had been. Deleting it
5189    /// joins nothing — a wrap is drawn, not written.
5190    fn line_span(&self) -> (usize, usize) {
5191        let (row, _) = self.caret_pos();
5192        match self.view {
5193            View::Source => self.line_bounds(),
5194            View::Wysiwyg => self.row_span(row),
5195        }
5196    }
5197
5198    /// The first offset in `[start, end]` holding something other than
5199    /// whitespace, or `end` when the line holds nothing else — where Home aims.
5200    ///
5201    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5202    /// so a hidden delimiter is never taken for the line's first character (nor
5203    /// landed on), and the source view steps the source it is showing.
5204    fn first_non_space(&self, start: usize, end: usize) -> usize {
5205        let mut off = start;
5206        while off < end {
5207            if self.class_at(off) != Class::Space {
5208                return off;
5209            }
5210            off = match self.view {
5211                View::Source => next_boundary(&self.source, off),
5212                View::Wysiwyg => match self.vmap.stop_after(off) {
5213                    Some(next) => next,
5214                    None => return end,
5215                },
5216            };
5217        }
5218        end
5219    }
5220
5221    /// Home: to the first character on the line, or to column 0 when the caret
5222    /// is already on it — the two-press toggle every editor spells this way.
5223    /// The indentation is somewhere the caret has to be able to reach and almost
5224    /// never where a reader is headed, so it costs the second press.
5225    pub fn move_home(&mut self, extend: bool) {
5226        self.goal_col = None;
5227        let (start, end) = self.line_bounds();
5228        let text = self.first_non_space(start, end);
5229        let target = if self.caret == text { start } else { text };
5230        let before = self.caret;
5231        self.move_to(target, extend);
5232        self.debug_assert_on_a_stop(before);
5233    }
5234
5235    /// End: to the end of the line.
5236    pub fn move_end(&mut self, extend: bool) {
5237        self.goal_col = None;
5238        let (_, end) = self.line_bounds();
5239        let before = self.caret;
5240        self.move_to(end, extend);
5241        self.debug_assert_on_a_stop(before);
5242    }
5243
5244    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5245    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5246    /// when the caret isn't in a table, or is already in the last/first cell — the
5247    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5248    /// meaning everywhere else.
5249    pub fn cell_hop(&mut self, forward: bool) -> bool {
5250        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5251            return false;
5252        };
5253        // Flatten to document (row-major) order and step one cell either way.
5254        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5255        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5256        let next = if forward {
5257            i.checked_add(1)
5258        } else {
5259            i.checked_sub(1)
5260        };
5261        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5262            return false; // at the table's edge; leave Tab to the frontend
5263        };
5264        self.select_cell(start, end);
5265        true
5266    }
5267
5268    /// Move the caret to the cell directly above (`down == false`) or below in
5269    /// the same column, landing with the cell's whole content selected (see
5270    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5271    /// when the caret isn't in a table), so the frontend can fall through — the
5272    /// vertical counterpart of [`cell_hop`].
5273    ///
5274    /// A ragged row that is short a column clamps to its last cell, so Down never
5275    /// falls out of the table over a gap the row above happened to have.
5276    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5277        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5278            return false;
5279        };
5280        let target = match down {
5281            true => r + 1,
5282            false if r == 0 => return false,
5283            false => r - 1,
5284        };
5285        let Some(row) = grid.get(target) else {
5286            return false;
5287        };
5288        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5289            return false;
5290        };
5291        self.select_cell(start, end);
5292        true
5293    }
5294
5295    /// The table containing `off` as a row-major grid of `(start, end)` cell
5296    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5297    /// isn't in a table. Read straight off the visual map's laid-out grid, so
5298    /// every cell (an empty one included, whose derived home twig gives no
5299    /// `content_span` for) is present and in the order Tab walks them.
5300    // Grid, row, column — three returns that only ever travel together, and a
5301    // named type for the pair of them would be read at one call site.
5302    #[allow(clippy::type_complexity)]
5303    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5304        for t in &self.vmap.tables {
5305            let mut pos = None;
5306            let grid: Vec<Vec<(usize, usize)>> = t
5307                .grid
5308                .iter()
5309                .enumerate()
5310                .map(|(r, row)| {
5311                    row.cells
5312                        .iter()
5313                        .enumerate()
5314                        .map(|(c, cell)| {
5315                            if pos.is_none() && off >= cell.start && off <= cell.end {
5316                                pos = Some((r, c));
5317                            }
5318                            (cell.start, cell.end)
5319                        })
5320                        .collect()
5321                })
5322                .collect();
5323            if let Some((r, c)) = pos {
5324                return Some((grid, r, c));
5325            }
5326        }
5327        None
5328    }
5329
5330    // ── table key policy ──────────────────────────────────────────────────────
5331    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5332    // as one policy every frontend shares, rather than each re-deriving it. Each
5333    // reports whether it acted *as a table key*; a `false` hands the key back to
5334    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5335    // everywhere else.
5336
5337    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5338    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5339    /// back and simply stays put at the very first cell. `false` when the caret
5340    /// isn't in a table.
5341    pub fn cell_tab(&mut self, forward: bool) -> bool {
5342        if !self.caret_in_table() {
5343            return false;
5344        }
5345        if self.cell_hop(forward) {
5346            return true;
5347        }
5348        // Off the last cell: grow the table by a row and step into its first
5349        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5350        if forward {
5351            self.append_row_and_enter(0);
5352        }
5353        true
5354    }
5355
5356    /// Return inside a table: drop to the cell below in the same column,
5357    /// appending a new row when the caret is already in the last one. `false`
5358    /// when the caret isn't in a table, so the frontend inserts a newline.
5359    pub fn cell_return(&mut self) -> bool {
5360        if !self.caret_in_table() {
5361            return false;
5362        }
5363        if self.cell_move_vertical(true) {
5364            return true;
5365        }
5366        // Already on the last row: grow one below and drop into the same column.
5367        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5368        self.append_row_and_enter(col);
5369        true
5370    }
5371
5372    /// Append a row below the caret's (last) row and land in `col` of it. The
5373    /// caret is in the last row, so twig's "insert below" makes the fresh row the
5374    /// table's new last — but twig re-spells the whole table, moving every byte,
5375    /// so the destination is read back from the rebuilt grid by the table's
5376    /// position (stable across a row insert), not from the pre-edit caret.
5377    fn append_row_and_enter(&mut self, col: usize) {
5378        let table = self.caret_table_index();
5379        self.table_insert_row(true);
5380        self.rebuild_map();
5381        let Some((start, end)) = table
5382            .and_then(|ti| self.vmap.tables.get(ti))
5383            .and_then(|t| t.grid.last())
5384            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5385            .map(|cell| (cell.start, cell.end))
5386        else {
5387            return;
5388        };
5389        self.select_cell(start, end);
5390    }
5391
5392    /// The index, among the document's tables, of the one the caret sits in —
5393    /// `None` when it's in none. Used to re-find a table after an edit re-spells
5394    /// it (a row insert leaves the table order unchanged).
5395    fn caret_table_index(&self) -> Option<usize> {
5396        let off = self.caret;
5397        self.vmap.tables.iter().position(|t| {
5398            t.grid
5399                .iter()
5400                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5401        })
5402    }
5403
5404    /// Shift+Return inside a table: insert a hard line break *within* the current
5405    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5406    /// table, so the frontend inserts an ordinary line break.
5407    ///
5408    /// A table row is a single source line, so the newline-spelled hard break
5409    /// can't live in a cell. twig spells the in-cell break the format's way
5410    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5411    /// break round-trips as structure the renderer reads back as a line — not the
5412    /// opaque raw HTML the old raw-splice left behind.
5413    ///
5414    /// Djot has no idiomatic in-cell break, so twig refuses it
5415    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5416    /// would render as the literal text `<br>`. The gesture is still *consumed*
5417    /// there — returning `false` would let the frontend insert a real newline,
5418    /// which splits the one-line row — it just leaves the cell unchanged and says
5419    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5420    ///
5421    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5422    /// have to be read together: djot is not the only `false`, and naming it in
5423    /// the message was already a guess that HTML — which spells the break as its
5424    /// own `<br>` — would have made wrong.
5425    pub fn cell_line_break(&mut self) -> bool {
5426        if !self.caret_in_table() {
5427            return false;
5428        }
5429        self.record_caret();
5430        match self.editor.insert_line_break(self.caret) {
5431            Ok(change) => {
5432                self.last_edit_kind = None;
5433                self.refresh();
5434                self.caret = change.new.end;
5435                self.anchor = None;
5436                self.goal_col = None;
5437                self.clamp_caret();
5438                self.dirty = self.source != self.clean_source;
5439                self.status = None;
5440                self.record_caret();
5441            }
5442            Err(twig::Error::UnsupportedFormat) => {
5443                self.status = Some(format!(
5444                    "in-cell line breaks aren't supported in {}",
5445                    self.format_name()
5446                ));
5447            }
5448            Err(_) => {}
5449        }
5450        true
5451    }
5452
5453    /// Rebuild the visual map at the width the last build used. A structural edit
5454    /// bumps the revision and swaps the source in, but leaves the *map* stale;
5455    /// when a single gesture edits and then moves over the result (Tab appending
5456    /// a row, then stepping into it), the move needs the map to already show the
5457    /// edit rather than waiting for the frontend's next frame.
5458    fn rebuild_map(&mut self) {
5459        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5460        self.build_map(wrap);
5461    }
5462
5463    /// Move the caret to the very start of the document (⌘↑ on macOS,
5464    /// Ctrl+Home on Windows/Linux).
5465    pub fn move_doc_start(&mut self, extend: bool) {
5466        self.goal_col = None;
5467        self.move_to(0, extend);
5468    }
5469
5470    /// Move the caret to the very end of the document (⌘↓ on macOS,
5471    /// Ctrl+End on Windows/Linux).
5472    pub fn move_doc_end(&mut self, extend: bool) {
5473        self.goal_col = None;
5474        let end = self.source.len();
5475        self.move_to(end, extend);
5476    }
5477
5478    /// Point the caret at the body cell `(row, col)` the mouse landed on —
5479    /// `col` being a cell of the terminal grid, which is what a display column
5480    /// is. A click on the far cell of a wide character lands at that
5481    /// character's start; the mapping's own doc-comments carry the rule.
5482    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5483        self.goal_col = None;
5484        let target = match self.view {
5485            View::Source => row_col_to_offset(&self.source, row, col),
5486            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5487        };
5488        let before = self.caret;
5489        self.move_to(target, extend);
5490        self.debug_assert_on_a_stop(before);
5491    }
5492
5493    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5494    /// screen if it has moved since the last frame, and never scroll past the
5495    /// last of `rows`.
5496    ///
5497    /// Only if it has *moved* — that's the whole point. Revealing the caret on
5498    /// every frame ties the viewport to it, and a scroll wheel that fights the
5499    /// caret for the viewport loses: the view snaps back the instant it tries to
5500    /// pass the caret's row, so the document can't be scrolled beyond what's
5501    /// already on screen. A caret move is the frontend's cue to follow; a scroll
5502    /// with the caret sitting still is the reader's cue to leave it alone.
5503    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5504        if self.drawn_caret != Some(self.caret) {
5505            if caret_row < self.scroll {
5506                self.scroll = caret_row;
5507            } else if height > 0 && caret_row >= self.scroll + height {
5508                self.scroll = caret_row + 1 - height;
5509            }
5510            self.drawn_caret = Some(self.caret);
5511        }
5512        self.scroll = self.scroll.min(rows.saturating_sub(1));
5513    }
5514
5515    /// The caret's screen position `(row, col)` in the active view's grid, with
5516    /// `col` a display column: the cell to draw the caret in, which on a line of
5517    /// `你好` or emoji is not the count of characters before it.
5518    pub fn caret_pos(&self) -> (usize, usize) {
5519        match self.view {
5520            View::Source => offset_to_row_col(&self.source, self.caret),
5521            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5522        }
5523    }
5524
5525    fn clamp_caret(&mut self) {
5526        if self.caret > self.source.len() {
5527            self.caret = self.source.len();
5528        }
5529        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5530        // any selection anchor) to the first rendered offset.
5531        let floor = self.caret_floor();
5532        if self.caret < floor {
5533            self.caret = floor;
5534        }
5535        if let Some(a) = self.anchor
5536            && a < floor
5537        {
5538            self.anchor = Some(floor);
5539        }
5540        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5541            self.caret -= 1;
5542        }
5543    }
5544}
5545
5546// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5547
5548// Left/right motion and backspace/delete step by *grapheme cluster*, not
5549// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5550// marks moves and deletes as the single character a user sees. Grapheme
5551// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5552
5553/// How an insert of `text` groups for undo: a single typed character folds into
5554/// the run of typing around it, while a newline or a multi-character insert is a
5555/// step of its own.
5556fn typed_edit_kind(text: &str) -> EditKind {
5557    if text.chars().take(2).count() == 1 && text != "\n" {
5558        EditKind::Insert
5559    } else {
5560        EditKind::Other
5561    }
5562}
5563
5564fn prev_boundary(s: &str, i: usize) -> usize {
5565    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5566    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5567}
5568
5569fn next_boundary(s: &str, i: usize) -> usize {
5570    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5571    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5572}
5573
5574// ── word boundaries ──────────────────────────────────────────────────────────
5575// The shared primitive behind word-wise motion, word deletion, and
5576// double-click-to-select-a-word. A "word" is a maximal run of one character
5577// class; whitespace and punctuation are their own classes, so motion skips
5578// cleanly between them the way native text fields do.
5579
5580#[derive(PartialEq, Eq, Clone, Copy)]
5581enum Class {
5582    Word,
5583    Space,
5584    Other,
5585}
5586
5587/// The source range of an inline node's own visible text — the part of it a
5588/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5589/// `None` for a node with no interior to empty (a `str`, a break).
5590///
5591/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5592/// one delimiter in from the span — the same place the renderer maps it to. A
5593/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5594/// against the source rather than trusted: a range guessed wrong here is text
5595/// deleted wrong.
5596fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5597    if let Some(span) = n.content_span.clone() {
5598        return Some(span);
5599    }
5600    match n.kind.as_str() {
5601        "verbatim" | "inline_math" => {
5602            let text = n.text.as_ref()?;
5603            let start = n.span.start + 1;
5604            let range = start..start + text.len();
5605            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
5606        }
5607        _ => None,
5608    }
5609}
5610
5611/// The `id` a node declares, or `None` for one that declares none — the
5612/// attribute djot writes for a `{#v1}` and mints for a heading.
5613///
5614/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
5615/// names nothing, so it reads as absent rather than as the empty string.
5616fn declared_id(n: &FlatNode) -> Option<&str> {
5617    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
5618}
5619
5620/// A heading's words reduced to the form a link fragment spells them in:
5621/// lowercase, runs of anything else collapsed to a single `-`, with none left
5622/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
5623///
5624/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
5625/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
5626/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
5627/// any other language is still a heading someone will link to. Underscores
5628/// survive for the same reason they do on the web: they are word characters
5629/// wherever identifiers are written.
5630fn slug(text: &str) -> String {
5631    let mut out = String::new();
5632    let mut pending = false;
5633    for c in text.chars() {
5634        if c.is_alphanumeric() || c == '_' {
5635            if pending && !out.is_empty() {
5636                out.push('-');
5637            }
5638            pending = false;
5639            out.extend(c.to_lowercase());
5640        } else {
5641            pending = true;
5642        }
5643    }
5644    out
5645}
5646
5647fn is_block_container(kind: &Kind) -> bool {
5648    matches!(
5649        kind,
5650        Kind::Doc
5651            | Kind::Section
5652            | Kind::BlockQuote
5653            | Kind::BulletList
5654            | Kind::OrderedList
5655            | Kind::TaskList
5656            | Kind::ListItem
5657            | Kind::TaskListItem
5658            // Every `container` — a directive in any of its three forms, or a
5659            // promoted HTML element. A *text* directive is really inline, so
5660            // claiming it here is a small overreach, and the deliberate one this
5661            // function's kind-only peer `is_inline_kind` documents: the pair is
5662            // consulted together, and answering "block container" for something
5663            // inline is what keeps an ancestor walk from stopping short of the
5664            // paragraph that actually holds it.
5665            | Kind::Container
5666    )
5667}
5668
5669/// The `[start, end)` byte range of the source line containing `off` (newline
5670/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
5671/// line between paragraphs).
5672fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
5673    let off = off.min(s.len());
5674    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
5675    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
5676    start..end
5677}
5678
5679/// How many leading bytes an outdent takes off `line`: a whole indent level
5680/// where the line has one, and whatever it has where it has less.
5681///
5682/// A leading tab counts as a level on its own. It's indentation some other
5683/// editor wrote, and one tab is one level everywhere it came from — measuring it
5684/// in spaces it doesn't contain would leave it untouchable.
5685fn outdent_width(line: &str, unit: usize) -> usize {
5686    if line.starts_with('\t') {
5687        return 1;
5688    }
5689    line.bytes().take(unit).take_while(|b| *b == b' ').count()
5690}
5691
5692/// A list marker found at the head of a line, together with everything before it
5693/// that a sibling line has to repeat.
5694///
5695/// The three offsets differ only inside a block quote, where `>   - b` opens with
5696/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
5697/// `line_start == marker_start`, and `text` is the plain `"  - "`.
5698#[derive(Clone, Debug)]
5699struct ListMarker {
5700    /// The line's first byte.
5701    line_start: usize,
5702    /// Where the marker proper begins, past any quote prefix. The offset to hand
5703    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
5704    marker_start: usize,
5705    /// `line_start` through the marker's trailing space — quote prefix, indent
5706    /// and bullet together, which is what the next item's line opens with.
5707    text: String,
5708}
5709
5710impl ListMarker {
5711    /// Where the item's content starts — one past the marker's trailing space.
5712    fn content_start(&self) -> usize {
5713        self.line_start + self.text.len()
5714    }
5715}
5716
5717fn classify(c: char) -> Class {
5718    if c == '_' || c.is_alphanumeric() {
5719        Class::Word
5720    } else if c.is_whitespace() {
5721        Class::Space
5722    } else {
5723        Class::Other
5724    }
5725}
5726
5727/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
5728/// skip any leading separators, then consume the following word run.
5729fn next_word(s: &str, i: usize) -> usize {
5730    let mut off = i;
5731    let mut in_word = false;
5732    for c in s[i..].chars() {
5733        if classify(c) == Class::Word {
5734            in_word = true;
5735        } else if in_word {
5736            break;
5737        }
5738        off += c.len_utf8();
5739    }
5740    off
5741}
5742
5743/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
5744/// skip separators walking left, then consume the preceding word run.
5745fn prev_word(s: &str, i: usize) -> usize {
5746    let mut off = i;
5747    let mut in_word = false;
5748    for c in s[..i].chars().rev() {
5749        if classify(c) == Class::Word {
5750            in_word = true;
5751        } else if in_word {
5752            break;
5753        }
5754        off -= c.len_utf8();
5755    }
5756    off
5757}
5758
5759/// The `[start, end)` run of same-class characters surrounding `off` — the
5760/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
5761/// the run ending there is used.
5762fn word_range_at(s: &str, off: usize) -> (usize, usize) {
5763    if s.is_empty() {
5764        return (0, 0);
5765    }
5766    let off = off.min(s.len());
5767    let reference = if off < s.len() {
5768        s[off..].chars().next()
5769    } else {
5770        s[..off].chars().next_back()
5771    };
5772    let Some(rc) = reference else {
5773        return (off, off);
5774    };
5775    let class = classify(rc);
5776
5777    let mut start = off;
5778    for c in s[..start].chars().rev() {
5779        if classify(c) == class {
5780            start -= c.len_utf8();
5781        } else {
5782            break;
5783        }
5784    }
5785    let mut end = off;
5786    for c in s[end..].chars() {
5787        if classify(c) == class {
5788            end += c.len_utf8();
5789        } else {
5790            break;
5791        }
5792    }
5793    (start, end)
5794}
5795
5796/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
5797/// the line's start — terminal cells, not characters, so the column names the
5798/// cell the caret is drawn in even on a line of `你好` or emoji.
5799fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
5800    let off = off.min(s.len());
5801    let mut row = 0;
5802    let mut line_start = 0;
5803    for (i, &b) in s.as_bytes().iter().enumerate() {
5804        if i >= off {
5805            break;
5806        }
5807        if b == b'\n' {
5808            row += 1;
5809            line_start = i + 1;
5810        }
5811    }
5812    (row, wysiwyg::text_width(&s[line_start..off]))
5813}
5814
5815/// The byte offset at display column `col` of `row` (clamped to that line's
5816/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
5817///
5818/// A column landing *inside* a character — the second cell of `你`, or any cell
5819/// but the first of an emoji — resolves to that character's start, which is the
5820/// column the caret would have been drawn at to begin with. So both cells of a
5821/// wide character mean the character, and every offset survives the round trip
5822/// out to a column and back. The walk steps by grapheme cluster for the same
5823/// reason the caret does: a cluster is the character, and the cells belong to it
5824/// rather than to the codepoints spelling it.
5825fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
5826    let start = line_start(s, row);
5827    let end = line_end_from(s, start);
5828    let mut off = start;
5829    let mut at = 0; // the display column `off` sits at
5830    while off < end {
5831        let next = next_boundary(s, off).min(end);
5832        let cells = wysiwyg::text_width(&s[off..next]);
5833        if at + cells > col {
5834            break; // `col` is one of this cluster's own cells
5835        }
5836        at += cells;
5837        off = next;
5838    }
5839    off
5840}
5841
5842fn line_start(s: &str, row: usize) -> usize {
5843    if row == 0 {
5844        return 0;
5845    }
5846    let mut r = 0;
5847    for (i, &b) in s.as_bytes().iter().enumerate() {
5848        if b == b'\n' {
5849            r += 1;
5850            if r == row {
5851                return i + 1;
5852            }
5853        }
5854    }
5855    s.len()
5856}
5857
5858fn line_end_from(s: &str, start: usize) -> usize {
5859    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
5860}
5861
5862/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
5863/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
5864/// twig applies writing the mark out, so the toolbar can light the same button
5865/// that made the node.
5866///
5867/// `None` for every other kind, including the inline nodes that aren't marks at
5868/// all (`str`, `link`, `image`, the math and break kinds): they're things a
5869/// caret stands in, not formatting a button toggles.
5870fn inline_kind(kind: &Kind) -> Option<InlineKind> {
5871    Some(match kind {
5872        Kind::Strong => InlineKind::Strong,
5873        Kind::Emph => InlineKind::Emph,
5874        Kind::Verbatim => InlineKind::Verbatim,
5875        Kind::Mark => InlineKind::Mark,
5876        Kind::Superscript => InlineKind::Superscript,
5877        Kind::Subscript => InlineKind::Subscript,
5878        Kind::Insert => InlineKind::Insert,
5879        Kind::Delete => InlineKind::Delete,
5880        _ => return None,
5881    })
5882}
5883
5884/// A watermark for a file's contents (see `Doc::disk_hash`).
5885///
5886/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
5887/// watermark is compared only against one taken by the same process moments
5888/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
5889/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
5890fn hash_bytes(bytes: &[u8]) -> u64 {
5891    use std::hash::{Hash, Hasher};
5892    let mut h = std::collections::hash_map::DefaultHasher::new();
5893    bytes.hash(&mut h);
5894    h.finish()
5895}
5896
5897#[cfg(feature = "fs")]
5898fn detect_format(path: &Path) -> Result<Format> {
5899    let ext = path
5900        .extension()
5901        .and_then(|e| e.to_str())
5902        .unwrap_or("")
5903        .to_ascii_lowercase();
5904    Ok(match ext.as_str() {
5905        "dj" | "djot" => Format::Djot,
5906        "md" | "markdown" => Format::Markdown,
5907        "xml" => Format::Xml,
5908        "html" | "htm" => Format::Html,
5909        other => return Err(anyhow!("unknown document extension: .{other}")),
5910    })
5911}
5912
5913#[cfg(test)]
5914mod tests {
5915    use super::*;
5916
5917    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
5918    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
5919    /// without one is a view no user is ever in.
5920    fn doc_in(view: View, name: &str, body: &str) -> Doc {
5921        // The fixture name doubles as the temp file's, so two tests picking the
5922        // same one raced under the parallel runner and read each other's body —
5923        // a green suite proving the wrong thing. The counter makes that
5924        // unreachable rather than asking every future caller to notice.
5925        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5926        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5927        let mut p = std::env::temp_dir();
5928        p.push(format!("leaf_test_{name}_{seq}.md"));
5929        std::fs::write(&p, body).unwrap();
5930        let mut d = Doc::open(p).unwrap();
5931        d.view = view;
5932        if view == View::Wysiwyg {
5933            d.build_visual(80);
5934        }
5935        d
5936    }
5937
5938    // Source-view document for the source-behaviour tests. `Doc::open` now
5939    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
5940    // `wysiwyg_doc` builds the rich-text variant on top of this.
5941    fn doc_with(name: &str, body: &str) -> Doc {
5942        doc_in(View::Source, name, body)
5943    }
5944
5945    /// Every visual row's drawn text — what the reader actually sees, which is
5946    /// the only thing the reveal preference is supposed to change.
5947    fn drawn_rows(d: &Doc) -> Vec<String> {
5948        d.vmap
5949            .rows
5950            .iter()
5951            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5952            .collect()
5953    }
5954
5955    /// Put the caret at the first byte of `needle` and rebuild, so the row under
5956    /// it becomes the revealed line.
5957    fn caret_at(d: &mut Doc, needle: &str) {
5958        d.caret = d.source.find(needle).expect("needle in source");
5959        d.build_visual(80);
5960    }
5961
5962    #[test]
5963    fn blockquote_after_a_list_is_not_bulleted() {
5964        // twig nests a following top-level block quote under the `bullet_list`
5965        // (a direct child, not a `list_item`). The map must render it de-nested —
5966        // `│ quote`, never `• │ quote` — with a blank separator, like any block
5967        // that follows a list. Regression for the "combined list + blockquote" bug.
5968        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
5969        d.build_visual(80);
5970        let rows: Vec<String> = d
5971            .vmap
5972            .rows
5973            .iter()
5974            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5975            .collect();
5976        assert!(
5977            rows.iter().any(|r| r == "│ quote"),
5978            "block quote should render on its own gutter, got rows: {rows:?}"
5979        );
5980        assert!(
5981            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
5982            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
5983        );
5984    }
5985
5986    // ── the map is built at most once per (revision, wrap) ───────────────────
5987    //
5988    // A frontend repaints for reasons that have nothing to do with the text — a
5989    // blinking caret, a scroll — and rebuilding the map is O(document). These
5990    // pin *that the cache fires*, which a passing suite can't tell you: a cache
5991    // that never hits is invisible to every other test in this file.
5992    //
5993    // The probe is to wreck the built map and ask for it again. A rebuild
5994    // repairs it; a cache hit hands the wreckage straight back. Nothing else
5995    // can distinguish the two from outside.
5996
5997    #[test]
5998    fn a_rebuild_with_nothing_changed_reuses_the_map() {
5999        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6000        d.build_visual(80);
6001        assert!(!d.vmap.rows.is_empty());
6002        d.vmap.rows.clear(); // wreck it
6003        d.build_visual(80);
6004        assert!(
6005            d.vmap.rows.is_empty(),
6006            "the map was rebuilt though nothing changed — the cache never fired"
6007        );
6008    }
6009
6010    #[test]
6011    fn an_edit_rebuilds_the_map() {
6012        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6013        d.build_visual(80);
6014        let before = d.revision();
6015        d.vmap.rows.clear();
6016        d.insert("x");
6017        d.build_visual(80);
6018        assert!(d.revision() > before, "an edit must move the revision");
6019        assert!(
6020            !d.vmap.rows.is_empty(),
6021            "an edited document must not paint from a stale map"
6022        );
6023    }
6024
6025    #[test]
6026    fn a_width_change_rebuilds_the_map() {
6027        // The map is a function of the wrap width too, so a resize is a miss
6028        // even though the text is untouched.
6029        let mut d = doc_in(
6030            View::Wysiwyg,
6031            "cache_width",
6032            "one two three four five six\n",
6033        );
6034        d.build_visual(80);
6035        d.vmap.rows.clear();
6036        d.build_visual(12);
6037        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6038        // And the unwrapped map is its own key, not the same as any width.
6039        d.vmap.rows.clear();
6040        d.build_visual_unwrapped();
6041        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6042    }
6043
6044    #[test]
6045    fn a_motion_does_not_rebuild_the_map() {
6046        // The whole point: moving the caret changes nothing the map is built
6047        // from. If a motion bumped the revision, every arrow key would cost a
6048        // full rebuild and the cache would be worthless.
6049        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6050        d.build_visual(80);
6051        let rev = d.revision();
6052        d.move_right(false);
6053        d.move_right(true);
6054        d.move_down(false);
6055        assert_eq!(d.revision(), rev, "a motion must not move the revision");
6056        d.vmap.rows.clear();
6057        d.build_visual(80);
6058        assert!(
6059            d.vmap.rows.is_empty(),
6060            "a motion should not rebuild the map"
6061        );
6062    }
6063
6064    #[test]
6065    fn saving_does_not_rebuild_the_map() {
6066        // Saving changes `dirty`, not the text.
6067        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6068        d.insert("x");
6069        d.build_visual(80);
6070        let rev = d.revision();
6071        d.save();
6072        assert_eq!(d.revision(), rev, "a save must not move the revision");
6073        assert!(!d.dirty, "the save should have cleaned the document");
6074    }
6075
6076    #[test]
6077    fn a_reload_rebuilds_the_map() {
6078        // Reload replaces the text without going through `refresh`, so it has to
6079        // move the revision itself — else the editor paints the old file.
6080        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6081        d.build_visual(80);
6082        let rev = d.revision();
6083        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6084        d.reload();
6085        assert!(d.revision() > rev, "a reload must move the revision");
6086        d.build_visual(80);
6087        let text: String = d
6088            .vmap
6089            .rows
6090            .iter()
6091            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6092            .collect();
6093        assert!(
6094            text.contains("wholly new"),
6095            "the reloaded text should be on screen, got {text:?}"
6096        );
6097    }
6098
6099    // ── golden-case harness ──────────────────────────────────────────────────
6100    // The pattern the whole parity suite can reuse: write a fixture with the
6101    // caret marked by `|`, run one action, and compare the rendered result —
6102    // also caret-marked — against the expected string. One readable line per
6103    // behavior, and it exercises the exact `Doc` ops both frontends call.
6104
6105    /// Split a `|`-marked fixture into `(source, caret_offset)`.
6106    fn parse_caret(marked: &str) -> (String, usize) {
6107        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6108        (marked.replacen('|', "", 1), caret)
6109    }
6110
6111    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6112    /// selection) so a result reads like the fixtures.
6113    fn render_caret(d: &Doc) -> String {
6114        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6115        // so the caret always renders inside its own selection.
6116        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6117        if let Some((s, e)) = d.selection() {
6118            marks.push((s, 0, '['));
6119            marks.push((e, 2, ']'));
6120        }
6121        // Insert right-to-left: descending offset, then descending rank.
6122        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6123        let mut out = d.source.clone();
6124        for (at, _, ch) in marks {
6125            out.insert(at, ch);
6126        }
6127        out
6128    }
6129
6130    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6131    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6132        golden_in(View::Source, name, marked, action)
6133    }
6134
6135    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6136    /// the same fixture has to read the same way in both.
6137    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6138        let (src, caret) = parse_caret(marked);
6139        let mut d = doc_in(view, name, &src);
6140        d.caret = caret;
6141        action(&mut d);
6142        render_caret(&d)
6143    }
6144
6145    #[test]
6146    fn word_motion_walks_word_by_word() {
6147        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6148        assert_eq!(
6149            g("hello wor|ld", |d| d.move_word_left(false)),
6150            "hello |world"
6151        );
6152        assert_eq!(
6153            g("hello| world", |d| d.move_word_left(false)),
6154            "|hello world"
6155        );
6156        assert_eq!(
6157            g("hel|lo world", |d| d.move_word_right(false)),
6158            "hello| world"
6159        );
6160        assert_eq!(
6161            g("hello| world", |d| d.move_word_right(false)),
6162            "hello world|"
6163        );
6164        // Punctuation is its own class, so motion stops at the boundary.
6165        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6166    }
6167
6168    #[test]
6169    fn word_motion_extends_the_selection_when_asked() {
6170        assert_eq!(
6171            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6172            "hello [world|]"
6173        );
6174    }
6175
6176    #[test]
6177    fn delete_word_removes_a_whole_word() {
6178        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6179        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6180        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6181        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6182    }
6183
6184    // ── Home / End ───────────────────────────────────────────────────────────
6185
6186    #[test]
6187    fn home_toggles_between_the_line_s_text_and_its_margin() {
6188        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6189        // indent to the markup it spells everywhere it means one, so the fixture
6190        // with whitespace left to walk is a code block, which is verbatim.
6191        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6192        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
6193        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
6194        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
6195        // A line with no indentation has one place to go, so the toggle is a
6196        // no-op rather than a trip to nowhere.
6197        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6198        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6199
6200        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
6201        let indent = d.source.find("    indented").unwrap();
6202        d.caret = indent + 6; // inside "indented"
6203        d.move_home(false);
6204        assert_eq!(
6205            d.caret,
6206            indent + 4,
6207            "wysiwyg: Home aims at the code line's text"
6208        );
6209        d.move_home(false);
6210        assert_eq!(
6211            d.caret, indent,
6212            "wysiwyg: the second press takes the indent"
6213        );
6214        d.move_home(false);
6215        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6216    }
6217
6218    #[test]
6219    fn end_takes_the_line_the_view_is_showing() {
6220        // The line differs by view for the same document, and that is the point:
6221        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6222        // as a space on one row and the source view as two lines.
6223        let mut d = doc_with("end_src", "one two\nthree\n");
6224        d.caret = 1;
6225        d.move_end(false);
6226        assert_eq!(d.caret, 7, "source: the end of the source line");
6227
6228        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6229        d.caret = 1;
6230        d.move_end(false);
6231        assert_eq!(
6232            d.caret, 13,
6233            "wysiwyg: the end of the row, soft break and all"
6234        );
6235    }
6236
6237    #[test]
6238    fn home_and_end_extend_the_selection_when_asked() {
6239        for (view, tag) in VIEWS {
6240            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6241            d.caret = 6;
6242            d.move_end(true);
6243            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6244            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6245            d.caret = 6;
6246            d.move_home(true);
6247            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6248        }
6249    }
6250
6251    // ── kill to the line's start / end ───────────────────────────────────────
6252
6253    #[test]
6254    fn kill_to_the_line_start_and_end_in_both_views() {
6255        for (view, tag) in VIEWS {
6256            // The gap that reads as a paragraph break in each view: the source
6257            // view's lines are the renderer's rows only where the source says so.
6258            let gap = if view == View::Source { "\n" } else { "\n\n" };
6259            let mut d = doc_in(
6260                view,
6261                &format!("kill_end_{tag}"),
6262                &format!("one two{gap}three\n"),
6263            );
6264            d.caret = 3;
6265            d.delete_to_line_end();
6266            assert_eq!(
6267                d.source,
6268                format!("one{gap}three\n"),
6269                "{tag}: ^K to the line's end"
6270            );
6271            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6272
6273            let mut d = doc_in(
6274                view,
6275                &format!("kill_start_{tag}"),
6276                &format!("one two{gap}three\n"),
6277            );
6278            d.caret = 7; // the end of the first line
6279            d.delete_to_line_start();
6280            assert_eq!(
6281                d.source,
6282                format!("{gap}three\n"),
6283                "{tag}: ⌘⌫ to the line's start"
6284            );
6285            assert_eq!(d.caret, 0, "{tag}");
6286        }
6287    }
6288
6289    #[test]
6290    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6291        // The decision: at the boundary both kills do nothing, rather than
6292        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6293        // at a soft wrap as often as at a newline, where there is nothing
6294        // written to delete — and a source newline is only half of the blank
6295        // line between two paragraphs, so taking it leaves a soft break rather
6296        // than the join it looks like. Backspace and Delete are the keys for it.
6297        for (view, tag) in VIEWS {
6298            let gap = if view == View::Source { "\n" } else { "\n\n" };
6299            let src = format!("one{gap}three\n");
6300            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6301            d.caret = 3; // the end of "one"
6302            d.delete_to_line_end();
6303            assert_eq!(
6304                d.source, src,
6305                "{tag}: ^K at the line's end joined it to the next"
6306            );
6307
6308            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6309            d.caret = 3 + gap.len(); // the start of "three"
6310            d.delete_to_line_start();
6311            assert_eq!(
6312                d.source, src,
6313                "{tag}: ⌘⌫ at the line's start joined it to the last"
6314            );
6315        }
6316    }
6317
6318    #[test]
6319    fn a_kill_takes_the_selection_when_there_is_one() {
6320        // What every other delete here does with one, so these two as well.
6321        for (view, tag) in VIEWS {
6322            for (name, kill) in [
6323                (
6324                    "end",
6325                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6326                ),
6327                ("start", |d: &mut Doc| d.delete_to_line_start()),
6328            ] {
6329                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
6330                d.anchor = Some(4);
6331                d.caret = 7; // "two"
6332                kill(&mut d);
6333                assert_eq!(
6334                    d.source, "one  three\n",
6335                    "{tag}: {name} ignored the selection"
6336                );
6337                assert_eq!(d.selection(), None, "{tag}: {name}");
6338            }
6339        }
6340    }
6341
6342    #[test]
6343    fn a_kill_takes_the_markup_it_empties_with_it() {
6344        // The same hazard a word-delete has: a WYSIWYG range covers what the
6345        // user can see, which for `**bold**` is the word and never the
6346        // delimiters, so a kill that stopped at the text would leave `a ****` —
6347        // markup wrapped around nothing.
6348        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
6349        d.caret = d.source.find("bold").unwrap();
6350        d.delete_to_line_end();
6351        assert_eq!(d.source, "a \n");
6352    }
6353
6354    #[test]
6355    fn a_kill_is_undone_in_one_step() {
6356        for (view, tag) in VIEWS {
6357            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6358            d.caret = 3;
6359            d.delete_to_line_end();
6360            assert_eq!(d.source, "one\n", "{tag}");
6361            d.undo();
6362            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6363        }
6364    }
6365
6366    #[test]
6367    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6368        // Regression: triple-click used move_home/move_end over visual rows, so
6369        // it only worked on a paragraph's first row (a wrap-boundary offset maps
6370        // to the earlier row). select_block_at reads the AST, so every offset in
6371        // the paragraph selects the whole thing.
6372        let body = "one two three four five six seven eight\n";
6373        let mut d = doc_with("sel_block", body);
6374        d.view = View::Wysiwyg;
6375        d.build_visual(12); // force the paragraph to wrap into several rows
6376        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6377        let para = (0, "one two three four five six seven eight".len());
6378        for off in [0usize, 8, 19, 28, 38] {
6379            d.caret = 0;
6380            d.anchor = None;
6381            d.select_block_at(off);
6382            assert_eq!(
6383                d.selection(),
6384                Some(para),
6385                "offset {off} should select the paragraph"
6386            );
6387        }
6388    }
6389
6390    #[test]
6391    fn select_block_uses_content_span_for_a_heading() {
6392        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6393        d.select_block_at(4); // inside "Title"
6394        // content_span excludes the "# " marker.
6395        assert_eq!(d.selected_text(), Some("Title"));
6396        d.select_block_at(10); // inside "body"
6397        assert_eq!(d.selected_text(), Some("body"));
6398    }
6399
6400    #[test]
6401    fn select_all_spans_the_document() {
6402        let mut d = doc_with("sel_all", "abc\n\ndef\n");
6403        d.select_all();
6404        assert_eq!(d.selection(), Some((0, d.source.len())));
6405    }
6406
6407    #[test]
6408    fn select_word_at_picks_the_surrounding_word() {
6409        let mut d = doc_with("sel_word", "hello world\n");
6410        d.select_word_at(8); // inside "world"
6411        assert_eq!(d.selection(), Some((6, 11)));
6412        // Double-clicking at end-of-word still grabs the word to its left.
6413        d.select_word_at(5); // the space between the words
6414        assert_eq!(d.selection(), Some((5, 6)));
6415    }
6416
6417    #[test]
6418    fn word_helpers_respect_utf8_boundaries() {
6419        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6420        assert_eq!(
6421            golden("utf8", "|café ok", |d| d.move_word_right(false)),
6422            "café| ok"
6423        );
6424        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6425    }
6426
6427    #[test]
6428    fn typing_inserts_at_the_caret_and_advances_it() {
6429        let mut d = doc_with("type", "hello\n");
6430        d.insert("Hi ");
6431        assert_eq!(d.source, "Hi hello\n");
6432        assert_eq!(d.caret, 3);
6433        assert!(d.dirty);
6434    }
6435
6436    #[test]
6437    fn backspace_deletes_the_char_before_the_caret() {
6438        let mut d = doc_with("bs", "hello\n");
6439        d.caret = 3; // after "hel"
6440        d.backspace();
6441        assert_eq!(d.source, "helo\n");
6442        assert_eq!(d.caret, 2);
6443    }
6444
6445    #[test]
6446    fn typing_replaces_the_selection() {
6447        let mut d = doc_with("replace", "a word b\n");
6448        d.anchor = Some(2);
6449        d.caret = 6; // "word" selected
6450        d.insert("X");
6451        assert_eq!(d.source, "a X b\n");
6452        assert_eq!(d.caret, 3);
6453        assert_eq!(d.anchor, None);
6454    }
6455
6456    #[test]
6457    fn toggle_bold_wraps_then_unwraps_the_selection() {
6458        let mut d = doc_with("bold", "a word b\n");
6459        d.anchor = Some(2);
6460        d.caret = 6;
6461        d.toggle(InlineKind::Strong);
6462        assert_eq!(d.source, "a **word** b\n");
6463        // The toggled region stays selected, so a second toggle reverses it.
6464        d.toggle(InlineKind::Strong);
6465        assert_eq!(d.source, "a word b\n");
6466    }
6467
6468    #[test]
6469    fn toggle_code_wraps_then_unwraps_the_selection() {
6470        let mut d = doc_with("code_rt", "a word b\n");
6471        d.anchor = Some(2);
6472        d.caret = 6;
6473        d.toggle(InlineKind::Verbatim);
6474        assert_eq!(d.source, "a `word` b\n");
6475        d.toggle(InlineKind::Verbatim);
6476        assert_eq!(d.source, "a word b\n");
6477    }
6478
6479    #[test]
6480    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6481        // ⌘b at a bare caret, then type: the text comes out bold with no
6482        // selection ever made — the word-processor "start bold here" gesture.
6483        let mut d = doc_with("sticky_wrap", "xy\n");
6484        d.caret = 1; // between x and y
6485        d.toggle(InlineKind::Strong);
6486        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6487        d.insert("A");
6488        assert_eq!(d.source, "x**A**y\n");
6489    }
6490
6491    #[test]
6492    fn sticky_bold_lights_the_toolbar_before_any_typing() {
6493        // The button must light the instant ⌘b is pressed, or the mode is
6494        // invisible until the first character lands.
6495        let mut d = doc_with("sticky_light", "xy\n");
6496        d.caret = 1;
6497        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6498        d.toggle(InlineKind::Strong);
6499        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6500    }
6501
6502    #[test]
6503    fn sticky_bold_toggled_off_types_normally_again() {
6504        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6505        // in the flow of typing, the exact sequence the user described.
6506        let mut d = doc_with("sticky_off", "\n");
6507        d.caret = 0;
6508        d.toggle(InlineKind::Strong);
6509        d.insert("a");
6510        d.insert("b"); // continues inside the run, no re-arming
6511        assert_eq!(d.source, "**ab**\n");
6512        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6513        d.insert("c");
6514        assert_eq!(d.source, "**ab**c\n");
6515    }
6516
6517    #[test]
6518    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6519        // Once a mark is realised the caret sits inside the run, so plain typing
6520        // extends it rather than starting a second, adjacent bold span.
6521        let mut d = doc_with("sticky_cont", "\n");
6522        d.caret = 0;
6523        d.toggle(InlineKind::Emph);
6524        d.insert("h");
6525        d.insert("i");
6526        assert_eq!(d.source, "*hi*\n");
6527    }
6528
6529    #[test]
6530    fn moving_the_caret_disarms_a_sticky_mark() {
6531        // Arming a mark and then moving away must not style text elsewhere.
6532        let mut d = doc_with("sticky_disarm", "xy\n");
6533        d.caret = 0;
6534        d.toggle(InlineKind::Strong);
6535        d.move_right(false); // caret 0 → 1, disarms
6536        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6537        d.insert("A");
6538        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6539    }
6540
6541    #[test]
6542    fn stacked_sticky_marks_apply_together() {
6543        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6544        let mut d = doc_with("sticky_stack", "\n");
6545        d.caret = 0;
6546        d.toggle(InlineKind::Strong);
6547        d.toggle(InlineKind::Emph);
6548        d.insert("x");
6549        // Land the caret on the styled character and confirm both marks are live.
6550        d.anchor = Some(d.source.find('x').unwrap());
6551        d.caret = d.anchor.unwrap() + 1;
6552        let marks = d.active_inline_marks();
6553        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6554        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6555    }
6556
6557    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6558
6559    #[test]
6560    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6561        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6562        // The space inside the run made `**bold **`, which is *not* bold — four
6563        // literal asterisks — so the rich view drew them, correctly and
6564        // uselessly, until the next character happened to close the run again.
6565        let mut d = wysiwyg_doc("edge_typing", "a \n");
6566        d.caret = 2;
6567        d.toggle(InlineKind::Strong);
6568        for c in "bold".chars() {
6569            d.insert(&c.to_string());
6570        }
6571        assert_eq!(d.source, "a **bold**\n");
6572        d.insert(" ");
6573        assert_eq!(
6574            d.source, "a **bold** \n",
6575            "the space belongs outside the run"
6576        );
6577        assert!(
6578            d.active_inline_marks().contains(InlineKind::Strong),
6579            "bold is still what's being typed, so the button stays lit"
6580        );
6581        // What the writer is looking at while all this happens: their words.
6582        d.build_visual(80);
6583        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6584        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6585        for c in "hey".chars() {
6586            d.insert(&c.to_string());
6587        }
6588        assert_eq!(
6589            d.source, "a **bold hey**\n",
6590            "one bold phrase, not two runs"
6591        );
6592    }
6593
6594    #[test]
6595    fn typing_past_a_space_can_still_leave_the_bold_behind() {
6596        // The other half: the marks stay armed across the space, so ⌘b turns
6597        // them off again there and the next word is plain — the run isn't
6598        // rejoined by a caret that was told not to.
6599        let mut d = wysiwyg_doc("edge_shed", "\n");
6600        d.caret = 0;
6601        d.toggle(InlineKind::Strong);
6602        for c in "bold ".chars() {
6603            d.insert(&c.to_string());
6604        }
6605        assert_eq!(d.source, "**bold** \n");
6606        d.toggle(InlineKind::Strong);
6607        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6608        d.insert("x");
6609        assert_eq!(d.source, "**bold** x\n");
6610    }
6611
6612    #[test]
6613    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
6614        // ⌘b and then a space before any word: the space is not marked (nothing
6615        // is), and the word after it is.
6616        let mut d = wysiwyg_doc("edge_space_first", "a\n");
6617        d.caret = 1;
6618        d.toggle(InlineKind::Strong);
6619        d.insert(" ");
6620        assert_eq!(d.source, "a \n");
6621        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6622        d.insert("b");
6623        assert_eq!(d.source, "a **b**\n");
6624    }
6625
6626    #[test]
6627    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
6628        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
6629        d.caret = 8; // the caret's home at the end of the run's text
6630        d.insert(" ");
6631        assert_eq!(
6632            d.source, "x **bold** \n",
6633            "the space lands past the delimiters"
6634        );
6635        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
6636
6637        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
6638        d.caret = 4; // in front of the "b"
6639        d.insert(" ");
6640        assert_eq!(d.source, "x  **bold** y\n");
6641        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
6642    }
6643
6644    #[test]
6645    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
6646        // Backspace over the last letter of a bold phrase.
6647        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
6648        d.caret = 10; // past the "h"
6649        d.backspace();
6650        assert_eq!(d.source, "a **bold** \n");
6651        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
6652        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6653        d.insert("x");
6654        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
6655    }
6656
6657    #[test]
6658    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
6659        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
6660        // mark, which is only text. The marks live on in the caret instead.
6661        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
6662        d.caret = 5;
6663        d.backspace();
6664        assert_eq!(d.source, "a  c\n");
6665        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6666        d.insert("x");
6667        assert_eq!(d.source, "a **x** c\n");
6668    }
6669
6670    #[test]
6671    fn typing_over_a_whole_bold_word_keeps_it_bold() {
6672        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
6673        d.anchor = Some(4);
6674        d.caret = 8; // the word, not its delimiters
6675        d.insert("x");
6676        assert_eq!(d.source, "a **x** c\n");
6677    }
6678
6679    #[test]
6680    fn a_code_span_keeps_the_space_it_is_given() {
6681        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
6682        // is still verbatim, so nothing is re-spelt. The repair asks the parser
6683        // rather than a table of kinds, and this is the answer it gets.
6684        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
6685        d.caret = 7;
6686        d.insert(" ");
6687        assert_eq!(d.source, "a `code ` c\n");
6688    }
6689
6690    #[test]
6691    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
6692        // A run's closing delimiter has a caret home on each side of it, one
6693        // column apart on screen — and a plain ← off the space after a bold word
6694        // lands on the outer one. The character drawn behind the caret there is
6695        // still the last letter of the phrase, so that is what Backspace takes;
6696        // the byte behind it is a `*` nobody can see.
6697        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
6698        d.caret = 9;
6699        d.move_left(false);
6700        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
6701        d.backspace();
6702        assert_eq!(
6703            d.source, "**bol** x\n",
6704            "a letter of the phrase, not its `*`"
6705        );
6706        assert_eq!(d.caret, 5);
6707
6708        // And the mirror in front of the opening delimiter, where Delete's
6709        // character is the first letter of the run.
6710        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
6711        d.caret = 1;
6712        d.delete_forward();
6713        assert_eq!(d.source, "x**old**\n");
6714        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
6715    }
6716
6717    #[test]
6718    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
6719        // The byte beside the caret at either edge of a bold word is a `*` the
6720        // rich view draws nothing for. Taking it is not the character delete the
6721        // key was pressed for — it unspells the run and puts a literal asterisk
6722        // on screen (`a *bold** c`). The visible character is the one that goes.
6723        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
6724        d.caret = 4; // in front of the "b"
6725        d.backspace();
6726        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
6727
6728        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
6729        d.caret = 8; // past the "d"
6730        d.delete_forward();
6731        assert_eq!(d.source, "a **bold**c\n");
6732        assert_eq!(d.caret, 8, "and the caret stays inside the run");
6733        d.insert("x");
6734        assert_eq!(d.source, "a **boldx**c\n");
6735
6736        // A code span's backticks are hidden the same way, so they are covered
6737        // by the same rule and not by a list of kinds.
6738        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
6739        d.caret = 3;
6740        d.backspace();
6741        assert_eq!(d.source, "a`code` c\n");
6742    }
6743
6744    #[test]
6745    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
6746        // The asterisks are on the screen there and the caret can stand between
6747        // them, so a delete takes exactly the byte it is aimed at.
6748        let mut d = doc_with("edge_open_src", "a **bold** c\n");
6749        d.caret = 4;
6750        d.backspace();
6751        assert_eq!(d.source, "a *bold** c\n");
6752
6753        let mut d = doc_with("edge_close_src", "a **bold** c\n");
6754        d.caret = 8;
6755        d.delete_forward();
6756        assert_eq!(d.source, "a **bold* c\n");
6757    }
6758
6759    #[test]
6760    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
6761        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
6762        // The space had stepped outside the run (the mark-edge rule), taking the
6763        // caret with it, so the delete put it back down on the far side of the
6764        // closing `**` — one place on screen, and the wrong side of it. Typing
6765        // came out plain and the toolbar went dark, with nothing to see.
6766        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
6767        d.caret = 0;
6768        d.toggle(InlineKind::Strong);
6769        for c in "bold".chars() {
6770            d.insert(&c.to_string());
6771        }
6772        d.insert(" ");
6773        assert_eq!(d.source, "**bold** \n");
6774        d.backspace();
6775        assert_eq!(
6776            d.source, "**bold**\n",
6777            "the space goes, the delimiters stay"
6778        );
6779        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
6780        assert!(
6781            d.active_inline_marks().contains(InlineKind::Strong),
6782            "so the button is still lit"
6783        );
6784        d.insert("x");
6785        assert_eq!(
6786            d.source, "**boldx**\n",
6787            "and the next character is still bold"
6788        );
6789    }
6790
6791    #[test]
6792    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
6793        // What the stranded caret did next: the byte behind it was the closing
6794        // `*`, so a second press took that instead of a letter — `**bold*`, the
6795        // styling gone and an asterisk on the screen where the word had been.
6796        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
6797        d.caret = 0;
6798        d.toggle(InlineKind::Strong);
6799        for c in "bold ".chars() {
6800            d.insert(&c.to_string());
6801        }
6802        assert_eq!(d.source, "**bold** \n");
6803        d.backspace();
6804        d.backspace();
6805        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
6806        assert_eq!(d.caret, 5);
6807    }
6808
6809    #[test]
6810    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
6811        // `***both***` closes two runs with one stack of asterisks: the caret has
6812        // to walk in through all of them, or it lands between the emph and the
6813        // strong and types half-marked.
6814        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
6815        d.caret = 11;
6816        d.backspace();
6817        assert_eq!(d.source, "***both***\n");
6818        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
6819        d.insert("x");
6820        assert_eq!(d.source, "***bothx***\n");
6821    }
6822
6823    #[test]
6824    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
6825        // The settle only moves a caret a run actually closed over. Ordinary
6826        // deletes — inside a run, or in plain prose — are untouched.
6827        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
6828        d.caret = 8;
6829        d.backspace();
6830        assert_eq!(d.source, "a **bol** c\n");
6831        assert_eq!(d.caret, 7);
6832
6833        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
6834        d.caret = 5;
6835        d.backspace();
6836        assert_eq!(d.source, "plai\n");
6837        assert_eq!(d.caret, 4);
6838    }
6839
6840    #[test]
6841    fn the_source_view_leaves_a_delete_where_it_landed() {
6842        // The delimiters are on the screen there, so the offset past them is a
6843        // place the caret can be seen to be — nothing to settle.
6844        let mut d = doc_with("edge_bksp_src", "**bold** \n");
6845        d.caret = 9;
6846        d.backspace();
6847        assert_eq!(d.source, "**bold**\n");
6848        assert_eq!(d.caret, 8);
6849    }
6850
6851    #[test]
6852    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
6853        // `***both***` closes two runs with one stack of asterisks; a space that
6854        // clears only the inner one lands against the outer's and breaks that
6855        // instead.
6856        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
6857        d.caret = 9;
6858        d.insert(" ");
6859        assert_eq!(d.source, "a ***both*** \n");
6860        assert_eq!(d.caret, 13);
6861        d.insert("x");
6862        assert_eq!(d.source, "a ***both x***\n");
6863    }
6864
6865    #[test]
6866    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
6867        // The delimiter shuffle is not an edit the writer made, so it is not a
6868        // step they have to undo past.
6869        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
6870        d.caret = 8;
6871        d.insert(" ");
6872        assert_eq!(d.source, "a **bold** \n");
6873        d.undo();
6874        assert_eq!(d.source, "a **bold**\n");
6875    }
6876
6877    #[test]
6878    fn the_source_view_types_the_space_where_it_was_asked_to() {
6879        // The rule is a rich-view courtesy. In the source view the delimiters are
6880        // on the screen and the user is editing the bytes they can see.
6881        let mut d = doc_with("edge_src", "a **bold** c\n");
6882        d.caret = 8;
6883        d.insert(" ");
6884        assert_eq!(d.source, "a **bold ** c\n");
6885    }
6886
6887    #[test]
6888    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
6889        // Double-clicking a word takes the space after it; bolding that must not
6890        // spell `**word **`, which is not bold at all.
6891        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
6892        d.anchor = Some(2);
6893        d.caret = 7; // "word "
6894        d.toggle(InlineKind::Strong);
6895        assert_eq!(d.source, "a **word** b\n");
6896        // And a selection of nothing but whitespace has no word to mark.
6897        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
6898        d.anchor = Some(6);
6899        d.caret = 7;
6900        d.toggle(InlineKind::Strong);
6901        assert_eq!(d.source, "a word b\n");
6902        assert!(d.status.is_some());
6903    }
6904
6905    #[test]
6906    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
6907        let mut d = doc_with("head_set", "hello\n");
6908        d.caret = 2; // caret inside the paragraph, no selection
6909        d.set_block(BlockKind::Heading(1));
6910        assert_eq!(d.source, "# hello\n");
6911    }
6912
6913    #[test]
6914    fn set_block_heading_works_in_wysiwyg_view() {
6915        // The app defaults to WYSIWYG; the caret is a source offset either way.
6916        let mut d = wysiwyg_doc("head_wys", "hello\n");
6917        d.caret = 2;
6918        d.set_block(BlockKind::Heading(1));
6919        assert_eq!(d.source, "# hello\n");
6920    }
6921
6922    #[test]
6923    fn toggle_heading_applies_switches_and_reverts() {
6924        let mut d = doc_with("head_toggle", "hello\n");
6925        d.caret = 2;
6926        d.toggle_heading(1);
6927        assert_eq!(d.source, "# hello\n"); // paragraph → H1
6928        d.toggle_heading(2);
6929        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
6930        d.toggle_heading(2);
6931        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
6932    }
6933
6934    #[test]
6935    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
6936        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
6937        // the blank line but the caret rendered on the *next* line, because the
6938        // separator was a non-navigable decoration row. In Preserve flow that
6939        // blank line is a real caret home — the caret must resolve onto it, and
6940        // typing there makes the soft break that continues the paragraph.
6941        let src = "line one:\nsecond line\n";
6942        let mut d = wysiwyg_doc("pre_enter_lineend", src);
6943        d.set_line_flow(LineFlow::Preserve);
6944        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
6945        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
6946        d.newline();
6947        d.build_visual_unwrapped();
6948        assert_eq!(d.source, "line one:\n\nsecond line\n");
6949        assert_eq!(
6950            d.caret, 10,
6951            "caret sits on the new blank line, not the next line"
6952        );
6953        // The blank line is row 1, and the caret resolves onto it — not row 2.
6954        assert_eq!(
6955            d.vmap.pos_of_offset(10),
6956            (1, 0),
6957            "caret renders on the blank row"
6958        );
6959        assert!(
6960            !d.vmap.rows[1].decoration,
6961            "the blank line is navigable in Preserve"
6962        );
6963        // Typing there makes a soft break: one paragraph, three lines.
6964        d.insert("new clause,");
6965        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
6966    }
6967
6968    #[test]
6969    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
6970        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
6971        // that keeps it one paragraph — where Fold would open a second paragraph.
6972        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
6973        d.set_line_flow(LineFlow::Preserve);
6974        d.caret = 3;
6975        d.newline();
6976        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
6977
6978        // End-of-paragraph: Enter then typing continues the same paragraph on a
6979        // new line (a soft break), not a fresh paragraph.
6980        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
6981        d.set_line_flow(LineFlow::Preserve);
6982        d.caret = 3;
6983        d.newline();
6984        d.insert("def");
6985        assert_eq!(
6986            d.source, "abc\ndef\n",
6987            "end-of-line Enter + typing is a soft break"
6988        );
6989    }
6990
6991    #[test]
6992    fn preserve_double_enter_still_makes_a_paragraph() {
6993        // Two Enters in a row promote to a real paragraph break: the second lands
6994        // on the blank line the first opened and takes the empty-line branch.
6995        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
6996        d.set_line_flow(LineFlow::Preserve);
6997        d.caret = 3;
6998        d.newline();
6999        d.newline();
7000        d.insert("def");
7001        assert_eq!(
7002            d.source, "abc\n\ndef\n",
7003            "double Enter is a paragraph break"
7004        );
7005    }
7006
7007    #[test]
7008    fn preserve_backspace_joins_across_a_soft_break() {
7009        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7010        // soft break it deletes the single newline and joins the two lines.
7011        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7012        d.set_line_flow(LineFlow::Preserve);
7013        d.build_visual(80);
7014        d.caret = 4; // start of "def", just past the soft break
7015        d.backspace();
7016        assert_eq!(
7017            d.source, "abcdef\n",
7018            "Backspace joins across the soft break"
7019        );
7020        assert_eq!(d.caret, 3, "caret lands where the lines meet");
7021    }
7022
7023    #[test]
7024    fn fold_enter_still_starts_a_new_paragraph() {
7025        // The default flow is unchanged: a lone `\n` would render as an invisible
7026        // space, so Enter keeps opening the paragraph break that actually shows.
7027        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7028        d.caret = 3;
7029        d.newline();
7030        assert_eq!(
7031            d.source, "abc\n\ndef\n",
7032            "Fold mid-line Enter is a paragraph break"
7033        );
7034    }
7035
7036    #[test]
7037    fn wysiwyg_one_enter_starts_a_new_paragraph() {
7038        // Regression: one Enter left the caret between the two newlines, so typing
7039        // made a soft break (one paragraph) and you needed a second Enter.
7040        let mut d = wysiwyg_doc("wys_enter", "abc\n");
7041        d.caret = 3;
7042        d.newline();
7043        d.insert("def");
7044        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7045    }
7046
7047    #[test]
7048    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7049        // Regression: Enter at the caret's natural End-of-line resting place
7050        // after a bold run with nothing following it (on screen: right after
7051        // "bold", before the hidden closing "**") spliced the paragraph break
7052        // at that very byte offset — which sits *before* the closing "**" in
7053        // the source, since the delimiter is hidden and emits no glyph of its
7054        // own for `push_row`'s "end of row" fallback to count. That severed the
7055        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7056        // "**" alone on the new line instead of leaving "**bold**" intact with
7057        // a fresh empty paragraph after it.
7058        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7059        d.move_end(false); // the WYSIWYG End key, from caret 0
7060        assert_eq!(
7061            d.caret, 6,
7062            "caret rests right after \"bold\", before the hidden \"**\""
7063        );
7064        d.newline();
7065        assert!(
7066            d.source.starts_with("**bold**"),
7067            "the closing ** must stay attached to \"bold\": got {:?}",
7068            d.source
7069        );
7070        assert_eq!(
7071            d.source, "**bold**\n\n\n",
7072            "a fresh empty paragraph follows the still-intact bold run"
7073        );
7074    }
7075
7076    #[test]
7077    fn source_view_enter_is_a_single_newline() {
7078        let mut d = doc_with("src_enter", "abc\n");
7079        d.caret = 3;
7080        d.newline();
7081        assert_eq!(d.source, "abc\n\n");
7082    }
7083
7084    #[test]
7085    fn heading_applies_at_the_end_of_a_paragraph() {
7086        // The caret at a line end sits at the doc level; set_block must still find
7087        // the block on that line.
7088        let mut d = doc_with("head_end", "abc\n");
7089        d.caret = 3; // end of "abc"
7090        d.toggle_heading(1);
7091        assert_eq!(d.source, "# abc\n");
7092    }
7093
7094    #[test]
7095    fn heading_on_an_empty_new_paragraph_creates_one() {
7096        let mut d = wysiwyg_doc("head_empty", "abc\n");
7097        d.caret = 3;
7098        d.newline(); // caret now on a fresh, empty paragraph
7099        d.toggle_heading(1);
7100        d.insert("Title");
7101        assert!(d.source.contains("# Title"), "got {:?}", d.source);
7102    }
7103
7104    #[test]
7105    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7106        // The reported bug, end to end: click a blank line with another one under
7107        // it, press H1, type. The text landed in the heading and the caret's
7108        // offset was right (the source view drew it there), but the rich view
7109        // drew it two rows lower, on the trailing blank line — the empty `# `
7110        // heading had left every row below it short by the marker's two bytes,
7111        // and the blank line ended up claiming the heading's own end offset.
7112        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7113        d.build_visual_unwrapped();
7114        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7115        d.toggle_heading(1);
7116        for c in "title".chars() {
7117            d.insert(&c.to_string());
7118            d.build_visual_unwrapped(); // as a frontend does, one frame per key
7119        }
7120        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7121        assert_eq!(
7122            d.caret_pos(),
7123            (4, 5),
7124            "the caret draws at the end of the heading"
7125        );
7126    }
7127
7128    #[test]
7129    fn clicking_an_empty_heading_types_after_its_marker() {
7130        // The same anchor from the other side: the empty heading's row is its own
7131        // caret home, so a click on it must land past the hidden `# `. Landing in
7132        // front of the hashes made the first keystroke un-heading the line.
7133        let mut d = wysiwyg_doc("head_click", "# \n");
7134        d.build_visual_unwrapped();
7135        d.caret = d.vmap.offset_of_pos(0, 0);
7136        d.insert("x");
7137        assert_eq!(d.source, "# x\n");
7138    }
7139
7140    #[test]
7141    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7142        let mut d = wysiwyg_doc("head_enter", "# Title\n");
7143        d.caret = 7; // end of the heading
7144        d.newline();
7145        d.insert("body");
7146        assert_eq!(d.source, "# Title\n\nbody\n");
7147    }
7148
7149    #[test]
7150    fn wysiwyg_enter_continues_a_bullet_list() {
7151        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7152        d.caret = 6; // end of "item"
7153        d.newline();
7154        d.insert("two");
7155        assert_eq!(d.source, "- item\n- two\n");
7156    }
7157
7158    #[test]
7159    fn wysiwyg_enter_increments_an_ordered_list() {
7160        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7161        d.caret = 6; // end of "one"
7162        d.newline();
7163        d.insert("two");
7164        assert_eq!(d.source, "1. one\n2. two\n");
7165    }
7166
7167    #[test]
7168    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7169        // Regression for the "extra newline" left between a list and the paragraph
7170        // below it. Enter, Enter leaves the list on a fresh empty paragraph
7171        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7172        // Backspace should then take the caret cleanly back to the end of the list
7173        // item, `- item\n\nnext`, not delete a single newline and strand it on the
7174        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7175        // no caret can land on. The map is rebuilt between keystrokes exactly as a
7176        // frontend does, since Backspace reads the stop table to place the delete.
7177        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7178        d.caret = 6; // end of "item"
7179        d.newline();
7180        d.build_visual(80);
7181        d.newline(); // leave the list onto a fresh empty paragraph
7182        d.build_visual(80);
7183        assert_eq!(
7184            d.source, "- item\n\n\n\nnext\n",
7185            "double-Enter opens the empty paragraph"
7186        );
7187        d.backspace();
7188        assert_eq!(
7189            d.source, "- item\n\nnext\n",
7190            "one Backspace collapses the whole gap"
7191        );
7192        assert_eq!(
7193            d.caret, 6,
7194            "and lands the caret back at the end of the list item"
7195        );
7196    }
7197
7198    #[test]
7199    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7200        // The stop-wise delete must not over-reach when there is no block boundary
7201        // to cross: two blank lines in a row are one caret stop apart, so pressing
7202        // Enter on an empty line and then Backspace removes exactly the one newline
7203        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7204        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7205        d.caret = 5; // the empty paragraph the first Enter already opened
7206        d.build_visual(80);
7207        d.newline();
7208        d.build_visual(80);
7209        assert_eq!(
7210            d.source, "abc\n\n\n\n",
7211            "Enter on the blank line adds one newline"
7212        );
7213        d.backspace();
7214        assert_eq!(
7215            d.source, "abc\n\n\n",
7216            "Backspace takes back exactly that one newline"
7217        );
7218    }
7219
7220    #[test]
7221    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7222        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7223        d.caret = 6; // end of the empty "- " item
7224        d.newline();
7225        d.insert("p");
7226        assert_eq!(d.source, "- a\n\np\n");
7227    }
7228
7229    #[test]
7230    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7231        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7232        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7233        // not take the list-exit path (which would splice the `- ` away as if
7234        // leaving an empty item); the AST guard sends it to a normal break and
7235        // leaves the underline intact.
7236        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7237        assert!(
7238            d.nodes().iter().any(|n| n.kind == Kind::Heading),
7239            "precondition: twig parses this as a heading, not a list",
7240        );
7241        d.caret = 7; // on the `- ` underline line
7242        d.newline();
7243        assert!(
7244            d.source.contains("- "),
7245            "the setext underline survives, not spliced away as a list item: {:?}",
7246            d.source,
7247        );
7248    }
7249
7250    #[test]
7251    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7252        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7253        d.caret = 7; // end of "abc" inside the fence
7254        d.newline();
7255        d.insert("def");
7256        assert_eq!(d.source, "```\nabc\ndef\n```\n");
7257    }
7258
7259    #[test]
7260    fn wysiwyg_enter_continues_a_block_quote() {
7261        // Enter opens a new *paragraph* inside the quote, not a second line of
7262        // the same one. `> quote\n> more` is a soft break, which under
7263        // `LineFlow::Fold` renders as a space — the keystroke would look like it
7264        // did nothing. The quoted blank line is what makes the break visible, and
7265        // it's the same thing Enter does in running prose.
7266        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7267        d.caret = 7; // end of "quote"
7268        d.newline();
7269        d.insert("more");
7270        assert_eq!(d.source, "> quote\n>\n> more\n");
7271        // Still one quote, now holding two paragraphs — not a quote and a stray
7272        // line that fell out of it.
7273        let quotes = d
7274            .nodes()
7275            .iter()
7276            .filter(|n| n.kind == Kind::BlockQuote)
7277            .count();
7278        assert_eq!(quotes, 1);
7279    }
7280
7281    #[test]
7282    fn set_block_makes_a_heading_at_the_caret() {
7283        let mut d = doc_with("head", "Title\n\nbody\n");
7284        d.caret = 0;
7285        d.set_block(BlockKind::Heading(2));
7286        assert_eq!(d.source, "## Title\n\nbody\n");
7287        d.set_block(BlockKind::Paragraph);
7288        assert_eq!(d.source, "Title\n\nbody\n");
7289    }
7290
7291    // ── block containers (quote / list) ──────────────────────────────────────
7292
7293    #[test]
7294    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7295        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7296        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7297        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7298        // A caret at a line end sits at the doc level; the block is still found.
7299        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7300    }
7301
7302    #[test]
7303    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7304        // Every source line of the paragraph gets its own `> `, so a caret left
7305        // on its old byte offset falls one prefix per line above it too far
7306        // back — inside the markup it just asked for rather than in its word.
7307        assert_eq!(
7308            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7309            "> aaa\n> b|bb\n> ccc\n"
7310        );
7311    }
7312
7313    #[test]
7314    fn toggle_blockquote_works_in_wysiwyg_view() {
7315        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7316        assert_eq!(
7317            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
7318            "> hel|lo\n"
7319        );
7320        assert_eq!(
7321            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
7322            "hel|lo\n"
7323        );
7324    }
7325
7326    #[test]
7327    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
7328        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
7329        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7330        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7331        // The *other* kind converts in place instead of nesting, which is what
7332        // makes the two buttons one three-state control.
7333        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7334        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7335        // Its own kind, over the only item the list holds, takes it off.
7336        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
7337    }
7338
7339    #[test]
7340    fn toggle_list_works_in_wysiwyg_view() {
7341        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7342        assert_eq!(
7343            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
7344            "1. hel|lo\n"
7345        );
7346        assert_eq!(
7347            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
7348            "- hel|lo\n"
7349        );
7350        assert_eq!(
7351            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
7352            "hel|lo\n"
7353        );
7354    }
7355
7356    #[test]
7357    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
7358        // The selection has to grow with the markup: twig takes a container off
7359        // only a range covering every block it holds, so the second press can
7360        // reverse the first only if the result is what's selected.
7361        let mut d = doc_with("list_sel", "abc\n\ndef\n");
7362        d.select_all();
7363        d.toggle_list(true);
7364        assert_eq!(d.source, "1. abc\n\n2. def\n");
7365        assert_eq!(d.selection(), Some((0, d.source.len())));
7366        d.toggle_list(true);
7367        assert_eq!(d.source, "abc\n\ndef\n");
7368    }
7369
7370    #[test]
7371    fn toggle_blockquote_nests_a_partly_covered_quote() {
7372        // twig's rule: covering only some of a container's blocks nests, because
7373        // taking the quote off would drag its uncovered siblings out with it.
7374        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
7375        d.caret = 2; // in the first quoted paragraph only
7376        d.toggle_blockquote();
7377        assert_eq!(d.source, "> > a\n>\n> b\n");
7378    }
7379
7380    #[test]
7381    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
7382        // A blank line used to be no block for twig to wrap —
7383        // `toggle_block_container` answered `NotFound` — so Quote and the list
7384        // buttons did nothing on the very line the H1 button works on, and leaf
7385        // lent twig a scratch paragraph to wrap and took it back out again.
7386        // twig 3.2.0 opens an empty container there itself, so what is left here
7387        // is where the caret lands: inside the marker that was just written.
7388        let mut d = doc_with("quote_blank", "\nabc\n");
7389        d.caret = 0;
7390        d.toggle_blockquote();
7391        assert_eq!(d.source, "> \nabc\n");
7392        assert_eq!(
7393            d.caret, 2,
7394            "the caret belongs inside the quote it just opened"
7395        );
7396        assert!(d.status.is_none(), "{:?}", d.status);
7397        assert!(d.dirty);
7398
7399        // And the paragraph below is still its own block: an empty container one
7400        // soft break from `abc` would take that paragraph into the quote with it.
7401        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
7402        d.caret = 0;
7403        d.toggle_blockquote();
7404        d.build_visual(80);
7405        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
7406
7407        // The same from the other side: a blank line directly under a paragraph
7408        // earns the blank line an empty block needs, rather than being read as a
7409        // soft break inside that paragraph.
7410        let mut d = doc_with("list_blank_below", "abc\n");
7411        d.caret = 4;
7412        d.toggle_list(false);
7413        assert_eq!(d.source, "abc\n\n- ");
7414        assert_eq!(d.caret, 7);
7415    }
7416
7417    #[test]
7418    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
7419        // The gesture the rendering fix is for. `newline` inside a quote already
7420        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
7421        // spelling — but the two marker lines it adds belonged to no node until
7422        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
7423        // just made drew as plain prose under the quote.
7424        let mut d = wysiwyg_doc("quote_enter", "> a\n");
7425        d.caret = 3; // past `a`, at the end of the quoted line
7426        d.newline();
7427        assert_eq!(d.source, "> a\n>\n> \n");
7428        d.build_visual(80);
7429        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
7430        // And the caret is on the new line, not stranded on the old one.
7431        assert_eq!(d.caret, 8);
7432    }
7433
7434    #[test]
7435    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
7436        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
7437        // now it is twig's single edit. Either way one ⌘z has to put the blank
7438        // line back rather than undoing into a half-built document.
7439        for open in [
7440            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7441            &|d: &mut Doc| d.toggle_list(false),
7442            &|d: &mut Doc| d.toggle_list(true),
7443        ] {
7444            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7445            d.caret = 3;
7446            open(&mut d);
7447            assert_ne!(d.source, "a\n\n\n\nb\n");
7448            d.undo();
7449            assert_eq!(d.source, "a\n\n\n\nb\n");
7450        }
7451    }
7452
7453    #[test]
7454    fn a_container_toggle_is_one_undo_step() {
7455        let mut d = doc_with("quote_undo", "hello\n");
7456        d.caret = 3;
7457        d.insert("X"); // a typing run the structural edit must not fold into
7458        d.toggle_blockquote();
7459        assert_eq!(d.source, "> helXlo\n");
7460        d.undo();
7461        assert_eq!(d.source, "helXlo\n");
7462    }
7463
7464    // ── links ────────────────────────────────────────────────────────────────
7465
7466    #[test]
7467    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7468        let mut d = doc_with("link_sel", "word here\n");
7469        d.anchor = Some(0);
7470        d.caret = 4;
7471        d.insert_link("http://x.dev");
7472        assert_eq!(d.source, "[word](http://x.dev) here\n");
7473        // The text, not the destination — so a second press re-points the link
7474        // the first one made rather than nesting one inside it.
7475        assert_eq!(d.selected_text(), Some("word"));
7476        d.insert_link("http://y.dev");
7477        assert_eq!(d.source, "[word](http://y.dev) here\n");
7478        assert_eq!(d.selected_text(), Some("word"));
7479    }
7480
7481    #[test]
7482    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7483        let mut d = doc_with("img_caret", "before after\n");
7484        d.caret = 7; // between "before " and "after"
7485        d.insert_image("cat.png", "a cat");
7486        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
7487        // The caret sits just past the inserted image, nothing selected.
7488        assert_eq!(d.selection(), None);
7489        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
7490    }
7491
7492    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7493    /// destination at the first space, so the `format!` this used to be wrote
7494    /// something that was not an image at all — and the reader saw the markup as
7495    /// text. twig owns the spelling now, and moves it into the angle form.
7496    #[test]
7497    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7498        let mut d = doc_with("img_space", "x\n");
7499        d.caret = 0;
7500        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7501        assert_eq!(
7502            d.source,
7503            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
7504        );
7505        // And it reads back as an image pointing at the unescaped path — the angle
7506        // brackets are spelling, not part of the destination.
7507        d.caret = 2;
7508        assert_eq!(
7509            d.image_destination_at_caret(),
7510            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7511        );
7512    }
7513
7514    /// A `)` in a caption or a filename must not close the image early.
7515    #[test]
7516    fn insert_image_escapes_a_paren_in_either_half() {
7517        let mut d = doc_with("img_paren", "x\n");
7518        d.caret = 0;
7519        d.insert_image("a)b.png", "");
7520        assert_eq!(d.source, "![](a\\)b.png)x\n");
7521        d.caret = 2;
7522        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7523    }
7524
7525    #[test]
7526    fn insert_image_uses_the_selection_as_alt_text() {
7527        let mut d = doc_with("img_sel", "caption here\n");
7528        d.anchor = Some(0);
7529        d.caret = 7; // "caption"
7530        d.insert_image("p.png", "ignored fallback");
7531        assert_eq!(d.source, "![caption](p.png) here\n");
7532    }
7533
7534    #[test]
7535    fn insert_image_with_no_alt_leaves_empty_brackets() {
7536        let mut d = doc_with("img_noalt", "\n");
7537        d.caret = 0;
7538        d.insert_image("logo.svg", "");
7539        assert_eq!(d.source, "![](logo.svg)\n");
7540    }
7541
7542    #[test]
7543    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7544        // The round trip is the point: it's no use writing markup the reader
7545        // can't pick up again. This is the pair that only holds from twig 2.5.1
7546        // on — before it, the one-line form went in fine and came back as a
7547        // paragraph of raw tags, publishing no media at all.
7548        let mut d = doc_with("vid_rt", "\n");
7549        d.caret = 0;
7550        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7551        assert_eq!(
7552            d.source,
7553            "<video src=\"clip.mp4\" controls>a clip</video>\n"
7554        );
7555
7556        d.build_visual(80);
7557        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7558        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7559        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7560        assert_eq!(d.vmap.media[0].alt, "a clip");
7561    }
7562
7563    #[test]
7564    fn insert_media_spells_audio_with_its_own_tag() {
7565        let mut d = doc_with("aud_rt", "\n");
7566        d.caret = 0;
7567        d.insert_media(MediaKind::Audio, "take.mp3", "");
7568        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7569        d.build_visual(80);
7570        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7571    }
7572
7573    #[test]
7574    fn insert_media_uses_the_selection_as_fallback_text() {
7575        // The same courtesy `insert_image` does with alt: select a caption,
7576        // insert, and the caption labels the thing rather than being replaced.
7577        let mut d = doc_with("vid_sel", "the talk here\n");
7578        d.anchor = Some(0);
7579        d.caret = 8; // "the talk"
7580        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7581        assert_eq!(
7582            d.source,
7583            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
7584        );
7585    }
7586
7587    #[test]
7588    fn insert_media_with_an_image_kind_is_just_insert_image() {
7589        let mut d = doc_with("img_via_media", "\n");
7590        d.caret = 0;
7591        d.insert_media(MediaKind::Image, "logo.svg", "x");
7592        assert_eq!(d.source, "![x](logo.svg)\n");
7593    }
7594
7595    // ── thematic breaks ─────────────────────────────────────────────────────
7596
7597    /// The node the source parses as at `caret` — what confirms an inserted
7598    /// `---` actually reads back as a rule, not stray text or a setext heading.
7599    ///
7600    /// The *narrowest* node covering the offset. Every ancestor covers it too,
7601    /// and since twig 2.8 that includes the `doc` root, which now carries a real
7602    /// span (it reported none before, so taking the first match used to land on
7603    /// the block by luck and now always answers `"doc"`).
7604    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
7605        d.nodes()
7606            .into_iter()
7607            .filter(|n| n.span.start <= caret && caret < n.span.end)
7608            .min_by_key(|n| n.span.end - n.span.start)
7609            .map(|n| n.kind)
7610    }
7611
7612    #[test]
7613    fn a_task_box_toggles_at_the_caret_and_reads_back() {
7614        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
7615        d.caret = 8; // inside "todo"
7616        assert_eq!(d.task_checked_at_caret(), Some(false));
7617        d.toggle_task_checked();
7618        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
7619        assert_eq!(d.task_checked_at_caret(), Some(true));
7620        d.toggle_task_checked();
7621        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
7622    }
7623
7624    #[test]
7625    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
7626        // The whole reason `toggle_task_at` exists apart from the caret form:
7627        // ticking a box elsewhere must not move the cursor out of what's being
7628        // typed.
7629        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
7630        d.caret = 8; // inside "first"
7631        let second = d.source.find("second").unwrap();
7632        d.toggle_task_at(second);
7633        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
7634        assert_eq!(d.caret, 8, "the caret stayed in the first item");
7635    }
7636
7637    #[test]
7638    fn a_plain_item_gains_and_loses_a_box() {
7639        let mut d = doc_with("task_mint", "- plain\n");
7640        d.caret = 4;
7641        assert_eq!(d.task_checked_at_caret(), None);
7642        d.toggle_task_item();
7643        assert_eq!(d.source, "- [ ] plain\n");
7644        assert_eq!(
7645            d.task_checked_at_caret(),
7646            Some(false),
7647            "a new box arrives unticked"
7648        );
7649        d.toggle_task_item();
7650        assert_eq!(d.source, "- plain\n");
7651    }
7652
7653    #[test]
7654    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
7655        // `set checked` must not silently convert a bullet into a task — that is
7656        // `toggle_task_item`'s job, and twig refuses it here.
7657        let mut d = doc_with("task_none", "- plain\n");
7658        d.caret = 4;
7659        d.toggle_task_checked();
7660        assert_eq!(d.source, "- plain\n", "nothing written");
7661        assert!(
7662            d.status.is_some(),
7663            "the refusal should reach the status line"
7664        );
7665    }
7666
7667    #[test]
7668    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
7669        let mut d = doc_with("task_quote", "> - [ ] nested\n");
7670        d.caret = d.source.find("nested").unwrap();
7671        assert_eq!(d.task_checked_at_caret(), Some(false));
7672        d.toggle_task_checked();
7673        assert_eq!(d.source, "> - [x] nested\n");
7674    }
7675
7676    #[test]
7677    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
7678        // A rule is a block, so twig's `insert_thematic_break` alone lands it
7679        // after the whole paragraph. `split_block` parts the paragraph first and
7680        // the rule is aimed at the *first* half, which is what a rule button is
7681        // understood to do — and what leaf spelled by hand until twig grew both
7682        // halves of the gesture.
7683        let mut d = doc_with("hr_mid", "before after\n");
7684        d.caret = 7; // between "before " and "after"
7685        d.insert_thematic_break();
7686        assert_eq!(d.source, "before \n\n---\n\nafter\n");
7687        assert_eq!(d.selection(), None);
7688        assert_eq!(
7689            kind_at(&mut d, "before \n\n".len()),
7690            Some(Kind::ThematicBreak)
7691        );
7692    }
7693
7694    #[test]
7695    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
7696        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
7697        // and leaf wrote the first into both until twig started spelling it.
7698        let mut md = doc_with("hr_md", "para\n");
7699        md.caret = 2;
7700        md.insert_thematic_break();
7701        assert_eq!(md.source, "pa\n\n---\n\nra\n");
7702
7703        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
7704        dj.caret = 2;
7705        dj.insert_thematic_break();
7706        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
7707    }
7708
7709    #[test]
7710    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
7711        // The same bytes are two documents. In Markdown `  - b` is a nested item
7712        // and the next one belongs beside it, at its indent. In Djot a list
7713        // marker can't interrupt a paragraph, so those bytes are literal text in
7714        // item `a` and there is only one item — writing `  - ` under it would add
7715        // no item at all, just more text, and the new sibling has to go to
7716        // column zero. Both spellings come out of the *enclosing item's* line.
7717        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
7718        md.caret = "- a\n  - b".len();
7719        md.newline();
7720        assert_eq!(md.source, "- a\n  - b\n  - \n");
7721        assert_eq!(list_items(&mut md), 3);
7722
7723        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
7724        dj.view = View::Wysiwyg;
7725        dj.build_visual(80);
7726        dj.caret = "- a\n  - b".len();
7727        dj.newline();
7728        assert_eq!(dj.source, "- a\n  - b\n- \n");
7729        assert_eq!(list_items(&mut dj), 2);
7730
7731        // Where Djot's nesting is real — opened by a blank line — the indent is
7732        // reproduced there too, and the two formats agree again.
7733        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
7734        dj.view = View::Wysiwyg;
7735        dj.build_visual(80);
7736        dj.caret = "- a\n\n  - b".len();
7737        dj.newline();
7738        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
7739        assert_eq!(list_items(&mut dj), 3);
7740    }
7741
7742    #[test]
7743    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
7744        // Tab replaces the line's whole prefix with the one twig spells, so the
7745        // quote markers, the parent's indent and an ordered marker's extra
7746        // column are all its answer rather than leaf's arithmetic.
7747        for (name, body, caret, want) in [
7748            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
7749            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
7750            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
7751            // A checkbox is markup the item's own text wraps past, but a nested
7752            // list may only open at the *list* marker's column — four in from
7753            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
7754            // parses as one item, not two.
7755            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
7756            (
7757                "quoted task",
7758                "> - [ ] a\n> - [ ] b\n",
7759                18,
7760                "> - [ ] a\n>   - [ ] b\n",
7761            ),
7762        ] {
7763            let mut doc = wysiwyg_doc(name, body);
7764            doc.caret = caret;
7765            doc.indent();
7766            assert_eq!(doc.source, want, "{name}");
7767            // The nesting is real, not just indented text.
7768            assert_eq!(list_items(&mut doc), 2, "{name}");
7769        }
7770    }
7771
7772    #[test]
7773    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
7774        // The same bytes, the two formats disagreeing, and a gesture that used
7775        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
7776        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
7777        // so those bytes are literal text inside item `a` — there is nothing to
7778        // outdent, and treating them as a marker turned one item into two, a
7779        // structural edit from a keystroke that should delete one character.
7780        //
7781        // twig's `line_prefix` is what tells them apart: it reports the marker
7782        // on the Markdown line and nothing on the Djot one, which is a
7783        // continuation. No byte scan can reach that answer.
7784        let src = "- a\n  - b\n";
7785        let at = "- a\n  - ".len();
7786
7787        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
7788        md.view = View::Wysiwyg;
7789        md.build_visual(80);
7790        md.caret = at;
7791        md.backspace();
7792        assert_eq!(md.source, "- a\n- b\n");
7793        assert_eq!(list_items(&mut md), 2);
7794
7795        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
7796        dj.view = View::Wysiwyg;
7797        dj.build_visual(80);
7798        dj.caret = at;
7799        dj.backspace();
7800        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
7801        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
7802    }
7803
7804    #[test]
7805    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
7806        // Leaf used to spell the next item from the marker bytes it scanned, and
7807        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
7808        // and dropped out of the checklist. twig reproduces the whole
7809        // continuation, and a fresh item is always unticked however the one above
7810        // it stands.
7811        for (name, body, want) in [
7812            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
7813            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
7814        ] {
7815            let mut doc = wysiwyg_doc(name, body);
7816            doc.caret = body.trim_end_matches('\n').len();
7817            doc.newline();
7818            assert_eq!(doc.source, want, "{name}");
7819            // Both items are checklist items — the new one is a box, not the
7820            // plain bullet the old marker scan left behind — and it is unticked
7821            // whichever way the one above it faces.
7822            let boxes: Vec<Option<bool>> = doc
7823                .nodes()
7824                .iter()
7825                .filter(|n| n.kind == Kind::TaskListItem)
7826                .map(|n| n.checked)
7827                .collect();
7828            assert_eq!(boxes.len(), 2, "{name}");
7829            assert_eq!(boxes[1], Some(false), "{name}");
7830        }
7831    }
7832
7833    #[test]
7834    fn a_split_takes_the_space_the_caret_was_in_front_of() {
7835        // Splicing a break at the caret strands the space the words were parted
7836        // at on the head of the second block, where it reads as an indent nobody
7837        // typed. twig's split consumes it.
7838        for (name, body, caret, want) in [
7839            ("para", "one two\n", 3, "one\n\ntwo\n"),
7840            ("item", "- one two\n", 5, "- one\n- two\n"),
7841            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
7842            // A heading takes leaf's own path, which has to match.
7843            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
7844        ] {
7845            let mut doc = wysiwyg_doc(name, body);
7846            doc.caret = caret;
7847            doc.newline();
7848            assert_eq!(doc.source, want, "{name}");
7849        }
7850    }
7851
7852    #[test]
7853    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
7854        // The one place leaf keeps its own break: `split_block` repeats the `#`,
7855        // and Enter after a title is how the body under it is asked for.
7856        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
7857        doc.caret = "# Title".len();
7858        doc.newline();
7859        doc.insert("body");
7860        assert_eq!(doc.source, "# Title\n\nbody\n");
7861        assert_eq!(
7862            doc.nodes()
7863                .iter()
7864                .filter(|n| n.kind == Kind::Heading)
7865                .count(),
7866            1
7867        );
7868    }
7869
7870    #[test]
7871    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
7872        // A quoted item's marker doesn't open its line, so a scan that starts at
7873        // column zero finds a `>` where it wanted a bullet, calls the line "not a
7874        // list" and hands Enter to the plain-quote branch — which writes `> ` and
7875        // drops the list. The next item has to carry the whole prefix.
7876        for (name, body, want) in [
7877            ("flat", "> - a\n", "> - a\n> - \n"),
7878            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
7879            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
7880            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
7881            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
7882        ] {
7883            let mut doc = wysiwyg_doc(name, body);
7884            doc.caret = body.trim_end_matches('\n').len();
7885            doc.newline();
7886            assert_eq!(doc.source, want, "{name}");
7887            // The marker isn't just spelled right, it parses as an item.
7888            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
7889        }
7890    }
7891
7892    #[test]
7893    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
7894        // Double-Enter exits the list. Unquoted that means a blank line, but a
7895        // *bare* blank line would end the quote too and drop the caret out of it,
7896        // so the separator keeps its `>` and the caret's line keeps its `> `.
7897        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
7898        doc.caret = "> - a\n> - ".len();
7899        doc.newline();
7900        assert_eq!(doc.source, "> - a\n>\n> \n");
7901        assert_eq!(list_items(&mut doc), 1);
7902        // What "still in the quote" means for the next keystroke: the caret sits
7903        // behind the prefix, and what's typed there lands inside the quote as a
7904        // paragraph of its own — not as more of item `a`.
7905        doc.insert("x");
7906        assert_eq!(doc.source, "> - a\n>\n> x\n");
7907        assert!(
7908            doc.editor
7909                .ancestors_at(doc.caret - 1)
7910                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
7911        );
7912    }
7913
7914    #[test]
7915    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
7916        // The marker is hidden block markup, so Backspace over it is structural —
7917        // but only the marker is the list's. Splicing from the line start would
7918        // take the `>` with it and silently unquote the line.
7919        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
7920        doc.caret = "> - ".len();
7921        doc.backspace();
7922        assert_eq!(doc.source, "> a\n");
7923        assert_eq!(list_items(&mut doc), 0);
7924
7925        // A nested one outdents instead, moving the bullet within the quote
7926        // rather than moving the quote.
7927        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
7928        doc.caret = "> - a\n>   - ".len();
7929        doc.backspace();
7930        assert_eq!(doc.source, "> - a\n> - b\n");
7931        assert_eq!(list_items(&mut doc), 2);
7932    }
7933
7934    #[test]
7935    fn only_a_bare_paragraph_is_parted_around_the_caret() {
7936        // The split is deliberately narrow. Parting a fenced block would leave
7937        // two fences with a rule between them, and parting a list item would
7938        // mint an item nobody asked for on the way to a rule that lands after
7939        // the list either way — so both keep the whole block intact and take the
7940        // rule after it. A caret in a quote is likewise left alone.
7941        for (name, body, caret, want) in [
7942            (
7943                "code",
7944                "```\nfn x() {}\n```\n",
7945                8,
7946                "```\nfn x() {}\n```\n\n---\n",
7947            ),
7948            ("list", "- one two\n", 6, "- one two\n\n---\n"),
7949            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
7950        ] {
7951            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
7952            d.caret = caret;
7953            d.insert_thematic_break();
7954            assert_eq!(d.source, want, "{name}: the block should stay whole");
7955        }
7956    }
7957
7958    #[test]
7959    fn insert_thematic_break_replaces_the_selection() {
7960        // Now that the rule lands *at* the caret again, replacing the selection
7961        // is coherent once more: the text goes, and the rule takes its place.
7962        // The space the deletion left leading the second half is consumed by the
7963        // split rather than opening the new paragraph with it.
7964        let mut d = doc_with("hr_sel", "one two three\n");
7965        d.anchor = Some(4);
7966        d.caret = 7; // "two"
7967        d.insert_thematic_break();
7968        assert_eq!(d.source, "one \n\n---\n\nthree\n");
7969        assert_eq!(d.selection(), None);
7970    }
7971
7972    #[test]
7973    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
7974        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
7975        // because writing `---` into one is code, not a rule — twig now walks out
7976        // to the block that owns the caret's line, so there is nothing to refuse.
7977        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
7978        code.caret = 5; // inside the fenced code
7979        code.insert_thematic_break();
7980        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
7981        assert_eq!(code.status, None, "no refusal to report any more");
7982
7983        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
7984        table.caret = 3; // in the header row
7985        table.insert_thematic_break();
7986        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
7987    }
7988
7989    #[test]
7990    fn insert_thematic_break_in_a_list_item_ends_the_list() {
7991        // The un-indented rule cannot continue the list, so it closes the list
7992        // and lands at the top level rather than nested inside it.
7993        let mut d = doc_with("hr_list", "- one\n- two\n");
7994        d.caret = "- one\n- tw".len(); // mid "two"
7995        d.insert_thematic_break();
7996        d.build_visual(80);
7997        let rule_at = d.source.find("---").unwrap();
7998        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
7999        assert!(
8000            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8001                && n.span.start <= rule_at
8002                && rule_at < n.span.end),
8003            "the rule must not be nested inside the list"
8004        );
8005    }
8006
8007    #[test]
8008    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8009        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8010        // which is the document the gesture was actually asked for.
8011        let mut d = doc_with("hr_quote", "> hello\n");
8012        d.caret = 4; // inside the quoted text
8013        d.insert_thematic_break();
8014        assert_eq!(d.source, "> hello\n>\n> ---\n");
8015        d.build_visual(80);
8016        let rule_at = d.source.find("---").unwrap();
8017        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8018        assert!(
8019            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8020                && n.span.start <= rule_at
8021                && rule_at < n.span.end),
8022            "the rule belongs to the quote it was asked for"
8023        );
8024    }
8025
8026    // ── typing against a block picture ────────────────────────────────────────
8027
8028    /// A rendered-view document with the caret parked on one of the picture's two
8029    /// stops, and the map already built — the state a frontend is in between
8030    /// drawing a frame and the next keystroke.
8031    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8032        let mut d = doc_in(View::Wysiwyg, name, src);
8033        d.build_visual_unwrapped();
8034        let start = src.find("![").unwrap();
8035        d.caret = match side {
8036            MediaStop::Before => start,
8037            MediaStop::After => start + "![](p.png)".len(),
8038        };
8039        d
8040    }
8041
8042    /// The block media the map publishes, after rebuilding it — "is this still a
8043    /// picture, or has it become a line of text with an image in it?"
8044    fn media_count(d: &mut Doc) -> usize {
8045        d.build_visual_unwrapped();
8046        d.vmap.media.len()
8047    }
8048
8049    #[test]
8050    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8051        // The accident this prevents: tap the blank page under a photo (which
8052        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
8053        // paragraph with an *inline* image — the photo stops being drawn.
8054        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
8055        d.insert("xy");
8056        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
8057        assert_eq!(media_count(&mut d), 1, "still a picture");
8058    }
8059
8060    #[test]
8061    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8062        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8063        d.insert("xy");
8064        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
8065        assert_eq!(media_count(&mut d), 1);
8066    }
8067
8068    #[test]
8069    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8070        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
8071        d.insert("x");
8072        assert_eq!(d.source, "x\n\n![](p.png)\n");
8073        assert_eq!(media_count(&mut d), 1);
8074    }
8075
8076    #[test]
8077    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8078        // The opened paragraph is part of the keystroke, not an edit the writer
8079        // made — so it undoes with the character, not a step later.
8080        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
8081        d.insert("x");
8082        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
8083        d.undo();
8084        assert_eq!(d.source, "hi\n\n![](p.png)\n");
8085    }
8086
8087    #[test]
8088    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
8089        // ⌘V dissolves the picture exactly as a keystroke does.
8090        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
8091        d.paste("pasted");
8092        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
8093        assert_eq!(media_count(&mut d), 1);
8094    }
8095
8096    #[test]
8097    fn typing_beside_an_inline_image_is_ordinary_editing() {
8098        // An inline image has no placeholder row and no stops of its own. Opening
8099        // a paragraph mid-sentence would be the bug, not the fix.
8100        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
8101        d.build_visual_unwrapped();
8102        d.caret = "see ![](p.png)".len();
8103        d.insert("!");
8104        assert_eq!(d.source, "see ![](p.png)! here\n");
8105    }
8106
8107    #[test]
8108    fn source_view_types_raw_markup_against_an_image_untouched() {
8109        // Source view is for writing the markup itself; a break inserted behind
8110        // the writer's back there would be the editor arguing with them.
8111        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
8112        d.caret = "![](p.png)".len();
8113        d.insert("x");
8114        assert_eq!(d.source, "![](p.png)x\n");
8115    }
8116
8117    #[test]
8118    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
8119        // A selection is replaced, not joined into, so there is nothing to
8120        // protect: the range takes the picture with it.
8121        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
8122        d.anchor = Some(d.caret);
8123        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
8124        d.insert("x");
8125        assert_eq!(d.source, "hi\n\nx\n");
8126    }
8127
8128    #[test]
8129    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
8130        // What this actually cost: a real vault's photo, to one stray Backspace.
8131        // The caret past `![](p.png)` was deleting the closing paren — invisible
8132        // in the rendered view — and the photo became the text `![](p.png`.
8133        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
8134        d.backspace();
8135        assert_eq!(d.source, "hi\n");
8136        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
8137        d.undo();
8138        assert_eq!(
8139            d.source, "hi\n\n![](p.png)\n",
8140            "and comes back in one piece"
8141        );
8142    }
8143
8144    #[test]
8145    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
8146        // Deleting the break here would join the picture to the paragraph above,
8147        // where it is an *inline* image and stops being drawn. Step over the
8148        // boundary; the next press deletes in the paragraph the caret reached.
8149        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8150        d.backspace();
8151        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
8152        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8153        d.backspace();
8154        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
8155        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8156    }
8157
8158    #[test]
8159    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8160        // The mirror. A byte-step here eats the `!` and leaves a link.
8161        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
8162        d.delete_forward();
8163        assert_eq!(d.source, "hi\n\nbye\n");
8164        assert_eq!(media_count(&mut d), 0);
8165    }
8166
8167    #[test]
8168    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8169        let mut d = doc_at_picture(
8170            "pic_del_after",
8171            "hi\n\n![](p.png)\n\nbye\n",
8172            MediaStop::After,
8173        );
8174        d.delete_forward();
8175        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
8176        assert_eq!(
8177            d.caret,
8178            d.source.find("bye").unwrap(),
8179            "the caret stepped down to `bye`"
8180        );
8181    }
8182
8183    #[test]
8184    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8185        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
8186        d.backspace();
8187        assert_eq!(d.source, "\n");
8188        assert_eq!(media_count(&mut d), 0);
8189    }
8190
8191    #[test]
8192    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8193        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8194        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
8195        d.delete_word_back();
8196        assert_eq!(d.source, "hi there\n");
8197
8198        // And in front of one it runs *through* the paragraph break into the
8199        // prose above, which merges the picture inline — so it steps out first,
8200        // and the second press deletes the word it was aimed at.
8201        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
8202        d.delete_word_back();
8203        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
8204        d.delete_word_back();
8205        assert_eq!(
8206            d.source, "hi \n\n![](p.png)\n",
8207            "the word above went, the picture stayed"
8208        );
8209        assert_eq!(media_count(&mut d), 1);
8210    }
8211
8212    #[test]
8213    fn source_view_deletes_raw_markup_against_an_image_untouched() {
8214        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
8215        d.caret = "![](p.png)".len();
8216        d.backspace();
8217        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
8218    }
8219
8220    #[test]
8221    fn image_destination_at_caret_reads_the_image_under_the_caret() {
8222        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
8223        d.caret = 3; // inside the image markup
8224        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8225        // Past the image, the caret is in no image.
8226        d.caret = "![a cat](cat.png)".len();
8227        assert_eq!(d.image_destination_at_caret(), None);
8228    }
8229
8230    #[test]
8231    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8232        // The image is one placeholder row by default, and `set_media_rows` grows
8233        // it to the height the frontend measured: the label row plus blank
8234        // `decoration` fillers that hold the vertical space a raster is drawn into.
8235        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
8236        assert_eq!(d.vmap.media.len(), 1);
8237        let img_row = d.vmap.media[0].rows_span.start;
8238        assert_eq!(
8239            d.vmap.media[0].rows_span,
8240            img_row..img_row + 1,
8241            "default is one row"
8242        );
8243
8244        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8245        d.build_visual(80);
8246        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8247        let span = d.vmap.media[0].rows_span.clone();
8248        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8249        // The label row carries the mark and its glyphs; the three below are blank
8250        // decoration — drawn, but no caret and no text.
8251        assert!(
8252            d.vmap.rows[span.start].media.is_some(),
8253            "mark rides the first row"
8254        );
8255        for r in (span.start + 1)..span.end {
8256            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8257            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8258            assert!(
8259                d.vmap.rows[r].media.is_none(),
8260                "only the first row is marked"
8261            );
8262        }
8263    }
8264
8265    #[test]
8266    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8267        // The extra rows are pure spacers: the caret's only homes stay the stop in
8268        // front of the image and the one just past it, so walking the document top
8269        // to bottom visits the same offsets whether the image is 1 row or 5.
8270        let body = "ab\n\n![x](p.png)\n\ncd\n";
8271        let stops_at = |rows: usize| -> Vec<usize> {
8272            let mut d = wysiwyg_doc("img_stops", body);
8273            if rows > 1 {
8274                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8275                d.build_visual(80);
8276            }
8277            d.caret = 0;
8278            let mut seen = vec![d.caret];
8279            loop {
8280                d.move_right(false);
8281                if *seen.last().unwrap() == d.caret {
8282                    break;
8283                }
8284                seen.push(d.caret);
8285            }
8286            seen
8287        };
8288        assert_eq!(
8289            stops_at(1),
8290            stops_at(5),
8291            "reserving rows must not add stops"
8292        );
8293    }
8294
8295    #[test]
8296    fn insert_link_repoints_the_link_at_a_bare_caret() {
8297        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8298        d.caret = 3; // in the link's text, nothing selected
8299        d.insert_link("http://y.dev");
8300        assert_eq!(d.source, "[word](http://y.dev)\n");
8301        assert_eq!(d.selected_text(), Some("word"));
8302    }
8303
8304    #[test]
8305    fn insert_link_on_an_empty_range_autolinks_a_url() {
8306        // A link with no text of its own is an autolink, and twig spells it —
8307        // `<…>` is the canonical form and needs no text typed into it, so the
8308        // caret lands after it rather than selecting a finished link.
8309        let mut d = doc_with("link_empty", "\n");
8310        d.caret = 0;
8311        d.insert_link("http://x.dev");
8312        assert_eq!(d.source, "<http://x.dev>\n");
8313        assert_eq!(d.selection(), None);
8314        assert_eq!(d.caret, 14);
8315    }
8316
8317    #[test]
8318    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
8319        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
8320        // in Markdown, so a destination that can't autolink doubles as the text
8321        // instead — which is then selected, ready to be typed over.
8322        let mut d = doc_with("link_rel", "\n");
8323        d.caret = 0;
8324        d.insert_link("./notes.md");
8325        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
8326        assert_eq!(d.selection(), Some((1, 11)));
8327        d.insert("Notes");
8328        assert_eq!(d.source, "[Notes](./notes.md)\n");
8329    }
8330
8331    #[test]
8332    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
8333        // The autolink's text is its URL, so re-pointing replaces the whole
8334        // node — the caret must not splice a second link inside the first.
8335        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
8336        d.caret = 10;
8337        d.insert_link("https://y.dev");
8338        assert_eq!(d.source, "see <https://y.dev> ok\n");
8339    }
8340
8341    #[test]
8342    fn code_language_reads_and_edits_through_the_fence() {
8343        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
8344        d.caret = 10; // inside the code body
8345        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8346        assert!(d.caret_in_fenced_code());
8347
8348        d.set_code_language("python");
8349        assert!(
8350            d.source.starts_with("```python\n"),
8351            "source: {:?}",
8352            d.source
8353        );
8354        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
8355
8356        // Clearing it leaves a bare fence and no label.
8357        d.set_code_language("");
8358        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
8359        assert_eq!(d.code_language_at_caret(), None);
8360
8361        // A caret outside any code block edits nothing.
8362        let mut p = doc_with("code_lang_none", "just prose\n");
8363        assert!(!p.caret_in_fenced_code());
8364        p.set_code_language("rust");
8365        assert_eq!(p.source, "just prose\n");
8366    }
8367
8368    #[test]
8369    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
8370        // Markdown's info string ends at whitespace, so `two words` would write
8371        // a fence that reads back with a different language than the one asked
8372        // for. twig refuses it; leaf reports that and leaves the source alone.
8373        // The old splice trimmed the ends and wrote whatever was left.
8374        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
8375        d.caret = 10;
8376        d.set_code_language("two words");
8377        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
8378        assert!(d.status.is_some(), "the refusal should be reported");
8379        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8380    }
8381
8382    #[test]
8383    fn link_destination_at_caret_reads_both_spellings() {
8384        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
8385        d.caret = 5;
8386        assert_eq!(
8387            d.link_destination_at_caret().as_deref(),
8388            Some("https://x.dev")
8389        );
8390        d.caret = 0;
8391        assert_eq!(d.link_destination_at_caret(), None);
8392
8393        // An autolink has no `destination`; its text is the URL.
8394        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
8395        a.caret = 10;
8396        assert_eq!(
8397            a.link_destination_at_caret().as_deref(),
8398            Some("https://x.dev")
8399        );
8400        a.caret = 21;
8401        assert_eq!(a.link_destination_at_caret(), None);
8402    }
8403
8404    #[test]
8405    fn locate_finds_the_block_a_declared_id_names() {
8406        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
8407        // verse. The locator has to land on the *verse*, which is the whole
8408        // reason a link carries one.
8409        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
8410                   {#v2}\nYea, I make a record in the language of my father.\n";
8411        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8412        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
8413        assert_eq!(
8414            d.source[v2.start..v2.end].trim_end(),
8415            "Yea, I make a record in the language of my father."
8416        );
8417        // The attribute line is not part of it: `start` is a place to put a
8418        // caret, and `{#v2}` is markup the caret has no business landing in.
8419        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
8420        assert_eq!(d.locate("v99"), None);
8421    }
8422
8423    #[test]
8424    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
8425        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
8426        // Markdown heading is literal text. So `#the-second-part` can only be
8427        // the heading's own words, which is the rule every Markdown renderer
8428        // already follows and therefore the one a link was authored against.
8429        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
8430        let mut d = doc_with("locate_md", src);
8431        let hit = d.locate("the-second-part").expect("the heading's slug");
8432        assert!(d.source[hit.start..].starts_with("## The Second Part"));
8433        // Bounded by the next heading that isn't under it, so a peek shows the
8434        // section rather than only its title.
8435        assert_eq!(
8436            &d.source[hit.start..hit.end],
8437            "## The Second Part\n\nbody\n\n"
8438        );
8439
8440        // A subsection does not end its parent: `# Title` runs to `## Third`'s
8441        // sibling only because there is no other `#`, so it covers the lot.
8442        let title = d.locate("title").expect("the top heading");
8443        assert_eq!(title.end, d.source.len());
8444    }
8445
8446    #[test]
8447    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
8448        // djot mints `Some-Heading-Here`; a link to it is written
8449        // `#some-heading-here` by nearly everything that writes links. Both
8450        // spellings are one question.
8451        let src = "## Some Heading Here\n\nbody\n";
8452        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8453        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
8454        let slugged = d.locate("some-heading-here").expect("the link's spelling");
8455        assert_eq!(exact, slugged);
8456        // The section, not the heading line — there is more to show than a title.
8457        assert_eq!(&d.source[exact.start..exact.end], src);
8458    }
8459
8460    #[test]
8461    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
8462        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
8463        assert_eq!(d.locate(""), None);
8464        assert_eq!(d.locate("   "), None);
8465        // All punctuation: it names nothing, and must not be read as "match the
8466        // first heading whose slug is also empty".
8467        assert_eq!(d.locate("!!!"), None);
8468    }
8469
8470    #[test]
8471    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
8472        // The document's mistake, and the answer every other anchor
8473        // implementation gives — the alternative is for a link to mean whichever
8474        // of the two a walk happened to reach first.
8475        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
8476        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8477        let hit = d.locate("dup").expect("the first `{#dup}`");
8478        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
8479    }
8480
8481    #[test]
8482    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
8483        // The button's whole job: a reference where the caret was, a definition
8484        // to give it meaning, and the caret waiting in the empty note so the
8485        // next keystroke is the note's first word.
8486        let mut d = doc_with("fn_insert", "A claim and more.\n");
8487        d.caret = 7; // just past "A claim"
8488        d.insert_footnote();
8489        assert!(
8490            d.source.starts_with("A claim[^1] and more."),
8491            "{:?}",
8492            d.source
8493        );
8494        assert!(
8495            d.source.contains("[^1]:"),
8496            "the definition too: {:?}",
8497            d.source
8498        );
8499        assert_eq!(d.status, None);
8500
8501        let reference = d.source.find("[^1]").unwrap();
8502        let note = d
8503            .footnote_at(reference + 2)
8504            .expect("the reference just written");
8505        assert_eq!(note.label, "1");
8506        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
8507        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
8508        // …and typing there is typing into the note, not near it.
8509        d.insert("the note");
8510        assert_eq!(
8511            d.footnote_at(reference + 2).and_then(|f| f.text),
8512            Some("the note".to_string())
8513        );
8514    }
8515
8516    #[test]
8517    fn insert_footnote_numbers_past_the_notes_already_written() {
8518        // A second press must not hand back a label somebody else is using: twig
8519        // reuses a defined label rather than appending a rival definition, so a
8520        // repeat of `1` would quietly point the new reference at the old note.
8521        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8522        d.caret = 7; // past `[^1]`, before " two."
8523        d.insert_footnote();
8524        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8525        assert_eq!(d.source.matches("[^2]:").count(), 1);
8526    }
8527
8528    #[test]
8529    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8530        // `[^2]` with no definition is still a 2 that means something to whoever
8531        // wrote it — stepping over it would mint a note for their reference. A
8532        // word label takes no number, so it blocks none.
8533        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8534        d.caret = d.source.find(" c").unwrap();
8535        d.insert_footnote();
8536        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8537        assert!(
8538            d.source.starts_with("a[^2] b[^why][^1] c"),
8539            "{:?}",
8540            d.source
8541        );
8542    }
8543
8544    #[test]
8545    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8546        // A reference annotates the words before it. Consuming the selection —
8547        // which is what an insert normally does — would delete the very claim
8548        // the author selected in order to footnote.
8549        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8550        d.anchor = Some(2);
8551        d.caret = 7; // "claim" selected
8552        d.insert_footnote();
8553        assert!(
8554            d.source.starts_with("A claim[^1] and more."),
8555            "{:?}",
8556            d.source
8557        );
8558    }
8559
8560    #[test]
8561    fn a_note_just_written_still_knows_where_its_reference_is() {
8562        // The authoring loop in one test: press the button, type the note, ask to
8563        // go back. The caret ends at the note's last byte — which is the *end* of
8564        // the definition's span, the one offset the query used to exclude — so
8565        // this is where the round trip either works or doesn't.
8566        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8567        d.caret = 7;
8568        d.insert_footnote();
8569        d.insert("the note");
8570        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8571        let back = d
8572            .footnote_definition_at_caret()
8573            .expect("still in the note we just typed");
8574        assert_eq!(back.label, "1");
8575        // …and following it lands on the reference's label, where a reader's
8576        // return leg lands.
8577        assert_eq!(back.offset, Some(9));
8578        assert_eq!(&d.source[9..10], "1");
8579    }
8580
8581    #[test]
8582    fn insert_footnote_takes_one_undo_for_both_halves() {
8583        // twig writes the pair as a single edit; the point of that is here.
8584        let before = "A claim and more.\n";
8585        let mut d = doc_with("fn_insert_undo", before);
8586        d.caret = 7;
8587        d.insert_footnote();
8588        assert_ne!(d.source, before);
8589        d.undo();
8590        assert_eq!(d.source, before, "one undo takes back both halves");
8591    }
8592
8593    #[test]
8594    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
8595        // HTML is authorable — it spells the inline marks — and has no footnote.
8596        // The refusal says so rather than writing brackets that would render as
8597        // brackets.
8598        let src = "<p>A claim.</p>\n";
8599        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
8600        assert!(!Capabilities::of(Format::Html).footnote);
8601        d.caret = 5;
8602        d.insert_footnote();
8603        assert_eq!(d.source, src, "nothing written");
8604        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
8605    }
8606
8607    #[test]
8608    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
8609        // The empty body is the one place this could go wrong: the definition
8610        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
8611        // byte early would draw up in the paragraph above the note it belongs to.
8612        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
8613        d.place_caret(7, false);
8614        d.insert_footnote();
8615        d.build_visual(80); // the frame a frontend draws after the edit
8616        assert_eq!(
8617            d.vmap.snap_to_stop(d.caret),
8618            d.caret,
8619            "the caret sits on a stop"
8620        );
8621        let (row, _) = d.caret_pos();
8622        assert!(
8623            drawn_rows(&d)[row].contains("[1]"),
8624            "the caret is on the note's row, not above it: {:?}",
8625            drawn_rows(&d)
8626        );
8627    }
8628
8629    #[test]
8630    fn footnote_at_caret_resolves_a_reference_to_its_note() {
8631        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
8632        // blank line, as one has to.
8633        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
8634        d.caret = 9;
8635        let f = d
8636            .footnote_at_caret()
8637            .expect("the caret stands in a reference");
8638        assert_eq!(f.label, "1");
8639        assert_eq!(f.text.as_deref(), Some("the note"));
8640        // The offset points at the note's first word, not at the definition's
8641        // `[` — the marker is decoration with no caret stop on it.
8642        assert_eq!(f.offset, Some(29));
8643        assert_eq!(&d.source[29..37], "the note");
8644        // …and `end` closes the range, so a frontend can ask which rendered rows
8645        // the note occupies rather than re-deriving them from the text.
8646        assert_eq!(f.end, Some(37));
8647        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
8648    }
8649
8650    /// Two definitions in a row: each is its own note, and neither reaches into
8651    /// the other.
8652    ///
8653    /// A djot definition's span used to run past the blank line into the first
8654    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
8655    /// offsets named the *next* note's rows too, showing a reader two footnotes
8656    /// when they had asked about one. twig 3.1 ends the span after the block's
8657    /// own last line; the test outlives the workaround leaf carried for it.
8658    #[test]
8659    fn footnote_at_stops_a_note_at_the_definition_after_it() {
8660        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
8661        for format in [Format::Markdown, Format::Djot] {
8662            let mut d = Doc::from_source(src.to_string(), format).unwrap();
8663            d.caret = 7;
8664            let f = d.footnote_at_caret().expect("a reference");
8665            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
8666            assert_eq!(
8667                &src[f.offset.unwrap()..f.end.unwrap()],
8668                "first note.",
8669                "in {format:?}"
8670            );
8671        }
8672    }
8673
8674    /// The other side of that boundary: a blank line *inside* a definition is
8675    /// interior to it, and the note keeps its second paragraph.
8676    ///
8677    /// This is what the old body scan cost. It stopped at the first line not
8678    /// indented under the note — a blank line is not — so a two-paragraph note
8679    /// came back as its first paragraph, and "go to note" framed half of it.
8680    /// Reading the span twig gives is both simpler and right.
8681    #[test]
8682    fn footnote_at_keeps_a_notes_second_paragraph() {
8683        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
8684        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8685        d.caret = 7;
8686        let f = d.footnote_at_caret().expect("a reference");
8687        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
8688        // And it stops there — `After.` is the next block, not more note.
8689        assert_eq!(
8690            &src[f.offset.unwrap()..f.end.unwrap()],
8691            f.text.as_deref().unwrap()
8692        );
8693        assert!(!f.text.as_deref().unwrap().contains("After"));
8694    }
8695
8696    #[test]
8697    fn footnote_at_bounds_a_note_whose_body_is_empty() {
8698        // `[^1]:` with nothing after it. The range is empty rather than
8699        // inverted, and still points inside the definition — which is what keeps
8700        // a frontend's row lookup from walking off into the block above.
8701        let src = "A claim[^1].\n\n[^1]:\n";
8702        let mut d = doc_with("fn_empty_body", src);
8703        d.caret = 9;
8704        let f = d.footnote_at_caret().expect("a reference");
8705        assert_eq!(f.text.as_deref(), Some(""));
8706        assert_eq!(f.offset, f.end, "an empty note is an empty range");
8707        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
8708    }
8709
8710    #[test]
8711    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
8712        let mut d = doc_with(
8713            "fn_at_caret_none",
8714            "A claim[^1] and more.\n\n[^1]: the note\n",
8715        );
8716        d.caret = 2; // in the prose
8717        assert_eq!(d.footnote_at_caret(), None);
8718    }
8719
8720    #[test]
8721    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
8722        // The two are deliberately separate: a reference names a note in this
8723        // document, a link names somewhere to leave for, and answering one with
8724        // the other is what made a reference click do nothing at all.
8725        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
8726        d.caret = 3; // the `1` of `[^1]`
8727        assert!(d.footnote_at_caret().is_some());
8728        assert_eq!(
8729            d.link_destination_at_caret(),
8730            None,
8731            "a reference is not a link"
8732        );
8733
8734        d.caret = 10; // inside the link's label
8735        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
8736        assert_eq!(
8737            d.link_destination_at_caret().as_deref(),
8738            Some("https://x.dev")
8739        );
8740    }
8741
8742    #[test]
8743    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
8744        // A `[^99]` the document never defines is a real state — a note deleted
8745        // out from under its reference — and the label is what lets a frontend
8746        // say so. `None` here would be indistinguishable from "not on a
8747        // reference", which is the wrong thing to tell a reader.
8748        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
8749        d.caret = 9;
8750        let f = d
8751            .footnote_at_caret()
8752            .expect("the reference is still a reference");
8753        assert_eq!(f.label, "99");
8754        assert_eq!(f.text, None);
8755        assert_eq!(f.offset, None);
8756    }
8757
8758    #[test]
8759    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
8760        // Labels are not always numbers, and a note's body runs past its first
8761        // line — the indented continuation belongs to the note, so it comes back
8762        // with it (source bytes, verbatim, as documented).
8763        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
8764        let mut d = doc_with("fn_word_label", src);
8765        d.caret = 6;
8766        let f = d
8767            .footnote_at_caret()
8768            .expect("the caret stands in a reference");
8769        assert_eq!(f.label, "note");
8770        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
8771    }
8772
8773    #[test]
8774    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
8775        // The point of the offset form: a pointer hovering a reference asks what
8776        // note it names, and must not drag the caret along to ask.
8777        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
8778        d.caret = 0;
8779        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
8780        assert_eq!(f.label, "1");
8781        assert_eq!(f.text.as_deref(), Some("the note"));
8782        assert_eq!(d.caret, 0, "asking must not move the caret");
8783        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
8784    }
8785
8786    #[test]
8787    fn footnote_definition_at_caret_points_back_at_the_reference() {
8788        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
8789        // the caret can rest on — is at 9.
8790        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
8791        d.caret = 30; // inside the note's body
8792        let f = d
8793            .footnote_definition_at_caret()
8794            .expect("the caret stands in a definition");
8795        assert_eq!(f.label, "1");
8796        assert_eq!(f.offset, Some(9));
8797        assert_eq!(&d.source[7..11], "[^1]");
8798    }
8799
8800    #[test]
8801    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
8802        // The two legs have to meet: wherever `footnote_at` sends the caret, the
8803        // definition query must answer for — otherwise arriving at a note leaves
8804        // the reader somewhere the way back isn't offered.
8805        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
8806        let mut d = doc_with("fn_def_marker", src);
8807        let landed = d.footnote_at(9).unwrap().offset.unwrap();
8808        assert_eq!(
8809            d.footnote_definition_at(landed).and_then(|f| f.offset),
8810            Some(9),
8811            "the note a reference sends you to offers the way back"
8812        );
8813    }
8814
8815    #[test]
8816    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
8817        // The two queries answer for disjoint places, which is what lets one
8818        // gesture mean "down to the note" in one and "back up" in the other
8819        // without either having to remember which way the reader is going.
8820        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
8821        d.caret = 2; // prose
8822        assert_eq!(d.footnote_definition_at_caret(), None);
8823        d.caret = 9; // the reference
8824        assert_eq!(d.footnote_definition_at_caret(), None);
8825        assert!(
8826            d.footnote_at_caret().is_some(),
8827            "which is the reference's own query"
8828        );
8829    }
8830
8831    #[test]
8832    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
8833        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
8834        // note", which is false and leaves a frontend unable to explain why the
8835        // way back is missing.
8836        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
8837        let mut d = doc_with("fn_def_orphan", src);
8838        d.caret = src.find("orphan").unwrap();
8839        let f = d
8840            .footnote_definition_at_caret()
8841            .expect("an orphan is still a definition");
8842        assert_eq!(f.label, "2");
8843        assert_eq!(f.offset, None);
8844    }
8845
8846    #[test]
8847    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
8848        // One label, cited twice. The first is where the reader most likely came
8849        // from, and the only answer that doesn't depend on how they got here.
8850        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
8851        let mut d = doc_with("fn_def_repeat", src);
8852        d.caret = src.find("the note").unwrap();
8853        let f = d.footnote_definition_at_caret().expect("a definition");
8854        assert_eq!(
8855            f.offset,
8856            Some(5),
8857            "the first `[^a]`'s label, not the second's"
8858        );
8859        assert_eq!(&src[3..7], "[^a]");
8860    }
8861
8862    #[test]
8863    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
8864        // Down and back up, each leg found from the document rather than from a
8865        // memory of the other — so it still works for a reader who scrolled to
8866        // the notes instead of jumping there.
8867        //
8868        // `place_caret` rather than assigning `caret`, because that is what a
8869        // frontend calls: it snaps to a real caret stop, and a jump that lands
8870        // on a byte the caret can't rest on would arrive somewhere the return
8871        // leg no longer answers for. `build_map` first, since snapping is a
8872        // no-op until the map exists — which is exactly how this went unnoticed
8873        // when the offsets pointed at the `[^` markers.
8874        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
8875        d.build_map(None);
8876        d.place_caret(9, false);
8877        let down = d
8878            .footnote_at_caret()
8879            .expect("a reference")
8880            .offset
8881            .expect("a note");
8882        d.place_caret(down, false);
8883        let up = d
8884            .footnote_definition_at_caret()
8885            .expect("a definition")
8886            .offset
8887            .expect("a reference");
8888        d.place_caret(up, false);
8889        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
8890        assert_eq!(
8891            d.footnote_at_caret().expect("back on the reference").label,
8892            "1"
8893        );
8894    }
8895
8896    #[test]
8897    fn insert_link_hands_the_destination_to_twig_raw() {
8898        // Escaping is twig's, and format-specific: Markdown ends a destination
8899        // at the first space and needs the `<…>` form, where djot would read
8900        // those angle brackets as part of the URL.
8901        let mut d = doc_with("link_space", "word\n");
8902        d.anchor = Some(0);
8903        d.caret = 4;
8904        d.insert_link("a b");
8905        assert_eq!(d.source, "[word](<a b>)\n");
8906    }
8907
8908    #[test]
8909    fn insert_link_reports_a_destination_no_format_can_carry() {
8910        let mut d = doc_with("link_bad", "word\n");
8911        d.anchor = Some(0);
8912        d.caret = 4;
8913        d.insert_link("a\nb");
8914        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
8915        assert!(
8916            d.status.is_some(),
8917            "InvalidArgument should reach the status line"
8918        );
8919        assert!(!d.dirty);
8920    }
8921
8922    #[test]
8923    fn insert_link_works_in_wysiwyg_view() {
8924        let mut d = wysiwyg_doc("link_wys", "word here\n");
8925        d.anchor = Some(0);
8926        d.caret = 4;
8927        d.insert_link("http://x.dev");
8928        assert_eq!(d.source, "[word](http://x.dev) here\n");
8929        assert_eq!(d.selected_text(), Some("word"));
8930        // The map the caret has to keep riding is rebuilt each frame; motion
8931        // over the fresh one must still land on a real stop (the debug_assert).
8932        d.build_visual(80);
8933        d.move_right(false);
8934        d.move_left(false);
8935    }
8936
8937    #[test]
8938    fn click_maps_a_row_col_to_a_byte_offset() {
8939        let mut d = doc_with("click", "ab\ncd\n");
8940        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
8941        assert_eq!(d.caret, 4);
8942    }
8943
8944    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
8945    // stop just as the `(row, col)` click path does, so the caret can never come
8946    // to rest in the blank gap between two paragraphs — where it would draw in one
8947    // place and type in another.
8948    #[test]
8949    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
8950        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
8951        // caret stop (stops are 0,1,3,4).
8952        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
8953        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
8954        d.place_caret(2, false);
8955        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8956        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
8957    }
8958
8959    #[test]
8960    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
8961        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
8962        d.place_caret(0, false); // anchor at the start of "A"
8963        d.place_caret(2, true); // drag into the gap
8964        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8965        let (s, e) = d.selection().expect("a selection");
8966        assert!(
8967            d.vmap.is_stop(s) && d.vmap.is_stop(e),
8968            "selection {s}..{e} off a stop"
8969        );
8970    }
8971
8972    #[test]
8973    fn place_caret_on_a_real_stop_is_left_untouched() {
8974        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
8975        d.place_caret(3, false); // the start of "B" — a genuine stop
8976        assert_eq!(d.caret, 3);
8977    }
8978
8979    // An *empty paragraph* (two blank lines, an intentional blank line the user
8980    // opened) is a real caret stop, unlike the gap — a click into it must stay.
8981    #[test]
8982    fn place_caret_rests_in_an_empty_paragraph() {
8983        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
8984        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
8985        assert!(d.vmap.is_stop(empty));
8986        d.place_caret(empty, false);
8987        assert_eq!(d.caret, empty);
8988    }
8989
8990    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
8991        doc_in(View::Wysiwyg, name, body)
8992    }
8993
8994    /// How many list items the source actually parses into — the check that a
8995    /// marker Leaf wrote is a marker the format agrees is one.
8996    fn list_items(doc: &mut Doc) -> usize {
8997        doc.editor
8998            .nodes()
8999            .unwrap()
9000            .iter()
9001            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9002            .count()
9003    }
9004
9005    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9006    /// incremental (`build_spliced` / `build_cached`) path must always match.
9007    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9008        reference_map_revealing(source, None)
9009    }
9010
9011    /// [`reference_map`] with a reveal line — the ground truth for the
9012    /// `MarkupMode::Full` builds, where the map is a function of the caret's
9013    /// line as well as the text.
9014    fn reference_map_revealing(
9015        source: &str,
9016        reveal: Option<Range<usize>>,
9017    ) -> crate::wysiwyg::VisualMap {
9018        // The same parse `Doc` uses. With twig's plain defaults instead, the two
9019        // sides disagree on what the *document* is before the renderer is even
9020        // reached — a bare `:word` is a text directive to one and prose to the
9021        // other — and the mismatch reads as a splice bug that isn't one.
9022        let mut ed =
9023            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9024        let nodes = ed.nodes().unwrap();
9025        crate::wysiwyg::build(
9026            &nodes,
9027            source,
9028            None,
9029            false,
9030            &std::collections::HashMap::new(),
9031            reveal,
9032        )
9033    }
9034
9035    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
9036        if a.rows.len() != b.rows.len() {
9037            return true;
9038        }
9039        for (ra, rb) in a.rows.iter().zip(&b.rows) {
9040            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
9041                return true;
9042            }
9043            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
9044                if ga.ch != gb.ch || ga.src != gb.src {
9045                    return true;
9046                }
9047            }
9048        }
9049        false
9050    }
9051
9052    #[test]
9053    fn incremental_build_matches_a_fresh_build_across_edits() {
9054        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
9055        // fast path, gated on twig's `dirty_range`) or falls back to
9056        // `build_cached`. After each edit the map must be byte-identical to a
9057        // from-scratch build — this is the correctness net under the splice.
9058        let docs = [
9059            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
9060            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
9061            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
9062            // A footnote definition is a root beside `doc`, merged back into the
9063            // top-level list by `wysiwyg::top_blocks`. The random edits below
9064            // make and unmake definitions as they go (a deleted `:` turns one
9065            // back into a paragraph, and vice versa), which is exactly the
9066            // structural churn the splice path has to notice and bail out of.
9067            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
9068        ];
9069        // A deterministic mix: mostly single characters (which stay inside one
9070        // block → splice), plus edits that reshape structure (a paragraph break,
9071        // a heading marker, a code fence → fallback), so both paths are exercised.
9072        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
9073        for src in docs {
9074            let mut d = wysiwyg_doc("diff", src);
9075            d.build_visual_unwrapped();
9076            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
9077
9078            for step in 0..60usize {
9079                let len = d.source.len();
9080                let raw = (step * 13 + 5) % (len + 1);
9081                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9082                let pre = d.source.clone();
9083                let action;
9084                if step % 3 == 0 && pos < len {
9085                    let end = (pos + 1..=len)
9086                        .find(|&i| d.source.is_char_boundary(i))
9087                        .unwrap();
9088                    action = format!("delete [{pos},{end})");
9089                    d.edit(pos, end, "");
9090                } else {
9091                    let ins = inserts[step % inserts.len()];
9092                    action = format!("insert {ins:?} @ {pos}");
9093                    d.edit(pos, pos, ins);
9094                }
9095                d.build_visual_unwrapped();
9096                if maps_differ(&d.vmap, &reference_map(&d.source)) {
9097                    panic!(
9098                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
9099                        d.source
9100                    );
9101                }
9102            }
9103        }
9104    }
9105
9106    #[test]
9107    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
9108        // The same correctness net as `incremental_build_matches_a_fresh_build_
9109        // across_edits`, under `MarkupMode::Full` — where the map depends on
9110        // the caret's *line* as well as the text, so the two caches have a new
9111        // way to be wrong. Both are exercised: the block cache can hand back
9112        // rows built for a line that is no longer the revealed one, and the
9113        // splice path can reuse a suffix that still has yesterday's line raw.
9114        //
9115        // Caret motion is interleaved with the edits deliberately, because a
9116        // caret that only ever moved with the edit would never cross a line
9117        // without also dirtying it — the case where a stale reveal survives.
9118        let docs = [
9119            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
9120            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
9121        ];
9122        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
9123        for src in docs {
9124            let mut d = wysiwyg_doc("reveal_diff", src);
9125            d.set_markup_mode(MarkupMode::Full);
9126
9127            for step in 0..60usize {
9128                let len = d.source.len();
9129                let raw = (step * 13 + 5) % (len + 1);
9130                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9131                let pre = d.source.clone();
9132                let action;
9133                if step % 3 == 0 && pos < len {
9134                    let end = (pos + 1..=len)
9135                        .find(|&i| d.source.is_char_boundary(i))
9136                        .unwrap();
9137                    action = format!("delete [{pos},{end})");
9138                    d.edit(pos, end, "");
9139                } else {
9140                    let ins = inserts[step % inserts.len()];
9141                    action = format!("insert {ins:?} @ {pos}");
9142                    d.edit(pos, pos, ins);
9143                }
9144                // Walk the caret somewhere else in the document, independently
9145                // of where the edit landed.
9146                let want = (step * 29 + 11) % (d.source.len() + 1);
9147                d.caret = (want..=d.source.len())
9148                    .find(|&i| d.source.is_char_boundary(i))
9149                    .unwrap();
9150                d.build_visual_unwrapped();
9151
9152                let want = reference_map_revealing(&d.source, d.reveal_line());
9153                if maps_differ(&d.vmap, &want) {
9154                    panic!(
9155                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
9156                        d.caret, d.source
9157                    );
9158                }
9159            }
9160        }
9161    }
9162
9163    #[test]
9164    fn caret_motion_across_lines_rebuilds_only_under_full() {
9165        // The cache-key change has to earn its keep in both directions: `Full`
9166        // must rebuild when the caret changes line (or the reveal would never
9167        // move), and the hidden modes must *not* (or every arrow key would pay
9168        // for a feature they don't use). The existing `cache_motion` test pins
9169        // the second for the default mode; this pins the pair against a mode
9170        // change alone.
9171        let body = "*one* here\n\n*two* there\n";
9172
9173        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9174        full.set_markup_mode(MarkupMode::Full);
9175        caret_at(&mut full, "one");
9176        let before = full.revision();
9177        caret_at(&mut full, "two");
9178        assert_eq!(full.revision(), before, "motion is not an edit");
9179        assert!(
9180            drawn_rows(&full).iter().any(|r| r == "*two* there"),
9181            "the map followed the caret: {:?}",
9182            drawn_rows(&full)
9183        );
9184
9185        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9186        caret_at(&mut hidden, "one");
9187        let key = hidden.vmap_key.clone();
9188        caret_at(&mut hidden, "two");
9189        assert_eq!(
9190            hidden.vmap_key, key,
9191            "a hidden mode rebuilds nothing on motion"
9192        );
9193    }
9194
9195    #[test]
9196    fn wysiwyg_down_crosses_a_paragraph_boundary() {
9197        // Regression: the blank separator row used to share the previous
9198        // paragraph's end offset, so Down got pinned at the boundary (while Up
9199        // still crossed). Both directions must step through it symmetrically.
9200        //
9201        // It's now stepped *over* rather than onto: the blank line between two
9202        // paragraphs is the boundary being drawn, not a line of the document, so
9203        // one press of Down crosses it. The goal column survives the crossing —
9204        // col 3 at the end of "abc" is col 3 at the end of "def".
9205        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9206        d.caret = 3; // end of "abc" (row 0)
9207        d.move_down(false);
9208        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9209        assert_eq!(d.caret, 8); // end of "def", col 3 kept
9210        d.move_up(false);
9211        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9212        assert_eq!(d.caret, 3);
9213    }
9214
9215    #[test]
9216    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
9217        // The second Up and the second Down here run off the ends of the
9218        // document, which is no longer a place a press is swallowed: they carry
9219        // the caret to the start and the end of the text. The claim in the
9220        // middle — that a Down retraces the Up that crossed the paragraph gap —
9221        // is the one this test is for, and it is asserted where it is made.
9222        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
9223        d.caret = 5; // start of "def"
9224        let start = d.caret_pos();
9225        d.move_up(false);
9226        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
9227        d.move_up(false);
9228        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
9229        d.move_down(false);
9230        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
9231        d.move_down(false);
9232        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
9233    }
9234
9235    #[test]
9236    fn wysiwyg_new_paragraph_shows_before_typing() {
9237        // Regression: two Enters at the end of a paragraph produced trailing
9238        // newlines with no AST node, so the caret appeared stuck on the old line
9239        // until a character was typed. It must ride down onto the new line now.
9240        let mut d = doc_with("wys_newpara", "abc\n");
9241        d.view = View::Wysiwyg;
9242        d.caret = 3;
9243        d.insert("\n");
9244        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
9245        assert_eq!(d.source, "abc\n\n\n");
9246        d.build_visual(80);
9247        let (row, _) = d.caret_pos();
9248        assert!(
9249            row >= 2,
9250            "caret should have moved down to the new line, got row {row}"
9251        );
9252        assert!(
9253            d.vmap.num_rows() >= 3,
9254            "the blank lines should render as rows"
9255        );
9256    }
9257
9258    #[test]
9259    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
9260        // The reported bug: Enter at the end of a paragraph that has another
9261        // paragraph below put the caret at the *start of the next paragraph* —
9262        // the empty paragraph it opened had no row, so the caret snapped onto
9263        // "World". It must now sit on its own empty line, with a blank spacer
9264        // above it (the paragraph gap).
9265        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
9266        d.caret = 5; // end of "Hello"
9267        d.newline();
9268        d.build_visual(80);
9269        let (row, col) = d.caret_pos();
9270        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
9271        assert_eq!(
9272            d.vmap.row_width(row),
9273            0,
9274            "caret's row must be empty, not 'World'"
9275        );
9276        assert!(
9277            row >= 2,
9278            "a blank spacer row should sit above the caret, got row {row}"
9279        );
9280        // The row above the caret is a real (empty) gap, and "Hello" stays put.
9281        assert_eq!(
9282            d.vmap.row_width(row - 1),
9283            0,
9284            "the row above the caret is a gap"
9285        );
9286        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9287        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
9288    }
9289
9290    #[test]
9291    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
9292        // At the document end a single Enter must also show the paragraph gap —
9293        // a blank spacer row above the caret — so the layout already matches how
9294        // it will look once the new paragraph has text.
9295        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
9296        d.caret = 5; // end of "Hello", no trailing newline
9297        d.newline(); // source becomes "Hello\n\n"
9298        d.build_visual(80);
9299        let (row, col) = d.caret_pos();
9300        assert_eq!(col, 0);
9301        assert!(
9302            row >= 2,
9303            "caret should sit below a blank spacer, got row {row}"
9304        );
9305        assert_eq!(
9306            d.vmap.row_width(row - 1),
9307            0,
9308            "the row above the caret is a gap"
9309        );
9310    }
9311
9312    #[test]
9313    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
9314        // The spacer is view-only: typing the new paragraph must not reflow the
9315        // caret onto a different row — the transient view already matched the
9316        // settled one.
9317        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
9318        d.caret = 5;
9319        d.newline();
9320        d.build_visual(80);
9321        let before = d.caret_pos();
9322        d.insert("New");
9323        d.build_visual(80);
9324        let after = d.caret_pos();
9325        assert_eq!(
9326            after.0, before.0,
9327            "typing must not move the caret to another row ({before:?} -> {after:?})"
9328        );
9329    }
9330
9331    #[test]
9332    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
9333        let fm = "---\ntitle: hi\n---\n";
9334        let body = format!("{fm}# leaf\n\nbody\n");
9335        let mut d = wysiwyg_doc("wys_fm", &body);
9336        // Opening lifts the caret out of the now-hidden frontmatter.
9337        assert_eq!(
9338            d.caret,
9339            fm.len(),
9340            "caret should start at the first real block"
9341        );
9342        // Left at the content start can't step back into frontmatter.
9343        d.move_left(false);
9344        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
9345        // Doc-start lands on the content floor, not offset 0.
9346        d.move_doc_start(false);
9347        assert_eq!(d.caret, fm.len());
9348        // Select-all + copy never include the frontmatter bytes.
9349        d.select_all();
9350        let sel = d.selected_text().unwrap().to_string();
9351        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
9352        assert!(
9353            sel.starts_with("# leaf"),
9354            "selection should begin at content: {sel:?}"
9355        );
9356    }
9357
9358    #[test]
9359    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
9360        // Backspace deletes `prev_boundary..caret` directly; at the first real
9361        // block that boundary is inside the hidden frontmatter, so it must be a
9362        // no-op rather than eating the closing `---`.
9363        let fm = "---\ntitle: hi\n---\n";
9364        let body = format!("{fm}leaf\n");
9365        let mut d = wysiwyg_doc("wys_fm_bs", &body);
9366        assert_eq!(d.caret, fm.len());
9367        d.backspace();
9368        assert_eq!(d.source, body, "backspace must not touch frontmatter");
9369        d.delete_word_back();
9370        assert_eq!(
9371            d.source, body,
9372            "word-delete must not touch frontmatter either"
9373        );
9374    }
9375
9376    #[test]
9377    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
9378        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
9379        // fenced div, really, since core parses these on for every document
9380        // now (`parse_extensions`). The container is a `directive` node, an
9381        // `is_block_container` kind like `block_quote`, so the caret works
9382        // inside its child paragraph exactly as it would inside a quote: typing
9383        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
9384        // untouched.
9385        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
9386        let mut d = wysiwyg_doc("wys_vis", body);
9387        d.caret = body.find("hello").unwrap() + "hello".len();
9388        d.insert("!");
9389        assert_eq!(
9390            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
9391            "typing inside the block edits its content in place"
9392        );
9393        assert!(
9394            d.source.contains(":::vis{.public .family}"),
9395            "opening fence survives"
9396        );
9397        assert!(d.source.contains(":::\nafter"), "closing fence survives");
9398    }
9399
9400    #[test]
9401    fn source_view_still_reaches_frontmatter() {
9402        // The metadata is only *hidden*, never lost: the source view edits and
9403        // selects it in full, and it's always preserved on save.
9404        let fm = "---\ntitle: hi\n---\n";
9405        let body = format!("{fm}# leaf\n");
9406        let mut d = doc_with("src_fm", &body);
9407        d.select_all();
9408        let sel = d.selected_text().unwrap();
9409        assert!(
9410            sel.contains("title"),
9411            "source view should select everything"
9412        );
9413        d.move_doc_start(false);
9414        assert_eq!(d.caret, 0, "source view can reach offset 0");
9415    }
9416
9417    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
9418
9419    #[test]
9420    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
9421        // The border and padding between two cells all share one source offset,
9422        // so a column-stepping caret would sit on `│` and then stall there
9423        // forever. Right must step: end of "Name" -> start of "Qty".
9424        let mut d = wysiwyg_doc("tbl_right", TABLE);
9425        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
9426        d.move_right(false);
9427        assert_eq!(
9428            d.caret,
9429            TABLE.find("Qty").unwrap(),
9430            "should land in the next cell"
9431        );
9432        let (r, c) = d.caret_pos();
9433        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
9434    }
9435
9436    #[test]
9437    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
9438        let mut d = wysiwyg_doc("tbl_left", TABLE);
9439        d.caret = TABLE.find("Qty").unwrap();
9440        d.move_left(false);
9441        assert_eq!(
9442            d.caret,
9443            TABLE.find("Name").unwrap() + 4,
9444            "end of the previous cell"
9445        );
9446    }
9447
9448    #[test]
9449    fn wysiwyg_down_steps_over_a_table_rule() {
9450        // Between the header and the first body row sits a `├───┼───┤` rule.
9451        // It's drawn but holds no caret, so one Down must reach "Pear".
9452        let mut d = wysiwyg_doc("tbl_down", TABLE);
9453        d.caret = TABLE.find("Name").unwrap();
9454        d.move_down(false);
9455        assert_eq!(
9456            d.caret,
9457            TABLE.find("Pear").unwrap(),
9458            "one Down reaches the body row"
9459        );
9460        d.move_down(false);
9461        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
9462    }
9463
9464    #[test]
9465    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
9466        let mut d = wysiwyg_doc("tbl_tab", TABLE);
9467        d.caret = TABLE.find("Name").unwrap();
9468        // A hop lands with the destination cell's whole content selected, the
9469        // caret at its end — so typing replaces the cell like a form field.
9470        assert!(d.cell_hop(true));
9471        assert_eq!(
9472            d.selected_text(),
9473            Some("Qty"),
9474            "the target cell comes up selected"
9475        );
9476        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
9477        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
9478        assert_eq!(d.selected_text(), Some("Pear"));
9479        assert!(d.cell_hop(false));
9480        assert_eq!(d.selected_text(), Some("Qty"));
9481    }
9482
9483    #[test]
9484    fn tab_outside_a_table_is_not_a_cell_hop() {
9485        // `cell_hop` reports false so the frontend can indent as usual.
9486        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
9487        d.caret = 4;
9488        assert!(!d.cell_hop(true));
9489        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
9490    }
9491
9492    #[test]
9493    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
9494        let mut d = wysiwyg_doc("tbl_edge", TABLE);
9495        d.caret = TABLE.rfind("12").unwrap(); // the final cell
9496        assert!(!d.cell_hop(true), "no cell after the last one");
9497        d.caret = TABLE.find("Name").unwrap();
9498        assert!(!d.cell_hop(false), "no cell before the first one");
9499    }
9500
9501    #[test]
9502    fn wysiwyg_vertical_cell_motion_holds_the_column() {
9503        // Down/Up step to the cell above/below in the *same column*, not back to
9504        // the top-left the way a naive row/col motion over the picture would.
9505        let mut d = wysiwyg_doc("tbl_vert", TABLE);
9506        d.caret = TABLE.find("Qty").unwrap();
9507        // Each vertical hop selects the destination cell, holding the column.
9508        assert!(d.cell_move_vertical(true));
9509        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
9510        assert!(d.cell_move_vertical(true));
9511        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
9512        assert!(!d.cell_move_vertical(true), "no row below the last");
9513        assert!(d.cell_move_vertical(false));
9514        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
9515        assert!(d.cell_move_vertical(false));
9516        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
9517        assert!(!d.cell_move_vertical(false), "no row above the header");
9518    }
9519
9520    #[test]
9521    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
9522        let mut d = wysiwyg_doc("tbl_grow", TABLE);
9523        d.caret = TABLE.rfind("12").unwrap();
9524        let rows_before = d.source.matches('\n').count();
9525        assert!(d.cell_tab(true), "acts as a table key");
9526        assert_eq!(
9527            d.source.matches('\n').count(),
9528            rows_before + 1,
9529            "a fresh row was appended"
9530        );
9531        assert!(d.caret_in_table(), "the caret entered the new row");
9532        // The caret sits in the new row's first cell — past the old last cell.
9533        assert!(d.caret > TABLE.rfind("12").unwrap());
9534    }
9535
9536    #[test]
9537    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
9538        let mut d = wysiwyg_doc("tbl_ret", TABLE);
9539        d.caret = TABLE.find("Name").unwrap();
9540        assert!(d.cell_return(), "acts as a table key");
9541        assert_eq!(
9542            d.selected_text(),
9543            Some("Pear"),
9544            "Return drops one cell, selecting it"
9545        );
9546        // From the last row, Return appends a row and enters it.
9547        d.caret = TABLE.rfind("Fig").unwrap();
9548        let rows_before = d.source.matches('\n').count();
9549        assert!(d.cell_return());
9550        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
9551        assert!(d.caret_in_table());
9552    }
9553
9554    #[test]
9555    fn return_and_tab_outside_a_table_decline() {
9556        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
9557        d.caret = 4;
9558        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
9559        assert!(!d.cell_tab(true), "no table: the frontend indents");
9560        assert!(
9561            !d.cell_line_break(),
9562            "no table: the frontend breaks the line"
9563        );
9564    }
9565
9566    #[test]
9567    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
9568        let mut d = wysiwyg_doc("tbl_break", TABLE);
9569        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
9570        assert!(d.cell_line_break(), "acts as a table key");
9571        assert!(
9572            d.source.contains("Pear<br>"),
9573            "spelled as an inline <br>: {}",
9574            d.source
9575        );
9576        assert!(d.caret_in_table(), "still in the cell, past the break");
9577        // The break renders as a real line: the "Pear" cell now draws two lines,
9578        // so the table's picture is one row taller than a single-line table.
9579        d.build_visual(80);
9580        let table = &d.vmap.tables[0];
9581        let cell = &table.grid[1].cells[0]; // first body row, first column
9582        assert!(
9583            cell.glyphs.iter().any(|g| g.ch == '\n'),
9584            "the cell carries the break as a newline glyph for the frontend to split"
9585        );
9586    }
9587
9588    #[test]
9589    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
9590        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
9591        // back as structure — the whole point of routing through insert_line_break
9592        // instead of splicing raw `<br>` bytes.
9593        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
9594        d.caret = TABLE.find("Pear").unwrap() + 4;
9595        assert!(d.cell_line_break());
9596        let kinds: Vec<Kind> = d
9597            .editor
9598            .nodes()
9599            .unwrap()
9600            .iter()
9601            .map(|n| n.kind.clone())
9602            .collect();
9603        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
9604        assert!(
9605            !kinds.contains(&Kind::RawInline),
9606            "still raw HTML: {kinds:?}"
9607        );
9608    }
9609
9610    #[test]
9611    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
9612        // The `<br>` draws as one newline glyph, so Backspace over it must take
9613        // all four bytes — a one-byte delete would strand a visible `<br` in the
9614        // cell (the reported bug).
9615        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
9616        d.caret = TABLE.find("Pear").unwrap() + 4;
9617        assert!(d.cell_line_break());
9618        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
9619        d.backspace(); // caret sits just past the break
9620        assert!(
9621            !d.source.contains("<br"),
9622            "no half-deleted <br left: {}",
9623            d.source
9624        );
9625        assert!(
9626            d.source.contains("| Pear |"),
9627            "the cell is back to one line: {}",
9628            d.source
9629        );
9630    }
9631
9632    #[test]
9633    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
9634        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
9635        d.caret = TABLE.find("Pear").unwrap() + 4;
9636        assert!(d.cell_line_break());
9637        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
9638        d.delete_forward();
9639        assert!(
9640            !d.source.contains("<br"),
9641            "no half-deleted <br: {}",
9642            d.source
9643        );
9644        assert!(
9645            d.source.contains("| Pear |"),
9646            "cell back to one line: {}",
9647            d.source
9648        );
9649    }
9650
9651    #[test]
9652    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
9653        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
9654        // still consumed (a real newline would split the one-line row), but the
9655        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
9656        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
9657        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9658        d.caret = src.find("Pear").unwrap() + 4;
9659        assert!(d.caret_in_table(), "caret should be inside the djot table");
9660        assert!(
9661            d.cell_line_break(),
9662            "the key is consumed, not passed to the frontend"
9663        );
9664        assert_eq!(d.source, src, "the djot cell is left untouched");
9665        assert!(
9666            !d.source.contains("<br>"),
9667            "no non-idiomatic <br> spliced into djot"
9668        );
9669        assert!(
9670            d.status.is_some(),
9671            "the refusal is surfaced on the status line"
9672        );
9673    }
9674
9675    #[test]
9676    fn typing_in_a_cell_edits_that_cell() {
9677        // Editing comes free once offsets map correctly: the caret is a source
9678        // offset, so a normal splice lands inside the pipe table.
9679        let mut d = wysiwyg_doc("tbl_type", TABLE);
9680        d.caret = TABLE.find("Pear").unwrap() + 4;
9681        d.insert("s");
9682        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
9683    }
9684
9685    #[test]
9686    fn motion_and_delete_treat_an_emoji_as_one_character() {
9687        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
9688        // bytes, several codepoints. Right-arrow must clear it in one step, and
9689        // backspace must remove the whole cluster, not a stray joiner.
9690        let family = "👨‍👩‍👧";
9691        let mut d = doc_with("emoji", &format!("a{family}b\n"));
9692        d.caret = 1; // just after 'a', before the emoji
9693        d.move_right(false);
9694        assert_eq!(
9695            d.caret,
9696            1 + family.len(),
9697            "one step clears the whole cluster"
9698        );
9699        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
9700
9701        d.backspace(); // delete the emoji as a unit
9702        assert_eq!(d.source, "ab\n");
9703        assert_eq!(d.caret, 1);
9704    }
9705
9706    #[test]
9707    fn motion_handles_a_combining_accent_as_one_character() {
9708        // "e" + U+0301 (combining acute) renders as one é.
9709        let mut d = doc_with("combining", "e\u{0301}x\n");
9710        d.caret = 0;
9711        d.move_right(false);
9712        assert_eq!(
9713            d.caret,
9714            "e\u{0301}".len(),
9715            "steps past base + combining mark"
9716        );
9717    }
9718
9719    #[test]
9720    fn undo_then_redo_round_trips_an_edit() {
9721        let mut d = doc_with("undo", "hello\n");
9722        d.caret = 5;
9723        d.insert("!");
9724        assert_eq!(d.source, "hello!\n");
9725        d.undo();
9726        assert_eq!(d.source, "hello\n");
9727        assert_eq!(d.caret, 5, "undo restores the caret");
9728        d.redo();
9729        assert_eq!(d.source, "hello!\n");
9730    }
9731
9732    #[test]
9733    fn a_run_of_typing_undoes_as_one_step() {
9734        let mut d = doc_with("coalesce", "\n");
9735        d.caret = 0;
9736        d.insert("a");
9737        d.insert("b");
9738        d.insert("c");
9739        assert_eq!(d.source, "abc\n");
9740        d.undo(); // the whole typed run, not just "c"
9741        assert_eq!(d.source, "\n");
9742        d.undo(); // nothing left — the run was one step
9743        assert_eq!(d.source, "\n");
9744        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9745    }
9746
9747    // ── IME composition ──────────────────────────────────────────────────────
9748
9749    #[test]
9750    fn a_composition_run_undoes_as_one_step() {
9751        let mut d = doc_with("compose", "\n");
9752        d.caret = 0;
9753        // What an IME does: each step replaces the last one's provisional bytes.
9754        d.edit_composing(0, 0, "k");
9755        d.edit_composing(0, 1, "か");
9756        d.edit_composing(0, 3, "かん");
9757        d.edit_composing(0, 6, "感"); // the commit
9758        d.end_composition();
9759        assert_eq!(d.source, "感\n");
9760        d.undo(); // the whole composition, not its last keystroke
9761        assert_eq!(d.source, "\n");
9762        assert_eq!(d.status.as_deref(), None, "the run was a single step");
9763    }
9764
9765    #[test]
9766    fn two_compositions_are_two_undo_steps() {
9767        let mut d = doc_with("compose_two", "\n");
9768        d.caret = 0;
9769        d.edit_composing(0, 0, "か");
9770        d.edit_composing(0, 3, "蚊");
9771        d.end_composition();
9772        d.edit_composing(3, 3, "き");
9773        d.edit_composing(3, 6, "木");
9774        d.end_composition();
9775        assert_eq!(d.source, "蚊木\n");
9776        d.undo();
9777        assert_eq!(d.source, "蚊\n", "only the second composition");
9778        d.undo();
9779        assert_eq!(d.source, "\n");
9780    }
9781
9782    #[test]
9783    fn a_composition_does_not_fold_into_the_typing_around_it() {
9784        let mut d = doc_with("compose_typing", "\n");
9785        d.caret = 0;
9786        d.insert("a");
9787        d.insert("b");
9788        d.edit_composing(2, 2, "か");
9789        d.edit_composing(2, 5, "蚊");
9790        d.end_composition();
9791        d.insert("c");
9792        assert_eq!(d.source, "ab蚊c\n");
9793        d.undo();
9794        assert_eq!(d.source, "ab蚊\n");
9795        d.undo();
9796        assert_eq!(d.source, "ab\n");
9797        d.undo();
9798        assert_eq!(d.source, "\n");
9799    }
9800
9801    #[test]
9802    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
9803        let mut d = doc_with("compose_spurious", "\n");
9804        d.caret = 0;
9805        d.insert("a");
9806        d.end_composition(); // an IME unmarking unprompted
9807        d.insert("b");
9808        assert_eq!(d.source, "ab\n");
9809        d.undo();
9810        assert_eq!(d.source, "\n", "still one typed run");
9811    }
9812
9813    // ── the clipboard's rich flavor ──────────────────────────────────────────
9814
9815    #[test]
9816    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
9817        let mut d = doc_with("sel_inline", "a **bold** c\n");
9818        d.anchor = Some(2);
9819        d.caret = 10; // `**bold**`, inside the paragraph
9820        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
9821    }
9822
9823    #[test]
9824    fn a_whole_block_selection_keeps_its_paragraph() {
9825        let mut d = doc_with("sel_block", "a **bold** c\n");
9826        d.anchor = Some(0);
9827        d.caret = 12; // the entire paragraph
9828        assert_eq!(
9829            d.selection_html().as_deref(),
9830            Some("<p>a <strong>bold</strong> c</p>")
9831        );
9832    }
9833
9834    #[test]
9835    fn a_multi_block_selection_keeps_its_structure() {
9836        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
9837        d.select_all();
9838        let html = d.selection_html().expect("renders");
9839        assert!(html.contains("<p>para</p>"), "{html:?}");
9840        assert!(html.contains("<li>one</li>"), "{html:?}");
9841    }
9842
9843    #[test]
9844    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
9845        // The fragment `Head` is a paragraph standalone; the *document* says it
9846        // sits inside one block, so the wrapper is an artifact either way.
9847        let mut d = doc_with("sel_heading", "# Head line\n");
9848        d.anchor = Some(2);
9849        d.caret = 6;
9850        assert_eq!(d.selection_html().as_deref(), Some("Head"));
9851    }
9852
9853    #[test]
9854    fn no_selection_publishes_no_html() {
9855        let mut d = doc_with("sel_none", "a b\n");
9856        d.caret = 1;
9857        assert_eq!(d.selection_html(), None);
9858    }
9859
9860    #[test]
9861    fn pasting_html_converts_it_and_is_one_undo_step() {
9862        let mut d = doc_with("paste_html", "x\n");
9863        d.caret = 1;
9864        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
9865        assert_eq!(d.source, "xa **b** c\n");
9866        d.undo();
9867        assert_eq!(d.source, "x\n", "the whole paste, in one step");
9868    }
9869
9870    #[test]
9871    fn pasting_html_replaces_the_selection() {
9872        let mut d = doc_with("paste_html_sel", "keep drop\n");
9873        d.anchor = Some(5);
9874        d.caret = 9;
9875        assert!(d.paste_html("<em>new</em>"));
9876        assert_eq!(d.source, "keep *new*\n");
9877    }
9878
9879    #[test]
9880    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
9881        let mut d = doc_with("paste_html_bad", "x\n");
9882        d.caret = 1;
9883        // twig builds no table from HTML; raw `<table>` in prose is worse than
9884        // the plain flavor the caller still holds.
9885        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
9886        assert_eq!(d.source, "x\n", "declined edits nothing");
9887    }
9888
9889    #[test]
9890    fn copy_then_paste_round_trips_through_the_html_flavor() {
9891        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
9892        d.select_all();
9893        let html = d.selection_html().expect("renders");
9894        let mut into = doc_with("clip_round_dst", "\n");
9895        into.caret = 0;
9896        assert!(into.paste_html(&html));
9897        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
9898    }
9899
9900    #[test]
9901    fn moving_the_caret_starts_a_new_undo_group() {
9902        let mut d = doc_with("break", "\n");
9903        d.caret = 0;
9904        d.insert("a");
9905        d.insert("b"); // "ab\n", caret at 2
9906        d.move_left(false); // breaks the run
9907        d.insert("X"); // "aXb\n"
9908        assert_eq!(d.source, "aXb\n");
9909        d.undo();
9910        assert_eq!(
9911            d.source, "ab\n",
9912            "first undo removes only the post-move insert"
9913        );
9914        d.undo();
9915        assert_eq!(d.source, "\n", "second undo removes the earlier run");
9916    }
9917
9918    #[test]
9919    fn undo_reverses_a_format_toggle() {
9920        let mut d = doc_with("fmt_undo", "a word b\n");
9921        d.anchor = Some(2);
9922        d.caret = 6;
9923        d.toggle(InlineKind::Strong);
9924        assert_eq!(d.source, "a **word** b\n");
9925        d.undo();
9926        assert_eq!(d.source, "a word b\n");
9927    }
9928
9929    #[test]
9930    fn undo_back_to_the_saved_state_clears_dirty() {
9931        let mut d = doc_with("dirty_undo", "hello\n");
9932        assert!(!d.dirty);
9933        d.caret = 5;
9934        d.insert("!");
9935        assert!(d.dirty);
9936        d.undo();
9937        assert!(
9938            !d.dirty,
9939            "undoing to the saved source is not a modification"
9940        );
9941    }
9942
9943    #[test]
9944    fn a_new_edit_invalidates_redo() {
9945        let mut d = doc_with("redo_inv", "\n");
9946        d.caret = 0;
9947        d.insert("a");
9948        d.undo();
9949        d.insert("b"); // diverges — the redo of "a" is now gone
9950        d.redo();
9951        assert_eq!(d.source, "b\n");
9952    }
9953
9954    #[test]
9955    fn undo_on_empty_history_is_a_no_op() {
9956        let mut d = doc_with("undo_empty", "hi\n");
9957        d.undo();
9958        assert_eq!(d.source, "hi\n");
9959        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9960    }
9961
9962    #[test]
9963    fn a_one_character_paste_is_its_own_undo_step() {
9964        for view in [View::Source, View::Wysiwyg] {
9965            let mut d = doc_in(view, "paste_step", "ab\n");
9966            d.caret = 0;
9967            d.insert("x");
9968            d.insert("y"); // a run of typing
9969            d.paste("z"); // one character, but pasted — not part of that run
9970            assert_eq!(d.source, "xyzab\n");
9971            d.undo();
9972            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
9973            assert_eq!(d.caret, 2, "and hands back the caret it found");
9974            d.undo();
9975            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
9976        }
9977    }
9978
9979    #[test]
9980    fn the_same_character_typed_still_joins_the_run() {
9981        // The other half of the pair: `z` is a keystroke here and a paste above,
9982        // and the two undo differently. Nothing about the *string* says which —
9983        // which is why provenance has to come from the door the caller uses.
9984        for view in [View::Source, View::Wysiwyg] {
9985            let mut d = doc_in(view, "typed_run", "ab\n");
9986            d.caret = 0;
9987            d.insert("x");
9988            d.insert("y");
9989            d.insert("z");
9990            d.undo();
9991            assert_eq!(d.source, "ab\n", "one run, one step");
9992        }
9993    }
9994
9995    #[test]
9996    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
9997        for view in [View::Source, View::Wysiwyg] {
9998            let mut d = doc_in(view, "undo_caret", "hello world\n");
9999            d.caret = 11; // standing at the end of "world", away from the edit
10000            d.edit(0, 5, "goodbye");
10001            assert_eq!(d.source, "goodbye world\n");
10002            d.undo();
10003            assert_eq!(d.source, "hello world\n");
10004            // The undone edit ends at offset 5; the user was at 11.
10005            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
10006        }
10007    }
10008
10009    #[test]
10010    fn undo_restores_the_selection_the_edit_replaced() {
10011        for view in [View::Source, View::Wysiwyg] {
10012            let mut d = doc_in(view, "undo_sel", "a word b\n");
10013            d.anchor = Some(2);
10014            d.caret = 6; // "word" selected
10015            d.insert("X");
10016            assert_eq!(d.source, "a X b\n");
10017            d.undo();
10018            assert_eq!(d.source, "a word b\n");
10019            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
10020        }
10021    }
10022
10023    #[test]
10024    fn redo_restores_the_caret_the_edit_left_behind() {
10025        for view in [View::Source, View::Wysiwyg] {
10026            let mut d = doc_in(view, "redo_caret", "hello world\n");
10027            d.caret = 11;
10028            d.edit(0, 5, "goodbye");
10029            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
10030            d.undo();
10031            d.redo();
10032            assert_eq!(d.source, "goodbye world\n");
10033            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
10034        }
10035    }
10036
10037    #[test]
10038    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
10039        for view in [View::Source, View::Wysiwyg] {
10040            let mut d = doc_in(view, "run_caret", "hi\n");
10041            d.caret = 2;
10042            d.insert("a");
10043            d.insert("b");
10044            d.insert("c");
10045            assert_eq!(d.source, "hiabc\n");
10046            d.undo();
10047            assert_eq!(d.source, "hi\n");
10048            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
10049            d.redo();
10050            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
10051        }
10052    }
10053
10054    #[test]
10055    fn undo_restores_the_caret_across_a_format_toggle() {
10056        // A toggle reaches twig without going through `splice`, so it has to
10057        // record its own step — miss it and every stack depth below it is off by
10058        // one, and undo starts handing back another edit's caret.
10059        for view in [View::Source, View::Wysiwyg] {
10060            let mut d = doc_in(view, "fmt_caret", "a word b\n");
10061            d.caret = 8;
10062            d.anchor = Some(2);
10063            d.caret = 6;
10064            d.toggle(InlineKind::Strong);
10065            assert_eq!(d.source, "a **word** b\n");
10066            d.undo();
10067            assert_eq!(d.source, "a word b\n");
10068            assert_eq!(
10069                d.selection(),
10070                Some((2, 6)),
10071                "the toggled selection comes back"
10072            );
10073        }
10074    }
10075
10076    #[test]
10077    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
10078        // The drift that would never announce itself: twig drops its redo stack
10079        // on any fresh edit, so a leaf redo entry that outlives it would restore
10080        // a caret from the timeline that edit abandoned.
10081        for view in [View::Source, View::Wysiwyg] {
10082            let mut d = doc_in(view, "redo_trunc", "hello world\n");
10083            d.caret = 11;
10084            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
10085            d.undo();
10086            assert_eq!(d.caret, 11);
10087            d.caret = 0;
10088            d.insert("X"); // diverges: A's redo is gone from twig
10089            assert_eq!(d.source, "Xhello world\n");
10090
10091            d.redo();
10092            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
10093            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
10094            d.undo();
10095            assert_eq!(d.source, "hello world\n");
10096            assert_eq!(
10097                d.caret, 0,
10098                "the surviving step's caret, not the dropped one"
10099            );
10100        }
10101    }
10102
10103    #[test]
10104    fn indent_and_outdent_move_the_caret_line_with_its_text() {
10105        for view in [View::Source, View::Wysiwyg] {
10106            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
10107            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
10108            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
10109            // Indentation the caret is standing *in* collapses to the line start
10110            // rather than dragging the caret into the text.
10111            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
10112            // A line with none to give back is left exactly as it was.
10113            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
10114            // Less than a full level gives back what it has.
10115            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
10116            // A tab is one level however many spaces it isn't.
10117            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
10118        }
10119    }
10120
10121    #[test]
10122    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
10123        // Why the level is two spaces and not the four both frontends type
10124        // today. Four is markdown's indented-code-block marker, so a Tab on a
10125        // paragraph would silently restyle it as code — a width that changes
10126        // what the document *means* isn't an indent. Pinned because the number
10127        // is the kind of thing a later list-aware pass would reach for.
10128        let mut d = doc_with("indent_kind", "hello\n");
10129        d.caret = 2;
10130        d.indent();
10131        assert_eq!(d.source, "  hello\n");
10132        assert!(
10133            d.nodes().iter().any(|n| n.kind == Kind::Para),
10134            "still prose after a Tab"
10135        );
10136        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
10137
10138        // The four-space level this replaces, for contrast: same text, and twig
10139        // reparses the paragraph into a code block.
10140        let mut wide = doc_with("indent_kind_4", "    hello\n");
10141        wide.build_visual(80);
10142        assert!(
10143            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
10144            "four spaces is a code block, not an indented paragraph"
10145        );
10146    }
10147
10148    #[test]
10149    fn indent_nests_a_list_item_under_its_parent() {
10150        // Tab indents a list item by its own marker width, landing its marker at
10151        // the parent's content column so twig reparses it as a nested list.
10152        for view in [View::Source, View::Wysiwyg] {
10153            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
10154            d.caret = 6; // on the second item
10155            d.indent();
10156            assert_eq!(d.source, "- a\n  - b\n");
10157            let lists = d
10158                .nodes()
10159                .iter()
10160                .filter(|n| n.kind == Kind::BulletList)
10161                .count();
10162            assert_eq!(lists, 2, "the indented item is a nested list");
10163        }
10164    }
10165
10166    #[test]
10167    fn indent_nests_an_ordered_item_at_its_marker_width() {
10168        // An ordered marker `1. ` is three columns wide, so a two-space step
10169        // (which nests a bullet) leaves it flat. Regression: Tab must use the
10170        // marker width, three, so the item actually nests — and the source
10171        // renumbers so the sub-list restarts at 1 and the outer list resumes.
10172        for view in [View::Source, View::Wysiwyg] {
10173            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
10174            d.caret = d.source.find('b').unwrap();
10175            d.indent();
10176            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
10177            let lists = d
10178                .nodes()
10179                .iter()
10180                .filter(|n| n.kind == Kind::OrderedList)
10181                .count();
10182            assert_eq!(lists, 2, "the indented item is a nested ordered list");
10183        }
10184    }
10185
10186    #[test]
10187    fn indent_leaves_a_lists_first_item_put() {
10188        // The first item of a list has no sibling above it to nest under, so Tab
10189        // is a no-op there — the marker stays at column zero rather than being
10190        // shoved into indentation twig can't read as a sub-list.
10191        for view in [View::Source, View::Wysiwyg] {
10192            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
10193            d.caret = 1; // on the FIRST item
10194            d.indent();
10195            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
10196            // The sibling below still nests, proving the guard is per-item.
10197            d.caret = d.source.find('b').unwrap();
10198            d.indent();
10199            assert_eq!(d.source, "- a\n  - b\n");
10200        }
10201    }
10202
10203    #[test]
10204    fn hidden_mode_keeps_typed_markup_literal() {
10205        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
10206        // twig escapes what would open markup, so the source is `\*hi\*` and the
10207        // AST is a plain string. Formatting is the commands' job in this mode.
10208        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
10209        d.insert("*hi*");
10210        assert_eq!(d.source, "\\*hi\\*");
10211        assert!(
10212            d.nodes()
10213                .iter()
10214                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
10215        );
10216    }
10217
10218    #[test]
10219    fn hidden_mode_escapes_a_line_start_block_marker() {
10220        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
10221        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
10222        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
10223        d.insert("# hi");
10224        assert_eq!(d.source, "\\# hi");
10225        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
10226    }
10227
10228    #[test]
10229    fn authoring_modes_keep_typed_markup_live() {
10230        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
10231        // (no escape), the same as source view — escaping is `None`'s alone, and
10232        // it's the axis, not the reveal, that decides.
10233        for (view, mode) in [
10234            (View::Wysiwyg, MarkupMode::Shortcuts),
10235            (View::Wysiwyg, MarkupMode::Full),
10236            (View::Source, MarkupMode::None),
10237        ] {
10238            let mut d = doc_in(view, "live_markup", "");
10239            d.set_markup_mode(mode);
10240            d.insert("*hi*");
10241            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
10242        }
10243    }
10244
10245    #[test]
10246    fn hidden_mode_overwrite_undoes_in_one_step() {
10247        // Typing over a selection escapes the replacement *and* stays a single
10248        // undo — the selection-delete and the literal insert fold together, so
10249        // one undo brings the whole selection back, like a plain overwrite.
10250        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
10251        d.anchor = Some(2);
10252        d.caret = 6; // "word"
10253        d.insert("*");
10254        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
10255        d.undo();
10256        assert_eq!(d.source, "a word b\n");
10257        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
10258    }
10259
10260    #[test]
10261    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
10262        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
10263        // the whole visual character, never stranding the hidden `\`.
10264        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
10265        d.insert("*");
10266        assert_eq!(d.source, "\\*");
10267        d.backspace();
10268        assert_eq!(d.source, "", "the escape backslash went with the *");
10269        // A *literal* backslash (source view, no escape) is an ordinary char.
10270        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
10271        s.caret = 3; // after `b`
10272        s.backspace();
10273        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
10274    }
10275
10276    #[test]
10277    fn hidden_mode_leaves_structural_markup_alone() {
10278        // Enter continues a bullet list by writing a real `- ` marker (an
10279        // `insert_raw`, not the typing path), so Hidden mode's escaping never
10280        // touches it — the list keeps working.
10281        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
10282        d.caret = 6;
10283        d.newline();
10284        d.insert("two");
10285        assert_eq!(d.source, "- item\n- two\n");
10286    }
10287
10288    #[test]
10289    fn markup_mode_defaults_to_none_and_round_trips() {
10290        // Diaryx's default is the clean `None` surface; a markup-fluent
10291        // frontend can climb the ladder, and the choice sticks.
10292        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
10293        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
10294        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
10295            d.set_markup_mode(mode);
10296            assert_eq!(d.markup_mode(), mode);
10297        }
10298    }
10299
10300    #[test]
10301    fn full_mode_reveals_only_the_caret_line() {
10302        // The mode's whole claim: the caret's line shows its raw delimiters and
10303        // every other line stays resolved. Two paragraphs with identical markup
10304        // so the only difference between the rows is where the caret is.
10305        let mut d = doc_in(
10306            View::Wysiwyg,
10307            "reveal_caret_line",
10308            "*one* here\n\n*two* there\n",
10309        );
10310        d.set_markup_mode(MarkupMode::Full);
10311
10312        caret_at(&mut d, "one");
10313        let rows = drawn_rows(&d);
10314        assert!(
10315            rows.iter().any(|r| r == "*one* here"),
10316            "caret's line raw: {rows:?}"
10317        );
10318        assert!(
10319            rows.iter().any(|r| r == "two there"),
10320            "other line resolved: {rows:?}"
10321        );
10322
10323        // Move to the other paragraph: the reveal follows, and the line just
10324        // left goes back to being resolved.
10325        caret_at(&mut d, "two");
10326        let rows = drawn_rows(&d);
10327        assert!(
10328            rows.iter().any(|r| r == "*two* there"),
10329            "caret's line raw: {rows:?}"
10330        );
10331        assert!(
10332            rows.iter().any(|r| r == "one here"),
10333            "left line resolved: {rows:?}"
10334        );
10335    }
10336
10337    #[test]
10338    fn hidden_modes_never_reveal_wherever_the_caret_is() {
10339        // The two rungs below `Full` share a rendering: delimiters stay hidden
10340        // even under the caret. `Shortcuts` differing from `None` only in what
10341        // typing does is exactly the point of splitting the axes.
10342        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
10343            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
10344            d.set_markup_mode(mode);
10345            caret_at(&mut d, "one");
10346            let rows = drawn_rows(&d);
10347            assert!(
10348                rows.iter().any(|r| r == "one here"),
10349                "{mode:?} hides: {rows:?}"
10350            );
10351            assert!(
10352                !rows.iter().any(|r| r.contains('*')),
10353                "{mode:?} shows no `*`: {rows:?}"
10354            );
10355        }
10356    }
10357
10358    #[test]
10359    fn revealed_delimiters_are_the_authors_own_spelling() {
10360        // Delimiters are re-read from the source rather than synthesized per
10361        // kind, so a line comes back spelled the way it was written: `_em_` does
10362        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
10363        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
10364        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
10365        d.set_markup_mode(MarkupMode::Full);
10366        caret_at(&mut d, "em");
10367        let rows = drawn_rows(&d);
10368        assert!(
10369            rows.iter().any(|r| r == body.trim_end()),
10370            "the revealed line is its own source: {rows:?}"
10371        );
10372    }
10373
10374    #[test]
10375    fn revealed_heading_shows_its_hashes() {
10376        // The `# ` marker is a block-level prefix, not an inline delimiter, so
10377        // it takes its own path — but it reveals on the same rule.
10378        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
10379        d.set_markup_mode(MarkupMode::Full);
10380
10381        caret_at(&mut d, "Title");
10382        assert!(
10383            drawn_rows(&d).iter().any(|r| r == "# Title"),
10384            "{:?}",
10385            drawn_rows(&d)
10386        );
10387
10388        caret_at(&mut d, "body");
10389        let rows = drawn_rows(&d);
10390        assert!(
10391            rows.iter().any(|r| r == "Title"),
10392            "hashes hidden again: {rows:?}"
10393        );
10394    }
10395
10396    #[test]
10397    fn revealed_delimiters_are_caret_stops() {
10398        // A delimiter that is drawn but can't be reached is worse than one
10399        // that's hidden: the mode exists so the markup can be *edited*. Every
10400        // revealed byte must be somewhere the caret can stand.
10401        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
10402        d.set_markup_mode(MarkupMode::Full);
10403        caret_at(&mut d, "em");
10404        let opener = d.source.find('*').unwrap();
10405        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
10406        assert!(
10407            d.vmap.is_stop(opener + 3),
10408            "the closing `*` is a caret stop"
10409        );
10410    }
10411
10412    #[test]
10413    fn setext_heading_reveals_nothing_across_its_newline() {
10414        // A setext heading's underline is on another line, so it is not the
10415        // caret line's to reveal — and emitting it would inject a `\n` glyph
10416        // that splits the row where the author wrote no break.
10417        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
10418        d.set_markup_mode(MarkupMode::Full);
10419        caret_at(&mut d, "Title");
10420        let rows = drawn_rows(&d);
10421        assert!(
10422            rows.iter().any(|r| r == "Title"),
10423            "title renders alone: {rows:?}"
10424        );
10425        assert!(
10426            !rows.iter().any(|r| r.contains('=')),
10427            "no underline leaks in: {rows:?}"
10428        );
10429    }
10430
10431    #[test]
10432    fn markup_mode_axes_split_the_ladder() {
10433        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
10434        // that authors markup but still hides it, and it's the only rung where
10435        // the two axes disagree.
10436        assert!(!MarkupMode::None.authors());
10437        assert!(!MarkupMode::None.reveals_caret_line());
10438        assert!(MarkupMode::Shortcuts.authors());
10439        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
10440        assert!(MarkupMode::Full.authors());
10441        assert!(MarkupMode::Full.reveals_caret_line());
10442    }
10443
10444    #[test]
10445    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
10446        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
10447        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
10448        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
10449        // nested bullet and `hello` stays prose: the file round-trips instead of
10450        // hiding a heading the user never asked for.
10451        for view in [View::Source, View::Wysiwyg] {
10452            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
10453            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
10454            d.indent();
10455            assert_eq!(d.source, "- hello\n  * \n");
10456            assert!(
10457                d.nodes().iter().all(|n| n.kind != Kind::Heading),
10458                "no heading"
10459            );
10460            // And it's genuinely a nested list, not a flat one.
10461            assert_eq!(
10462                d.nodes()
10463                    .iter()
10464                    .filter(|n| n.kind == Kind::BulletList)
10465                    .count(),
10466                2
10467            );
10468        }
10469    }
10470
10471    #[test]
10472    fn indenting_a_dash_item_with_content_keeps_its_dash() {
10473        // With content, `- x` can't be a setext underline, so there's nothing to
10474        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
10475        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
10476        d.caret = d.source.find('x').unwrap();
10477        d.indent();
10478        assert_eq!(d.source, "- hello\n  - x\n");
10479    }
10480
10481    #[test]
10482    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
10483        // The dash→`*` repair coalesces into the Tab, so a single undo restores
10484        // the whole pre-Tab state rather than stranding a half-collapsed doc.
10485        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
10486        d.caret = d.source.find("- \n").unwrap() + 2;
10487        d.indent();
10488        assert_eq!(d.source, "- hello\n  * \n");
10489        d.undo();
10490        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
10491    }
10492
10493    #[test]
10494    fn indent_leaves_a_nested_lists_first_item_put_too() {
10495        // The guard is about siblings, not depth: the first item of an *inner*
10496        // list (already nested under `a`) still has nothing before it at its own
10497        // level, so Tab can't take it deeper.
10498        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
10499        d.caret = d.source.find('b').unwrap();
10500        d.indent();
10501        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
10502        // But `c` (a sibling of `b`) nests under `b`.
10503        d.caret = d.source.find('c').unwrap();
10504        d.indent();
10505        assert_eq!(d.source, "- a\n  - b\n    - c\n");
10506    }
10507
10508    #[test]
10509    fn backspace_at_a_nested_item_start_outdents_it() {
10510        // Backspace with the caret right after a nested item's marker gives back
10511        // one level of nesting, the mirror of Tab — and renumbers the flattened
10512        // ordered list back to a clean run.
10513        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
10514        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
10515        d.backspace();
10516        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10517    }
10518
10519    #[test]
10520    fn backspace_at_a_top_level_item_start_strips_the_marker() {
10521        // At the outermost level there's no nesting left to give back, so the same
10522        // keystroke drops the bullet and leaves a plain paragraph.
10523        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
10524        d.caret = d.source.find('b').unwrap(); // right after `- `
10525        d.backspace();
10526        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
10527    }
10528
10529    #[test]
10530    fn backspace_mid_item_still_deletes_a_character() {
10531        // The list behaviour is armed only at the item's content start; anywhere
10532        // else Backspace is the ordinary character delete.
10533        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
10534        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
10535        d.backspace();
10536        assert_eq!(d.source, "- b\n");
10537    }
10538
10539    #[test]
10540    fn backspace_at_a_heading_start_strips_the_marker() {
10541        // The `# ` is markup the rich view hides, so Backspace over it takes the
10542        // whole marker and leaves a paragraph. Deleting a byte of it instead left
10543        // `#Title` — no longer a heading, with the hash now literal text the user
10544        // never typed and has to delete again.
10545        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
10546        d.caret = d.source.find('T').unwrap(); // right after `## `
10547        d.backspace();
10548        assert_eq!(d.source, "Title\n");
10549        assert_eq!(
10550            d.caret, 0,
10551            "the caret stays with the text it was in front of"
10552        );
10553    }
10554
10555    #[test]
10556    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
10557        // Only the heading's own marker goes — the quote (or list) it sits in is
10558        // untouched, exactly as un-heading it should be.
10559        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
10560        d.caret = d.source.find('T').unwrap();
10561        d.backspace();
10562        assert_eq!(d.source, "> Title\n");
10563    }
10564
10565    #[test]
10566    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
10567        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
10568        // behind would surface the same stray hash the marker delete just avoided.
10569        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
10570        d.caret = d.source.find('T').unwrap();
10571        d.backspace();
10572        assert_eq!(d.source, "Title\n");
10573        // And it's one edit: a single undo puts the whole heading back.
10574        d.undo();
10575        assert_eq!(d.source, "# Title #\n");
10576    }
10577
10578    #[test]
10579    fn backspace_mid_heading_still_deletes_a_character() {
10580        // The heading behaviour is armed only at the content's start; anywhere
10581        // else Backspace is the ordinary character delete.
10582        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
10583        d.caret = d.source.find('b').unwrap();
10584        d.backspace();
10585        assert_eq!(d.source, "# b\n");
10586    }
10587
10588    #[test]
10589    fn source_view_backspace_still_edits_the_heading_marker_literally() {
10590        // In source view the `# ` is text on the screen the user is deleting a
10591        // byte of, so it keeps its literal meaning — the same split the list
10592        // ladder and Enter draw between the two views.
10593        let mut d = doc_with("bsp_head_src", "# Title\n");
10594        d.caret = d.source.find('T').unwrap();
10595        d.backspace();
10596        assert_eq!(d.source, "#Title\n");
10597    }
10598
10599    #[test]
10600    fn outdent_unnests_an_ordered_item_in_one_press() {
10601        // Shift+Tab gives back exactly the marker width the indent added, so a
10602        // nested ordered item unnests in a single press, and the flattened list
10603        // renumbers back to a clean 1, 2, 3.
10604        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
10605        d.caret = d.source.find('b').unwrap();
10606        d.outdent();
10607        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10608        let lists = d
10609            .nodes()
10610            .iter()
10611            .filter(|n| n.kind == Kind::OrderedList)
10612            .count();
10613        assert_eq!(lists, 1, "back to one flat list");
10614    }
10615
10616    #[test]
10617    fn table_insert_row_adds_a_row_below_the_caret() {
10618        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10619        d.caret = d.source.find('1').unwrap(); // in the body row
10620        d.table_insert_row(true);
10621        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
10622    }
10623
10624    #[test]
10625    fn table_insert_and_delete_column_at_the_caret() {
10626        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10627        d.caret = d.source.find('a').unwrap(); // column 0
10628        d.table_insert_column(true); // add a column to the right of `a`
10629        assert_eq!(
10630            d.source,
10631            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
10632        );
10633        d.caret = d.source.find('b').unwrap(); // now the third column
10634        d.table_delete_column();
10635        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
10636    }
10637
10638    // ── ragged formats ───────────────────────────────────────────────────────
10639    // No format spells every gesture. HTML writes the inline marks as a tag pair
10640    // and no heading, list, quote or link; Markdown spells three of the eight
10641    // marks; djot spells all eight and no in-cell break. leaf asks twig per
10642    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
10643    // op discover the fact on its own — one of them didn't.
10644
10645    /// An HTML document in the rich view, ready for a gesture.
10646    fn html_doc(body: &str) -> Doc {
10647        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
10648        d.view = View::Wysiwyg;
10649        d.build_visual(80);
10650        d
10651    }
10652
10653    #[test]
10654    fn a_table_gesture_leaves_an_html_table_alone() {
10655        // The regression this guard exists for. twig's table editor consults no
10656        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
10657        // HTML `<table>` as a *pipe table* and reported success: the whole
10658        // element replaced by `| a | b |`, silently, on one press of a toolbar
10659        // button. Every grid op went the same way.
10660        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
10661        // A table of named operations, which is what it looks like.
10662        #[allow(clippy::type_complexity)]
10663        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
10664            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
10665            ("delete row", &|d: &mut Doc| d.table_delete_row()),
10666            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
10667            ("delete column", &|d: &mut Doc| d.table_delete_column()),
10668            ("align", &|d: &mut Doc| {
10669                d.table_set_alignment(Alignment::Right)
10670            }),
10671            ("move row", &|d: &mut Doc| d.table_move_row(true)),
10672            ("move column", &|d: &mut Doc| d.table_move_column(true)),
10673        ];
10674        for (name, op) in ops {
10675            let mut d = html_doc(src);
10676            d.caret = d.source.find('a').unwrap();
10677            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
10678            op(&mut d);
10679            assert_eq!(d.source, src, "{name} rewrote an HTML table");
10680            assert!(
10681                !d.dirty,
10682                "{name} marked the document dirty without editing it"
10683            );
10684            assert!(d.status.is_some(), "{name} refused without saying why");
10685        }
10686    }
10687
10688    #[test]
10689    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
10690        // A heading is a wrapping tag pair carrying its level in both ends, a
10691        // quote wraps a range rather than prefixing each line, a link's
10692        // destination lives in an attribute — different *shapes*, not a
10693        // different alphabet, so twig spells none of them and neither does leaf.
10694        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
10695        // A table of named operations, which is what it looks like.
10696        #[allow(clippy::type_complexity)]
10697        let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
10698            ("heading", &|d: &mut Doc| d.toggle_heading(2)),
10699            ("paragraph", &|d: &mut Doc| {
10700                d.set_block(BlockKind::Paragraph)
10701            }),
10702            ("quote", &|d: &mut Doc| d.toggle_blockquote()),
10703            ("list", &|d: &mut Doc| d.toggle_list(false)),
10704            ("task item", &|d: &mut Doc| d.toggle_task_item()),
10705            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
10706            ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
10707            ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
10708            ("video", &|d: &mut Doc| {
10709                d.insert_media(MediaKind::Video, "clip.mp4", "")
10710            }),
10711        ];
10712        for (name, op) in ops {
10713            let mut d = html_doc(src);
10714            let at = d.source.find("Hello").unwrap();
10715            d.caret = at;
10716            d.anchor = Some(at + 5); // a selection, for the ops that want one
10717            op(&mut d);
10718            assert_eq!(d.source, src, "{name} edited an HTML document");
10719            assert!(
10720                !d.dirty,
10721                "{name} marked the document dirty without editing it"
10722            );
10723            let status = d.status.as_deref().unwrap_or("");
10724            assert!(
10725                status.contains("html"),
10726                "{name}: the refusal should name the format, got {status:?}"
10727            );
10728        }
10729    }
10730
10731    #[test]
10732    fn html_spells_the_inline_marks_and_the_rule() {
10733        // The other half, and why one per-document flag stopped being enough:
10734        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
10735        // already emits and the parser reads straight back as the same mark —
10736        // and the rule button writes an `<hr>`. Refusing these on the old
10737        // "HTML is parse-only" reading would now be leaf's own limitation.
10738        let mut d = html_doc("<p>Hello world</p>\n");
10739        let at = d.source.find("world").unwrap();
10740        d.caret = at;
10741        d.anchor = Some(at + 5);
10742        d.toggle(InlineKind::Strong);
10743        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
10744        assert!(d.dirty);
10745        assert_eq!(d.status, None, "a supported gesture reports nothing");
10746
10747        // And off again — the toggle reverses, which is the property that makes
10748        // authoring in HTML worth offering rather than a one-way trip.
10749        d.toggle(InlineKind::Strong);
10750        assert_eq!(d.source, "<p>Hello world</p>\n");
10751
10752        let mut d = html_doc("<p>Hello world</p>\n");
10753        d.caret = d.source.find("world").unwrap();
10754        d.insert_thematic_break();
10755        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
10756    }
10757
10758    #[test]
10759    fn a_mark_the_format_cannot_spell_arms_nothing() {
10760        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
10761        // sticky mark for the next text typed. Guarding only the twig call
10762        // leaves that path live, promising a highlight Markdown will never spell
10763        // and then swallowing the error inside `insert`. Markdown carries the
10764        // case now that HTML spells `<mark>`: `==mark==` is djot's alone.
10765        let mut d = doc_with("mark", "Hello world\n");
10766        d.view = View::Wysiwyg;
10767        d.build_visual(80);
10768        d.caret = d.source.find("world").unwrap();
10769        d.toggle(InlineKind::Mark);
10770        assert!(d.pending_marks.is_empty(), "no mark should be armed");
10771        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
10772        d.insert("X");
10773        assert_eq!(d.source, "Hello Xworld\n");
10774    }
10775
10776    #[test]
10777    fn html_documents_still_take_typed_text() {
10778        // The guard covers *markup* gestures and must not touch plain editing:
10779        // twig's splicer is language-neutral, and typing into an HTML document
10780        // is the thing that does work today.
10781        let mut d = html_doc("<p>Hello world</p>\n");
10782        d.caret = d.source.find("world").unwrap();
10783        d.insert("big ");
10784        assert_eq!(d.source, "<p>Hello big world</p>\n");
10785        assert!(d.dirty);
10786        d.backspace();
10787        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
10788        d.undo();
10789        d.undo();
10790        assert_eq!(d.source, "<p>Hello world</p>\n");
10791    }
10792
10793    #[test]
10794    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
10795        // `authorable` only separates "there is a door in" from "there is not",
10796        // and HTML is on the near side of that line — which is exactly why a
10797        // toolbar must not be built from it.
10798        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
10799        assert!(html.authorable());
10800        assert!(
10801            !Doc::from_source("<r>x</r>".into(), Format::Xml)
10802                .unwrap()
10803                .authorable()
10804        );
10805
10806        let caps = html.capabilities();
10807        assert!(caps.bold && caps.italic && caps.code && caps.mark);
10808        assert!(caps.thematic_break && caps.cell_line_break);
10809        assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
10810        assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
10811        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
10812        // twig's table editor would happily re-emit as `| a | b |`.
10813        assert!(!caps.table);
10814
10815        // The two lightweight formats spell everything leaf offers — and still
10816        // differ from each other, which is the other half of why one boolean
10817        // can't serve.
10818        for fmt in [Format::Markdown, Format::Djot] {
10819            let caps = Capabilities::of(fmt);
10820            assert!(
10821                caps.heading && caps.blockquote && caps.ordered_list,
10822                "{fmt:?}"
10823            );
10824            assert!(
10825                caps.task && caps.link && caps.image && caps.table,
10826                "{fmt:?}"
10827            );
10828        }
10829        assert!(Capabilities::of(Format::Djot).mark);
10830        assert!(!Capabilities::of(Format::Markdown).mark);
10831        assert!(Capabilities::of(Format::Markdown).cell_line_break);
10832        assert!(!Capabilities::of(Format::Djot).cell_line_break);
10833
10834        // A parse-only format answers no to every one of them, so the coarse
10835        // predicate and the record agree there.
10836        let caps = Capabilities::of(Format::Xml);
10837        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
10838    }
10839
10840    #[test]
10841    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
10842        // The guard exists to name the *document's* format rather than twig's
10843        // internals, so the message has to survive being one leaf writes itself.
10844        // Checked against the gesture twig also refuses, since that is the pair
10845        // most at risk of drifting apart.
10846        let mut d = html_doc("<p>Hello</p>\n");
10847        d.caret = d.source.find("Hello").unwrap();
10848        d.set_code_language("zig");
10849        assert_eq!(
10850            d.status.as_deref(),
10851            Some("code language: not supported in html")
10852        );
10853        assert!(!d.dirty);
10854    }
10855
10856    #[test]
10857    fn table_set_alignment_respells_the_delimiter() {
10858        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10859        d.caret = d.source.find('b').unwrap();
10860        d.table_set_alignment(Alignment::Right);
10861        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
10862    }
10863
10864    #[test]
10865    fn each_empty_table_cell_has_its_own_editable_home() {
10866        // Regression: an empty cell has no twig content_span, so both cells of a
10867        // `|  |  |` row collapsed onto the row's start (before the first `│`).
10868        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
10869        // tell the cells apart. Each empty cell must now have a distinct home
10870        // inside it.
10871        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
10872        let (c0, c1) = {
10873            let cells = &d.vmap.tables[0].grid[1].cells;
10874            (cells[0].start, cells[1].start)
10875        };
10876        assert!(
10877            c0 < c1,
10878            "the two empty cells have distinct homes: {c0} < {c1}"
10879        );
10880        d.caret = c0;
10881        d.insert("x");
10882        assert_eq!(
10883            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
10884            "typed inside the cell"
10885        );
10886    }
10887
10888    #[test]
10889    fn arrows_step_into_each_empty_table_cell() {
10890        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
10891        let (c0, c1) = {
10892            let cells = &d.vmap.tables[0].grid[1].cells;
10893            (cells[0].start, cells[1].start)
10894        };
10895        d.caret = d.source.find('b').unwrap(); // in the header's second cell
10896        let mut seen = std::collections::HashSet::new();
10897        for _ in 0..6 {
10898            d.move_right(false);
10899            seen.insert(d.caret);
10900        }
10901        assert!(
10902            seen.contains(&c0),
10903            "right arrow reaches the first empty cell"
10904        );
10905        assert!(
10906            seen.contains(&c1),
10907            "right arrow reaches the second empty cell"
10908        );
10909    }
10910
10911    #[test]
10912    fn table_op_off_a_table_is_a_no_op_with_a_status() {
10913        let mut d = doc_with("tbl_none", "just text\n");
10914        d.caret = 3;
10915        d.table_insert_row(true);
10916        assert_eq!(d.source, "just text\n", "nothing changed");
10917        assert!(d.status.is_some(), "a status explains why");
10918        assert!(!d.caret_in_table());
10919    }
10920
10921    #[test]
10922    fn enter_in_an_ordered_list_renumbers_the_following_items() {
10923        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
10924        // the renumber pass keeps them sequential, matching what the view draws.
10925        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
10926        d.caret = d.source.find('a').unwrap() + 1; // end of item a
10927        d.newline();
10928        d.insert("x");
10929        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
10930    }
10931
10932    #[test]
10933    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
10934        for view in [View::Source, View::Wysiwyg] {
10935            let mut d = doc_in(view, "outdent_noop", "hello\n");
10936            d.caret = 2;
10937            d.outdent();
10938            assert_eq!(d.source, "hello\n");
10939            assert!(!d.dirty, "a no-op is not a modification");
10940            d.undo();
10941            assert_eq!(
10942                d.status.as_deref(),
10943                Some("nothing to undo"),
10944                "spends no undo step"
10945            );
10946            assert_eq!(d.source, "hello\n");
10947        }
10948    }
10949
10950    #[test]
10951    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
10952        for view in [View::Source, View::Wysiwyg] {
10953            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
10954            d.anchor = Some(0);
10955            d.caret = 7; // through "two"
10956            d.indent();
10957            assert_eq!(
10958                d.source, "  one\n\n  two\n",
10959                "the blank line keeps no trailing pad"
10960            );
10961            // Selected, so a second Tab lands on the same lines rather than on
10962            // whatever the shifted offsets now cover.
10963            assert_eq!(d.selection(), Some((0, 12)));
10964            d.indent();
10965            assert_eq!(d.source, "    one\n\n    two\n");
10966        }
10967    }
10968
10969    #[test]
10970    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
10971        for view in [View::Source, View::Wysiwyg] {
10972            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
10973            d.anchor = Some(0);
10974            d.caret = 15;
10975            d.outdent();
10976            assert_eq!(d.source, "two\none\nnone\n");
10977        }
10978    }
10979
10980    #[test]
10981    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
10982        for view in [View::Source, View::Wysiwyg] {
10983            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
10984            d.anchor = Some(0);
10985            d.caret = 7;
10986            d.indent();
10987            assert_eq!(d.source, "  one\n\n  two\n");
10988            d.undo();
10989            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
10990            assert_eq!(
10991                d.selection(),
10992                Some((0, 7)),
10993                "with the selection it was aimed at"
10994            );
10995            d.redo();
10996            assert_eq!(d.source, "  one\n\n  two\n");
10997            assert_eq!(
10998                d.selection(),
10999                Some((0, 12)),
11000                "redo replays the caret the indent placed, not the one splice left"
11001            );
11002        }
11003    }
11004
11005    #[test]
11006    fn vertical_motion_keeps_the_column() {
11007        let mut d = doc_with("move", "abcd\nef\n");
11008        d.caret = 3; // "abc|d" on row 0, col 3
11009        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
11010        assert_eq!(d.caret, 7); // just after "ef"
11011    }
11012
11013    // ── goal column ──────────────────────────────────────────────────────────
11014
11015    #[test]
11016    fn vertical_motion_goal_column_survives_a_short_line() {
11017        // Regression: re-deriving the column from the clamped position on
11018        // every step permanently forgets it once a short line clamps it.
11019        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
11020        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
11021        assert_eq!(
11022            g("abcd|ef\nxy\nghijkl\n", |d| {
11023                d.move_down(false); // clamps to end of "xy"
11024                d.move_down(false); // restores col 4 on the long line
11025            }),
11026            "abcdef\nxy\nghij|kl\n"
11027        );
11028    }
11029
11030    #[test]
11031    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
11032        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
11033        assert_eq!(d.goal_col, None);
11034        d.caret = 4; // row 0, col 4
11035        d.move_down(false); // clamps into "xy"; goal stays the original col
11036        assert_eq!(d.goal_col, Some(4));
11037        assert_eq!(d.caret_pos(), (1, 2));
11038
11039        // A horizontal motion drops the goal column...
11040        d.move_left(false);
11041        assert_eq!(d.goal_col, None);
11042
11043        // ...so the next vertical motion picks up the *new* column (1), not
11044        // the stale one (4).
11045        d.move_down(false);
11046        assert_eq!(d.goal_col, Some(1));
11047        assert_eq!(d.caret_pos(), (2, 1));
11048    }
11049
11050    #[test]
11051    fn editing_clears_the_goal_column() {
11052        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
11053        d.caret = 4;
11054        d.move_down(false);
11055        assert_eq!(d.goal_col, Some(4));
11056        d.insert("Z");
11057        assert_eq!(d.goal_col, None);
11058    }
11059
11060    #[test]
11061    fn vertical_motion_on_an_empty_document_is_a_no_op() {
11062        let mut d = doc_with("empty_vert", "");
11063        d.move_down(false);
11064        assert_eq!(d.caret, 0);
11065        d.move_up(false);
11066        assert_eq!(d.caret, 0);
11067    }
11068
11069    // ── the document's edges ─────────────────────────────────────────────────
11070
11071    #[test]
11072    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
11073        // The reproduction, and the disagreement: Down on the last line ran to
11074        // the end of the document in the source view — by accident, an
11075        // out-of-range row clamping to the end of the string — and did nothing
11076        // whatever in the view leaf opens in. One rule now, in both.
11077        for (view, tag) in VIEWS {
11078            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
11079            d.caret = 1;
11080            d.move_down(false);
11081            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
11082            d.move_up(false);
11083            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
11084        }
11085    }
11086
11087    #[test]
11088    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
11089        // Down off the bottom is a motion like any other, so it latches a goal
11090        // column — and Up comes back to the column the caret left, not to the
11091        // one the document's end happened to be in.
11092        for (view, tag) in VIEWS {
11093            let gap = if view == View::Source { "\n" } else { "\n\n" };
11094            let src = format!("abcdef{gap}ghijkl");
11095            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
11096            d.caret = 2; // row 0, col 2
11097            d.move_down(false);
11098            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
11099            d.move_down(false);
11100            assert_eq!(
11101                d.caret,
11102                src.len(),
11103                "{tag}: Down off the bottom reaches the end"
11104            );
11105            d.move_up(false);
11106            assert_eq!(
11107                d.caret_pos().1,
11108                2,
11109                "{tag}: Up returns to the column Down left"
11110            );
11111        }
11112    }
11113
11114    #[test]
11115    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
11116        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
11117        // Up that did nothing still armed a goal column, and the next Down aimed
11118        // at a column the caret had never been in.
11119        for (view, tag) in VIEWS {
11120            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
11121            d.caret = 0;
11122            d.move_up(false);
11123            assert_eq!(d.caret, 0, "{tag}: already at the start");
11124            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
11125
11126            d.caret = d.source.len();
11127            d.move_down(false);
11128            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
11129            assert_eq!(
11130                d.goal_col, None,
11131                "{tag}: a no-op Down latched a goal column"
11132            );
11133        }
11134    }
11135
11136    // ── soft wrap ────────────────────────────────────────────────────────────
11137    // Every other test here builds the map at 80 columns, where no fixture is
11138    // long enough to fold. A wrap is where one offset belongs to two rows at
11139    // once, and it broke everything that asks the caret what row it is on.
11140
11141    /// The wrapped fixture these cases share, folded at 12 columns into
11142    /// `one two ` / `three four ` / `five six ` / `seven eight`.
11143    fn wrapped_doc(name: &str) -> Doc {
11144        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
11145        d.build_visual(12);
11146        d
11147    }
11148
11149    #[test]
11150    fn home_and_end_work_from_a_wrapped_row() {
11151        // The reproduction: offset 19 is the `f` of "five", the first character
11152        // of the third row — and also the offset the second row ends at. It
11153        // resolved to the *second* row, so End aimed at a place the caret was
11154        // already in and did nothing, while Home walked backwards onto a row the
11155        // caret had left.
11156        let mut d = wrapped_doc("wrap_home_end");
11157        d.caret = 19;
11158        assert_eq!(
11159            d.caret_pos(),
11160            (2, 0),
11161            "the wrap boundary opens the third row"
11162        );
11163        d.move_end(false);
11164        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
11165        d.move_home(false);
11166        assert_eq!(d.caret, 19, "Home left the row the caret was on");
11167    }
11168
11169    #[test]
11170    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
11171        // The row's end is the last offset that is only ever its own: the offset
11172        // past it opens the row below, and aiming there would send a second
11173        // press on to *that* row's end, and a third to the next — End walking
11174        // down the paragraph rather than sitting where it landed.
11175        let mut d = wrapped_doc("wrap_end_twice");
11176        d.caret = 12; // inside "three", on the second row
11177        d.move_end(false);
11178        assert_eq!(
11179            d.caret, 18,
11180            "the end of `three four`, before the space the wrap ate"
11181        );
11182        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
11183        d.move_end(false);
11184        assert_eq!(d.caret, 18, "a second End moved the caret");
11185        d.move_home(false);
11186        assert_eq!(d.caret, 8, "Home takes the row's own start");
11187    }
11188
11189    #[test]
11190    fn vertical_motion_crosses_a_soft_wrap() {
11191        // Down aimed at the row below's column 0, an offset that resolved *up*
11192        // to the row above's end — so it landed on the offset it already had and
11193        // the caret could never leave a paragraph's first row.
11194        let mut d = wrapped_doc("wrap_down");
11195        d.caret = 0;
11196        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
11197            d.move_down(false);
11198            assert_eq!(d.caret, want, "Down stalled");
11199            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
11200        }
11201        d.move_down(false);
11202        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
11203
11204        // ...and back up, one row per press. The goal column is the end of the
11205        // last row, past every other row's width, so each press clamps to the
11206        // row's own last offset rather than to the one that opens the next.
11207        let mut d = wrapped_doc("wrap_up");
11208        d.caret = 39;
11209        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
11210            d.move_up(false);
11211            assert_eq!(d.caret, want, "Up stalled");
11212            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
11213        }
11214    }
11215
11216    #[test]
11217    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
11218        // The kills take the same line Home and End do, so in WYSIWYG they take
11219        // the visual row — and a soft wrap has no newline in it to delete, so
11220        // nothing is joined by reaching the end of one.
11221        let mut d = wrapped_doc("wrap_kill");
11222        d.caret = 19; // the `f` of "five", opening the third row
11223        d.delete_to_line_end();
11224        // The space the wrap ate goes with the row it was drawn on: sparing it
11225        // would leave "four  seven", two spaces where the row had been.
11226        assert_eq!(d.source, "one two three four seven eight");
11227
11228        // Backwards from the row's last caret position — which is *before* that
11229        // space, so this one survives, being on the far side of the caret.
11230        let mut d = wrapped_doc("wrap_kill_back");
11231        d.caret = 27;
11232        d.delete_to_line_start();
11233        assert_eq!(d.source, "one two three four  seven eight");
11234    }
11235
11236    // ── document start / end ────────────────────────────────────────────────
11237
11238    #[test]
11239    fn move_doc_start_and_end_jump_to_the_edges() {
11240        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
11241        assert_eq!(
11242            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
11243            "|hello\nworld\n"
11244        );
11245        assert_eq!(
11246            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
11247            "hello\nworld\n|"
11248        );
11249        // Already at the edge: a no-op.
11250        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
11251        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
11252    }
11253
11254    #[test]
11255    fn move_doc_start_and_end_extend_the_selection() {
11256        assert_eq!(
11257            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
11258                .move_doc_end(true)),
11259            "hello wor[ld\n|]"
11260        );
11261        assert_eq!(
11262            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
11263                .move_doc_start(true)),
11264            "[|hello wor]ld\n"
11265        );
11266    }
11267
11268    #[test]
11269    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
11270        let mut d = doc_with("empty_edges", "");
11271        d.move_doc_end(false);
11272        assert_eq!(d.caret, 0);
11273        d.move_doc_start(false);
11274        assert_eq!(d.caret, 0);
11275    }
11276
11277    // ── arrow collapses an active selection ─────────────────────────────────
11278
11279    #[test]
11280    fn arrow_collapses_selection_to_its_near_edge() {
11281        let mut d = doc_with("collapse", "hello world\n");
11282
11283        // Forward selection (anchor before caret): Right -> end, Left -> start.
11284        d.anchor = Some(2);
11285        d.caret = 7;
11286        d.move_right(false);
11287        assert_eq!((d.caret, d.anchor), (7, None));
11288
11289        d.anchor = Some(2);
11290        d.caret = 7;
11291        d.move_left(false);
11292        assert_eq!((d.caret, d.anchor), (2, None));
11293
11294        // Backward selection (anchor after caret): edges are the same
11295        // regardless of which end the caret started on.
11296        d.anchor = Some(7);
11297        d.caret = 2;
11298        d.move_right(false);
11299        assert_eq!((d.caret, d.anchor), (7, None));
11300
11301        d.anchor = Some(7);
11302        d.caret = 2;
11303        d.move_left(false);
11304        assert_eq!((d.caret, d.anchor), (2, None));
11305    }
11306
11307    #[test]
11308    fn arrow_with_extend_keeps_growing_the_selection() {
11309        let mut d = doc_with("collapse_extend", "hello world\n");
11310        d.anchor = Some(2);
11311        d.caret = 7;
11312        d.move_right(true); // extend: no collapse, caret steps one further
11313        assert_eq!((d.caret, d.anchor), (8, Some(2)));
11314    }
11315
11316    #[test]
11317    fn arrow_without_a_selection_moves_one_character_as_before() {
11318        let mut d = doc_with("no_collapse", "hello\n");
11319        d.caret = 2;
11320        d.move_right(false);
11321        assert_eq!(d.caret, 3);
11322        d.move_left(false);
11323        assert_eq!(d.caret, 2);
11324    }
11325
11326    /// Press Right until it stops, collecting the offsets walked through. Every
11327    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
11328    /// two stops sharing one source offset can't be moved between, so the caret
11329    /// stalls on the first of them and the walk never reaches the rest.
11330    fn walk_right(d: &mut Doc) -> Vec<usize> {
11331        let mut seen = vec![d.caret];
11332        for _ in 0..2000 {
11333            let before = d.caret;
11334            d.move_right(false);
11335            if d.caret == before {
11336                break;
11337            }
11338            seen.push(d.caret);
11339        }
11340        seen
11341    }
11342
11343    #[test]
11344    fn the_caret_crosses_a_soft_break() {
11345        // A newline inside a paragraph is a `soft_break`, which twig gives no
11346        // span of its own — the space it renders as used to borrow the offset of
11347        // the character before it, and a caret can't move without changing
11348        // offset. Right must walk clean off the end of the first line.
11349        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
11350        d.caret = 0;
11351        let seen = walk_right(&mut d);
11352        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11353    }
11354
11355    #[test]
11356    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
11357        // The paragraph holds one soft break. Folded (the default) it lays out as
11358        // a single reflowed row; Preserve re-lays it as a row per source line.
11359        // The setter must invalidate the cached map for the change to show, and
11360        // again on the way back — so a round trip returns to the folded layout.
11361        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
11362        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
11363        d.build_visual(80);
11364        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
11365
11366        d.set_line_flow(LineFlow::Preserve);
11367        d.build_visual(80);
11368        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
11369
11370        d.set_line_flow(LineFlow::Fold);
11371        d.build_visual(80);
11372        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
11373    }
11374
11375    #[test]
11376    fn the_caret_still_crosses_a_preserved_soft_break() {
11377        // Preserve renders the soft break as a row boundary rather than a space,
11378        // but the caret must still reach every offset — the break's own offset is
11379        // the first row's end stop, so Right walks clean off the end of line one
11380        // onto line two, exactly as it does when the break is folded.
11381        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
11382        d.set_line_flow(LineFlow::Preserve);
11383        d.build_visual(80);
11384        d.caret = 0;
11385        let seen = walk_right(&mut d);
11386        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11387    }
11388
11389    #[test]
11390    fn the_caret_walks_a_code_block() {
11391        // Every glyph of a code block used to map to the block's start, so the
11392        // whole block was a single offset and the caret couldn't move inside it.
11393        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
11394        let mut d = wysiwyg_doc("code_walk", src);
11395        d.caret = 0;
11396        let seen = walk_right(&mut d);
11397        // The fences are markup: hidden, and no caret stop. The code between
11398        // them is reached a character at a time.
11399        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
11400        for off in code.clone() {
11401            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
11402        }
11403        assert!(seen.contains(&code.end), "no stop after the last line");
11404    }
11405
11406    #[test]
11407    fn the_caret_walks_an_indented_code_block() {
11408        // An indented block's text has the four-space indent stripped, so it
11409        // isn't a verbatim slice and its lines have to be re-found. The caret
11410        // lands on the code, never in the indent.
11411        let src = "    indented\n    code\n";
11412        let mut d = wysiwyg_doc("indent_code_walk", src);
11413        d.caret = 0;
11414        let seen = walk_right(&mut d);
11415        assert!(seen.contains(&src.find("indented").unwrap()));
11416        assert!(seen.contains(&src.find("code").unwrap()));
11417        assert!(
11418            !seen.contains(&0) || seen[0] == 0,
11419            "the caret starts where it was put"
11420        );
11421        // Nothing in the stripped indent is a stop.
11422        for off in [1, 2, 3] {
11423            assert!(!seen.contains(&off), "landed in the indent at {off}");
11424        }
11425    }
11426
11427    #[test]
11428    fn the_caret_leaves_a_tight_heading() {
11429        // "# H" with text directly under it: the heading row's end and the
11430        // separator row's end are the same offset. Right used to find the
11431        // separator's copy, set the caret to where it already was, and stop.
11432        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
11433        d.caret = 2; // the "H"
11434        let seen = walk_right(&mut d);
11435        assert!(
11436            seen.len() > 2,
11437            "Right stalled at the heading's end: {seen:?}"
11438        );
11439        assert!(
11440            seen.contains(&8),
11441            "never reached the end of \"text\": {seen:?}"
11442        );
11443    }
11444
11445    #[test]
11446    fn the_caret_skips_the_gap_between_two_paragraphs() {
11447        // The blank line between two paragraphs is the boundary itself. The
11448        // caret used to be able to sit on it, and typing there landed in the
11449        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
11450        // soft break, so the text visibly snapped back up.
11451        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
11452        d.caret = 1; // the end of "A"
11453        d.move_right(false);
11454        assert_eq!(d.caret, 3, "Right stopped in the gap");
11455        d.insert("x");
11456        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
11457    }
11458
11459    #[test]
11460    fn down_from_a_paragraph_lands_on_the_next_one() {
11461        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
11462        d.caret = 0;
11463        d.move_down(false);
11464        assert_eq!(d.caret, 3, "Down stopped in the gap");
11465    }
11466
11467    #[test]
11468    fn clicking_the_gap_lands_on_real_text() {
11469        // A click can still *reach* the gap — it's drawn, so it's clickable.
11470        // It has to resolve to somewhere the caret can be.
11471        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
11472        d.click(1, 0, false); // the gap row
11473        assert!(
11474            d.caret == 1 || d.caret == 3,
11475            "click left the caret in the gap at {}",
11476            d.caret
11477        );
11478        d.insert("x");
11479        // Either edge of the boundary is a fair place to land; inside it isn't.
11480        assert!(
11481            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
11482            "click in the gap typed into the boundary: {:?}",
11483            d.source
11484        );
11485    }
11486
11487    #[test]
11488    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
11489        // Enter inserts a paragraph break, which leaves a blank line spare on
11490        // either side of a new one. That middle line is a real empty paragraph:
11491        // the caret lands there, and typing makes a paragraph rather than
11492        // extending a neighbour.
11493        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
11494        d.caret = 1;
11495        d.newline();
11496        assert_eq!(d.source, "A\n\n\n\nB\n");
11497        d.build_visual(80);
11498        let (row, _) = d.caret_pos();
11499        assert!(
11500            d.vmap.row_is_navigable(row),
11501            "the caret landed on a gap row"
11502        );
11503        d.insert("x");
11504        assert_eq!(
11505            d.source, "A\n\nx\n\nB\n",
11506            "the new paragraph merged into a neighbour"
11507        );
11508    }
11509
11510    #[test]
11511    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
11512        let mut d = wysiwyg_doc("gap_eof", "A\n");
11513        d.caret = 1;
11514        d.newline();
11515        d.build_visual(80);
11516        let (row, _) = d.caret_pos();
11517        assert!(
11518            d.vmap.row_is_navigable(row),
11519            "the caret landed on a gap row"
11520        );
11521        d.insert("x");
11522        assert!(
11523            d.source.starts_with("A\n\n") && d.source.contains('x'),
11524            "typing at the end merged into A: {:?}",
11525            d.source
11526        );
11527    }
11528
11529    #[test]
11530    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
11531        // A paragraph broken over two source lines is one paragraph. Selecting
11532        // it must not stop at the newline inside it — that newline is markup the
11533        // rich-text view exists to hide.
11534        let src = "one two\nthree four\n\nnext\n";
11535        let mut d = wysiwyg_doc("triple_para", src);
11536        d.select_block_at(2);
11537        assert_eq!(
11538            d.selected_text(),
11539            Some("one two\nthree four"),
11540            "stopped at the soft break"
11541        );
11542    }
11543
11544    #[test]
11545    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
11546        // The reader scrolls down past the caret's row. Nothing moved the
11547        // caret, so the view must stay where it was put — the old code revealed
11548        // the caret every frame, which dragged the view straight back and made
11549        // the document unscrollable past the caret.
11550        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
11551        d.caret = 0;
11552        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
11553        d.scroll = 4; // the wheel
11554        d.follow_caret(0, 3, 9);
11555        assert_eq!(
11556            d.scroll, 4,
11557            "the wheel was overruled by a caret that never moved"
11558        );
11559    }
11560
11561    #[test]
11562    fn moving_the_caret_brings_the_view_back_to_it() {
11563        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
11564        d.caret = 0;
11565        d.follow_caret(0, 3, 9);
11566        d.scroll = 6; // scrolled away
11567        d.move_right(false); // ...and now the caret moves
11568        let (row, _) = d.caret_pos();
11569        d.follow_caret(row, 3, 9);
11570        assert!(
11571            d.scroll <= row && row < d.scroll + 3,
11572            "caret row {row} off screen at scroll {}",
11573            d.scroll
11574        );
11575    }
11576
11577    #[test]
11578    fn scrolling_stops_at_the_last_row() {
11579        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
11580        d.caret = 0;
11581        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
11582        d.scroll = 999; // the wheel, spun hard
11583        d.follow_caret(0, 3, 3);
11584        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
11585    }
11586
11587    #[test]
11588    fn every_cell_of_a_wide_table_is_reachable() {
11589        // A table whose cells are far wider than the surface: the columns are
11590        // cut to fit and the text wraps inside them, so no cell hangs off the
11591        // right edge where the caret can never go.
11592        let src = "| Ingredient | Notes |\n|---|---|\n\
11593                   | flour milled coarse | sift it twice before folding it in |\n";
11594        let mut d = wysiwyg_doc("wide_table_walk", src);
11595        d.build_visual(30);
11596        d.caret = 0;
11597        let seen = walk_right(&mut d);
11598        for word in ["Ingredient", "Notes", "coarse", "folding"] {
11599            let at = src.find(word).unwrap();
11600            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
11601        }
11602    }
11603
11604    // ── view parity ──────────────────────────────────────────────────────────
11605    // `doc_with` pins the source view, so everything above tests a view users
11606    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
11607    // deletion golden cases through *both*, plus the WYSIWYG cases the two
11608    // can't share: where the source carries markup the rendered text is a
11609    // different string, and the views agreeing would itself be the bug.
11610
11611    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
11612
11613    /// Run `action` in both views on one `|`-marked fixture and assert they
11614    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
11615    /// source verbatim, so the two views are looking at the same text and any
11616    /// disagreement is one of them having lost the plot.
11617    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
11618        let (src, caret) = parse_caret(marked);
11619        let run = |view: View, tag: &str| {
11620            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
11621            d.caret = caret;
11622            action(&mut d);
11623            render_caret(&d)
11624        };
11625        let source = run(VIEWS[0].0, VIEWS[0].1);
11626        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
11627        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
11628        source
11629    }
11630
11631    #[test]
11632    fn word_motion_agrees_across_the_views_on_plain_prose() {
11633        let g = both_views;
11634        assert_eq!(
11635            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
11636            "hello |world"
11637        );
11638        assert_eq!(
11639            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
11640            "|hello world"
11641        );
11642        assert_eq!(
11643            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
11644            "hello| world"
11645        );
11646        assert_eq!(
11647            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
11648            "hello world|"
11649        );
11650        assert_eq!(
11651            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
11652            "foo|.bar"
11653        );
11654        assert_eq!(
11655            g("par_ext", "hello |world", |d| d.move_word_right(true)),
11656            "hello [world|]"
11657        );
11658    }
11659
11660    #[test]
11661    fn word_deletion_agrees_across_the_views_on_plain_prose() {
11662        let g = both_views;
11663        assert_eq!(
11664            g("par_db", "hello world|", |d| d.delete_word_back()),
11665            "hello |"
11666        );
11667        assert_eq!(
11668            g("par_df", "hello |world", |d| d.delete_word_forward()),
11669            "hello |"
11670        );
11671        assert_eq!(
11672            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
11673            "|bar baz"
11674        );
11675        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
11676    }
11677
11678    #[test]
11679    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
11680        let g = both_views;
11681        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
11682        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
11683        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
11684        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
11685    }
11686
11687    #[test]
11688    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
11689        // The reproduction: the stop table was built one stop per `char`, so
11690        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
11691        // source view, which steps by grapheme, can't reach and backspace can't
11692        // survive. The two views must land on the same offset.
11693        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
11694        for (view, tag) in VIEWS {
11695            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
11696            d.caret = 1;
11697            d.move_right(false);
11698            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
11699
11700            // ...and the edit that used to sever a joiner off the front of it.
11701            d.backspace();
11702            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
11703            assert_eq!(d.caret, 1);
11704        }
11705    }
11706
11707    #[test]
11708    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
11709        for (view, tag) in VIEWS {
11710            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
11711            d.caret = 0;
11712            d.move_right(false);
11713            assert_eq!(
11714                d.caret,
11715                "e\u{0301}".len(),
11716                "{tag} stopped on the combining mark"
11717            );
11718        }
11719    }
11720
11721    #[test]
11722    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
11723        // The general form: whatever route the caret takes through a document
11724        // full of clusters, it never lands between the codepoints of one — so no
11725        // motion-then-backspace sequence can leave a dangling joiner behind.
11726        use unicode_segmentation::UnicodeSegmentation;
11727
11728        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
11729        let mut d = wysiwyg_doc("cluster_walk", src);
11730        d.caret = 0;
11731        let boundaries: Vec<usize> = src
11732            .grapheme_indices(true)
11733            .map(|(i, _)| i)
11734            .chain(std::iter::once(src.len()))
11735            .collect();
11736        for off in walk_right(&mut d) {
11737            assert!(
11738                boundaries.contains(&off),
11739                "Right stopped at {off}, inside a grapheme cluster"
11740            );
11741        }
11742    }
11743
11744    #[test]
11745    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
11746        // The reproduction: ⌥→ from inside the opening `**` computed its
11747        // boundary over the raw source and landed on byte 8 — inside the
11748        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
11749        // "bold". The caret drew past the bold word and sat inside it.
11750        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
11751        d.caret = 2;
11752        d.move_word_right(false);
11753        assert!(
11754            d.vmap.is_stop(d.caret),
11755            "landed at {}, not a caret stop",
11756            d.caret
11757        );
11758        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
11759        // The rendered row is "a bold c": column 6 is the space just past "bold",
11760        // and now the caret is really there rather than only drawn there.
11761        assert_eq!(d.caret_pos(), (0, 6));
11762
11763        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
11764        d.move_word_left(false);
11765        assert_eq!(d.caret, 4);
11766        assert_eq!(d.caret_pos(), (0, 2));
11767    }
11768
11769    #[test]
11770    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
11771        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
11772        // inside the closing `**`, and left "a ** c\n" — delimiters with no
11773        // opener. Glyph space covers the word alone, which would leave
11774        // "a **** c": markup wrapped around nothing. The word and the styling
11775        // that was only ever the word's go together.
11776        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
11777        d.caret = 10;
11778        d.delete_word_back();
11779        assert_eq!(d.source, "a  c\n");
11780        assert_eq!(d.caret, 2);
11781
11782        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
11783        d.caret = 4; // the "b"
11784        d.delete_word_forward();
11785        assert_eq!(d.source, "a  c\n");
11786    }
11787
11788    #[test]
11789    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
11790        let src = "a ***bold*** c\n";
11791        let mut d = wysiwyg_doc("wys_word_del_nest", src);
11792        d.caret = src.find(" c").unwrap();
11793        d.delete_word_back();
11794        assert_eq!(
11795            d.source, "a  c\n",
11796            "the emph inside the strong empties it too"
11797        );
11798
11799        let src = "a `code` c\n";
11800        let mut d = wysiwyg_doc("wys_word_del_code", src);
11801        d.caret = src.find(" c").unwrap();
11802        d.delete_word_back();
11803        assert_eq!(d.source, "a  c\n");
11804    }
11805
11806    #[test]
11807    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
11808        // Only an *emptied* node goes. Take one word of two and the `**` still
11809        // has a job to do — over the word that's left, with the space the delete
11810        // pushed against the opening delimiter moved out in front of it, or the
11811        // run would be no run at all (`** words**` is literal asterisks — see
11812        // the mark-edge rule on `splice`).
11813        let src = "a **two words** c\n";
11814        let mut d = wysiwyg_doc("wys_word_del_partial", src);
11815        d.caret = src.find(" words").unwrap();
11816        d.delete_word_back();
11817        assert_eq!(d.source, "a  **words** c\n");
11818    }
11819
11820    #[test]
11821    fn source_view_word_motion_still_walks_the_markup() {
11822        // The other half of the decision: in the source view the `**` are
11823        // characters like any other — they're on the screen, so word motion has
11824        // to stop at them and a word-delete has to leave them behind. Only
11825        // WYSIWYG hides them, so only WYSIWYG steps over them.
11826        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
11827        assert_eq!(
11828            g("src_word_motion", "a |**bold** c\n", |d| d
11829                .move_word_right(false)),
11830            "a **bold|** c\n"
11831        );
11832        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
11833        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
11834        assert_eq!(
11835            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
11836            "a **| c\n"
11837        );
11838    }
11839
11840    #[test]
11841    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
11842        // The single invariant both bugs violated: the caret draws and edits at
11843        // the same place only when it's on a stop. `debug_assert_on_a_stop`
11844        // makes the same claim in-place; this pins it from the outside, over a
11845        // document with every kind of thing the map has to be careful about.
11846        // At two widths: the wide one every other test builds at, where no
11847        // fixture folds, and one narrow enough that they all do. A soft wrap is
11848        // where an offset stops being on exactly one row, and testing only the
11849        // width that never wraps is how the caret came to be pinned at the first
11850        // one Down reached.
11851        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
11852                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
11853        // A table of named operations, which is what it looks like.
11854        #[allow(clippy::type_complexity)]
11855        let motions: [(&str, fn(&mut Doc)); 8] = [
11856            ("right", |d| d.move_right(false)),
11857            ("left", |d| d.move_left(false)),
11858            ("word_right", |d| d.move_word_right(false)),
11859            ("word_left", |d| d.move_word_left(false)),
11860            ("down", |d| d.move_down(false)),
11861            ("up", |d| d.move_up(false)),
11862            ("home", |d| d.move_home(false)),
11863            ("end", |d| d.move_end(false)),
11864        ];
11865        for width in [80, 12] {
11866            let mut d = wysiwyg_doc("stop_invariant", src);
11867            d.build_visual(width);
11868            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11869            assert!(stops.len() > 20, "fixture should have plenty of stops");
11870            for start in stops {
11871                for (name, motion) in &motions {
11872                    d.caret = start;
11873                    d.anchor = None;
11874                    motion(&mut d);
11875                    assert!(
11876                        d.vmap.is_stop(d.caret),
11877                        "{name} from {start} at width {width} landed at {} — not a caret stop",
11878                        d.caret
11879                    );
11880                }
11881            }
11882        }
11883    }
11884
11885    #[test]
11886    fn no_wysiwyg_motion_is_a_dead_end() {
11887        // Down held to the bottom of a document reaches the bottom, and Up held
11888        // to the top reaches the top — from anywhere, at a width that wraps. The
11889        // invariant above says a motion lands somewhere legal; this one says it
11890        // gets somewhere at all, which is what a caret pinned at a wrap boundary
11891        // was quietly failing to do while every assertion around it held.
11892        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
11893                   - item one two three four five\n\nlast\n";
11894        for width in [80, 12] {
11895            let mut d = wysiwyg_doc("no_dead_end", src);
11896            d.build_visual(width);
11897            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11898            let (first, last) = (stops[0], stops[stops.len() - 1]);
11899            for &start in &stops {
11900                for (name, motion, want) in [
11901                    (
11902                        "down",
11903                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
11904                        last,
11905                    ),
11906                    ("up", |d: &mut Doc| d.move_up(false), first),
11907                ] {
11908                    d.caret = start;
11909                    d.anchor = None;
11910                    d.goal_col = None;
11911                    // Every row, plus the presses the edges take, plus slack.
11912                    for _ in 0..d.vmap.num_rows() + 4 {
11913                        motion(&mut d);
11914                    }
11915                    assert_eq!(
11916                        d.caret, want,
11917                        "{name} held from {start} at width {width} never arrived"
11918                    );
11919                }
11920            }
11921        }
11922    }
11923    // ── display columns ──────────────────────────────────────────────────────
11924    // A `col` is a terminal cell, not a character. The two are the same number
11925    // for the ASCII the fixtures above are written in, which is how they came
11926    // apart in the first place: `你` is one character drawn in two cells, so a
11927    // column counted in characters names a cell the text isn't in — one earlier
11928    // for every wide character to its left.
11929
11930    #[test]
11931    fn a_wide_character_is_two_columns_wide() {
11932        // The reproduction: `你` is one char and two cells, so the caret just
11933        // past it drew at column 1 — inside the character it had already left.
11934        for (view, tag) in VIEWS {
11935            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
11936            d.caret = "你".len();
11937            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
11938            d.caret = "你好".len();
11939            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
11940        }
11941    }
11942
11943    #[test]
11944    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
11945        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
11946        // two-cell — measuring six cells one at a time, but the character they
11947        // spell is drawn in two. Width belongs to the cluster, not the glyph,
11948        // and the frontends measure it the same way.
11949        let family = "👨‍👩‍👧";
11950        for (view, tag) in VIEWS {
11951            let src = format!("a{family}b\n");
11952            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
11953            d.caret = 1 + family.len();
11954            assert_eq!(
11955                d.caret_pos(),
11956                (0, 3),
11957                "{tag}: 'a' is one cell, the family two"
11958            );
11959        }
11960    }
11961
11962    #[test]
11963    fn both_cells_of_a_wide_character_mean_the_character() {
11964        // Clicking the far half of `好` is still clicking `好`: half a character
11965        // is not a place the caret can be, so it comes to rest at the
11966        // character's start — the column it would have been drawn at anyway.
11967        for (view, tag) in VIEWS {
11968            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
11969            for col in [2, 3] {
11970                d.caret = 0;
11971                d.click(0, col, false);
11972                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
11973                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
11974            }
11975            // Past the last cell is the line's end, as it is for ASCII.
11976            d.click(0, 9, false);
11977            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
11978        }
11979    }
11980
11981    #[test]
11982    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
11983        // The mapping is only a mapping if it inverts: the cell the caret is
11984        // drawn in has to be the cell that brings it back to the same offset.
11985        // Over a fixture where a character may be one cell or two, and one
11986        // codepoint or five.
11987        use unicode_segmentation::UnicodeSegmentation;
11988
11989        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
11990
11991        let mut d = doc_in(View::Source, "roundtrip_source", src);
11992        // Every offset the source view's caret can occupy: it steps by grapheme
11993        // cluster, so those are its boundaries.
11994        for (off, _) in src
11995            .grapheme_indices(true)
11996            .chain(std::iter::once((src.len(), "")))
11997        {
11998            d.caret = off;
11999            let (row, col) = d.caret_pos();
12000            d.click(row, col, false);
12001            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
12002        }
12003
12004        // And in WYSIWYG, where the offsets the caret can occupy are the map's
12005        // stops rather than every boundary.
12006        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
12007        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12008        assert!(stops.len() > 20, "fixture should have plenty of stops");
12009        for off in stops {
12010            d.caret = off;
12011            let (row, col) = d.caret_pos();
12012            d.click(row, col, false);
12013            assert_eq!(
12014                d.caret, off,
12015                "wysiwyg: {off} → ({row}, {col}) → {}",
12016                d.caret
12017            );
12018        }
12019    }
12020
12021    #[test]
12022    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
12023        // Down from under `世` lands under the glyph in that cell, not two
12024        // characters further along the line. The goal is a column, so a line of
12025        // wide characters and a line of ASCII line up the way they're drawn.
12026        //
12027        // The gap differs by view: a bare newline inside a paragraph is a soft
12028        // break, which WYSIWYG draws as a space on a single row. The views share
12029        // a grid only where the source's lines are the renderer's rows too.
12030        for (view, tag) in VIEWS {
12031            let gap = if view == View::Source { "\n" } else { "\n\n" };
12032            let src = format!("你好世{gap}abcdef\n");
12033            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
12034            d.caret = "你好".len();
12035            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
12036            d.move_down(false);
12037            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
12038            assert!(
12039                d.source[d.caret..].starts_with('e'),
12040                "{tag}: landed on the wrong glyph"
12041            );
12042        }
12043    }
12044
12045    #[test]
12046    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
12047        // Down from column 3 onto `你好`, whose characters start at columns 0
12048        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
12049        // between the cells of one character, so the caret rests on it — and on
12050        // its start, which is the only offset there that is a caret stop.
12051        for (view, tag) in VIEWS {
12052            let gap = if view == View::Source { "\n" } else { "\n\n" };
12053            let src = format!("abcdef{gap}你好\n");
12054            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
12055            let line = src.find('你').unwrap();
12056            d.caret = 3;
12057            d.move_down(false);
12058            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
12059            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
12060        }
12061    }
12062
12063    #[test]
12064    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
12065        // The column the cell's text is laid out in is measured in cells, so the
12066        // caret walking that text has to be too — the two agreeing is the whole
12067        // point of the grid staying square.
12068        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
12069        let at = d.source.find("你").unwrap();
12070        d.caret = at;
12071        let (row, col) = d.caret_pos();
12072        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
12073        // cells further along.
12074        assert_eq!(col, 2, "the cell's first character");
12075        d.move_right(false);
12076        assert_eq!(
12077            d.caret_pos(),
12078            (row, 4),
12079            "`好` is drawn past `你`'s two cells"
12080        );
12081        assert_eq!(d.caret, at + "你".len());
12082    }
12083
12084    // ── active inline marks ───────────────────────────────────────────────────
12085
12086    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
12087    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
12088        let (src, caret) = parse_caret(marked);
12089        let mut d = doc_in(view, name, &src);
12090        d.caret = caret;
12091        d.active_inline_marks().iter().collect()
12092    }
12093
12094    /// The marks over the selection `[start, end)`.
12095    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
12096        let mut d = doc_in(view, name, src);
12097        d.anchor = Some(start);
12098        d.caret = end;
12099        d.active_inline_marks().iter().collect()
12100    }
12101
12102    #[test]
12103    fn a_caret_in_a_mark_reports_it() {
12104        for (view, tag) in VIEWS {
12105            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
12106            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
12107            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
12108            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
12109            // Plain text under no mark lights nothing — the toolbar's resting state.
12110            assert_eq!(m("a| **bold** b"), [], "{tag}");
12111            assert!(m("plain t|ext").is_empty(), "{tag}");
12112        }
12113    }
12114
12115    #[test]
12116    fn nested_marks_all_report() {
12117        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
12118        // the ancestor chain is a chain, and every mark on it is in force.
12119        for (view, tag) in VIEWS {
12120            assert_eq!(
12121                marks(
12122                    view,
12123                    &format!("marks_nested_{tag}"),
12124                    "**bold and *bo|th*** end"
12125                ),
12126                [InlineKind::Strong, InlineKind::Emph],
12127                "{tag}"
12128            );
12129        }
12130    }
12131
12132    #[test]
12133    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
12134        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
12135        // the first byte of its text and the byte after its last — both inside
12136        // the mark's span, both places typing lands inside the bold. The offset
12137        // past the closing delimiter is the next text, and reports nothing.
12138        let src = "a **bold** b";
12139        let inner_start = src.find("bold").unwrap(); // 4
12140        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
12141        for (view, tag) in VIEWS {
12142            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
12143            for off in [2, 3, inner_start, inner_end, 9] {
12144                d.caret = off;
12145                assert!(
12146                    d.active_inline_marks().contains(InlineKind::Strong),
12147                    "{tag}: offset {off} is inside the strong span"
12148                );
12149            }
12150            for off in [0, 1, 10, 11, 12] {
12151                d.caret = off;
12152                assert!(
12153                    !d.active_inline_marks().contains(InlineKind::Strong),
12154                    "{tag}: offset {off} is outside the strong run"
12155                );
12156            }
12157        }
12158    }
12159
12160    #[test]
12161    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
12162        // Regression: twig resolves an offset that is one node's end and the
12163        // next one's start to the node that *starts* there, so `**bold**|\n`
12164        // isn't bold. With nothing following there's no tie to break and the
12165        // chain still ended at the mark, which made a trailing `\n` — not the
12166        // text — decide whether the caret after a bold word reported bold. It's
12167        // the offset past the mark either way, and typing there is plain either
12168        // way. A blank document typed into is exactly this shape.
12169        for (view, tag) in VIEWS {
12170            let m = |name: String, marked| marks(view, &name, marked);
12171            assert_eq!(
12172                m(format!("marks_eob_{tag}"), "**bold**|"),
12173                [],
12174                "{tag}: no trailing newline"
12175            );
12176            assert_eq!(
12177                m(format!("marks_eol_{tag}"), "**bold**|\n"),
12178                [],
12179                "{tag}: with one"
12180            );
12181            // And the last offset that *is* in the mark still is.
12182            assert_eq!(
12183                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
12184                [InlineKind::Strong],
12185                "{tag}"
12186            );
12187        }
12188    }
12189
12190    #[test]
12191    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
12192        let src = "a **bold** b";
12193        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
12194        for (view, tag) in VIEWS {
12195            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
12196            // The whole bold word, and a slice of it.
12197            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
12198            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
12199            // Ending exactly at the closing delimiter's start is still all-bold:
12200            // an exclusive end sits *past* the last selected character, so the
12201            // question is asked of the character, not the boundary.
12202            assert_eq!(
12203                m(b, d_ + 2),
12204                [InlineKind::Strong],
12205                "{tag}: through the close"
12206            );
12207            // Half in, half out: Bold lit here would claim a press turns it off.
12208            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
12209            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
12210        }
12211    }
12212
12213    #[test]
12214    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
12215        // Both ends are bold, but the space between them isn't — two runs are two
12216        // nodes, which is exactly what the node id catches and a kind-only
12217        // comparison would not.
12218        let src = "**one** **two**";
12219        for (view, tag) in VIEWS {
12220            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
12221            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
12222        }
12223    }
12224
12225    #[test]
12226    fn marks_read_the_document_as_it_is_edited() {
12227        // The point of asking twig every frame instead of caching: the answer has
12228        // to follow the toggle that changed it.
12229        let mut d = wysiwyg_doc("marks_live", "one two\n");
12230        d.anchor = Some(0);
12231        d.caret = 3;
12232        assert!(d.active_inline_marks().is_empty(), "plain to start");
12233        d.toggle(InlineKind::Strong);
12234        assert_eq!(d.source, "**one** two\n");
12235        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
12236        assert!(d.active_inline_marks().contains(InlineKind::Strong));
12237        d.toggle(InlineKind::Strong);
12238        assert!(d.active_inline_marks().is_empty(), "and off again");
12239    }
12240
12241    #[test]
12242    fn a_link_is_not_an_inline_mark() {
12243        // `link`/`str` are inline nodes, but nothing on the inline toolbar
12244        // toggles them — a set with a "link mark" in it would have no button.
12245        for (view, tag) in VIEWS {
12246            assert_eq!(
12247                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
12248                [],
12249                "{tag}"
12250            );
12251        }
12252    }
12253
12254    // ── blank documents ───────────────────────────────────────────────────────
12255
12256    #[test]
12257    fn a_blank_document_is_untitled_empty_and_markdown() {
12258        let mut d = Doc::blank().unwrap();
12259        assert!(d.is_untitled());
12260        assert_eq!(d.path, PathBuf::new());
12261        assert_eq!(
12262            d.file_name(),
12263            "untitled",
12264            "the header has to show something"
12265        );
12266        assert_eq!(d.format_name(), "markdown");
12267        assert_eq!(d.source, "");
12268        assert!(!d.dirty, "nothing typed yet is nothing to lose");
12269        assert_eq!(d.disk_state(), DiskState::Untitled);
12270        // And it's a document you can be in: the default view renders it.
12271        d.build_visual(80);
12272        assert_eq!(d.caret, 0);
12273    }
12274
12275    #[test]
12276    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
12277        let mut d = Doc::blank().unwrap();
12278        d.insert("hello");
12279        assert!(d.dirty);
12280        d.save();
12281        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
12282        assert!(d.dirty, "it must not come away believing it saved");
12283        assert!(d.is_untitled(), "and it still has no file");
12284    }
12285
12286    #[test]
12287    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
12288        let p = temp_path("blank_save_as");
12289        let mut d = Doc::blank().unwrap();
12290        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
12291        // be kept literal (`\#`); this test is about save-as, not escaping (which
12292        // has its own test), so it types nothing that escaping would touch.
12293        d.insert("hi");
12294        d.save_as(p.clone());
12295        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
12296        assert!(!d.is_untitled());
12297        assert!(!d.dirty);
12298        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
12299        assert_eq!(
12300            d.disk_state(),
12301            DiskState::Unchanged,
12302            "the watermark is stamped"
12303        );
12304        // And ⌘S is a plain save from here on.
12305        d.insert("!");
12306        d.save();
12307        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
12308        let _ = std::fs::remove_file(&p);
12309    }
12310
12311    // ── save as ───────────────────────────────────────────────────────────────
12312
12313    /// A unique path in the temp dir that no fixture wrote — a Save As target.
12314    fn temp_path(name: &str) -> PathBuf {
12315        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12316        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12317        let mut p = std::env::temp_dir();
12318        p.push(format!("leaf_test_target_{name}_{seq}.md"));
12319        let _ = std::fs::remove_file(&p);
12320        p
12321    }
12322
12323    #[test]
12324    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
12325        let mut d = doc_with("save_as_move", "original\n");
12326        let old = d.path.clone();
12327        let new = temp_path("save_as_move");
12328        d.insert("edited: ");
12329        d.save_as(new.clone());
12330
12331        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
12332        assert_eq!(
12333            std::fs::read_to_string(&old).unwrap(),
12334            "original\n",
12335            "Save As doesn't touch the file it came from"
12336        );
12337        assert_eq!(d.path, new, "the document moved");
12338        assert!(!d.dirty);
12339        assert_eq!(
12340            d.status.as_deref(),
12341            Some(&*format!("saved {}", d.file_name()))
12342        );
12343
12344        // Every later save follows it, which is the whole difference from a copy.
12345        d.caret = 0;
12346        d.insert("re-");
12347        d.save();
12348        assert_eq!(
12349            std::fs::read_to_string(&new).unwrap(),
12350            "re-edited: original\n"
12351        );
12352        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
12353        let _ = std::fs::remove_file(&new);
12354    }
12355
12356    #[test]
12357    fn save_as_overwrites_an_existing_target() {
12358        // The picker already asked; asking again down here is the same question
12359        // twice, and the second one has no way to be answered.
12360        let new = temp_path("save_as_over");
12361        std::fs::write(&new, "theirs\n").unwrap();
12362        let mut d = doc_with("save_as_over", "ours\n");
12363        d.save_as(new.clone());
12364        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
12365        let _ = std::fs::remove_file(&new);
12366    }
12367
12368    #[test]
12369    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
12370        let mut d = doc_with("save_as_fail", "body\n");
12371        let old = d.path.clone();
12372        d.insert("x");
12373        // A directory that doesn't exist: the write can't land.
12374        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
12375        d.save_as(bad);
12376
12377        assert_eq!(
12378            d.path, old,
12379            "the document must not move to a file that isn't there"
12380        );
12381        assert!(d.dirty, "and must not believe it saved");
12382        assert!(
12383            d.status.as_deref().unwrap().starts_with("save failed:"),
12384            "the same failure a plain save reports, got {:?}",
12385            d.status
12386        );
12387        // The original is still the document's file, and still saveable.
12388        d.save();
12389        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
12390        assert!(!d.dirty);
12391    }
12392
12393    #[test]
12394    fn save_as_renames_without_reparsing_the_format() {
12395        // `.dj` on the name doesn't make the buffer djot: it was parsed as
12396        // Markdown and still is, and saying otherwise would be a conversion the
12397        // user never asked for (and an undo history thrown away to do it).
12398        let mut d = doc_with("save_as_format", "**b**\n");
12399        let mut new = temp_path("save_as_format");
12400        new.set_extension("dj");
12401        d.save_as(new.clone());
12402        assert_eq!(d.format_name(), "markdown");
12403        let _ = std::fs::remove_file(&new);
12404    }
12405
12406    // ── external change / reload ──────────────────────────────────────────────
12407
12408    #[test]
12409    fn an_untouched_file_reports_unchanged() {
12410        let mut d = doc_with("disk_clean", "body\n");
12411        assert_eq!(d.disk_state(), DiskState::Unchanged);
12412        // Editing the buffer is not editing the file.
12413        d.insert("x");
12414        assert_eq!(d.disk_state(), DiskState::Unchanged);
12415        assert!(d.dirty);
12416        // Saving re-stamps the watermark rather than reporting our own bytes back.
12417        d.save();
12418        assert_eq!(d.disk_state(), DiskState::Unchanged);
12419    }
12420
12421    #[test]
12422    fn a_file_written_underneath_reports_changed() {
12423        let mut d = doc_with("disk_changed", "body\n");
12424        std::fs::write(&d.path, "someone else\n").unwrap();
12425        assert_eq!(d.disk_state(), DiskState::Changed);
12426        // Dirty *and* changed is the clobber: both halves are readable, and
12427        // leaf-core takes neither side.
12428        d.insert("x");
12429        assert!(d.dirty && d.disk_state() == DiskState::Changed);
12430        // Saving anyway is allowed — the frontend asked, or chose not to.
12431        d.save();
12432        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
12433        assert_eq!(d.disk_state(), DiskState::Unchanged);
12434    }
12435
12436    #[test]
12437    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
12438        // The hash is what makes this honest: the file was written (a fresh
12439        // mtime), and nothing about the document is stale.
12440        let d = doc_with("disk_same_bytes", "body\n");
12441        std::fs::write(&d.path, "body\n").unwrap();
12442        assert_eq!(d.disk_state(), DiskState::Unchanged);
12443    }
12444
12445    #[test]
12446    fn a_deleted_file_reports_missing() {
12447        let mut d = doc_with("disk_missing", "body\n");
12448        std::fs::remove_file(&d.path).unwrap();
12449        assert_eq!(d.disk_state(), DiskState::Missing);
12450        // A save recreates it, and the document is whole again.
12451        d.save();
12452        assert_eq!(d.disk_state(), DiskState::Unchanged);
12453        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
12454    }
12455
12456    #[test]
12457    fn reload_replaces_the_document_with_the_file() {
12458        for (view, tag) in VIEWS {
12459            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
12460            d.insert("edited ");
12461            assert!(d.dirty);
12462            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12463            d.reload();
12464
12465            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
12466            assert!(!d.dirty, "{tag}: the file is what we have");
12467            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
12468            assert_eq!(
12469                d.status.as_deref(),
12470                Some(&*format!("reloaded {}", d.file_name()))
12471            );
12472            // The reloaded tree is live, not the old parse.
12473            d.caret = d.source.find("three").unwrap();
12474            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
12475        }
12476    }
12477
12478    #[test]
12479    fn reload_clamps_the_caret_and_drops_the_selection() {
12480        let mut d = doc_with("reload_caret", "a long first line\n");
12481        d.caret = 12;
12482        d.anchor = Some(4);
12483        std::fs::write(&d.path, "short\n").unwrap();
12484        d.reload();
12485        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
12486        assert_eq!(
12487            d.anchor, None,
12488            "a selection over bytes that changed is a lie"
12489        );
12490        assert!(d.selection().is_none());
12491
12492        // A caret the file still has room for stays put.
12493        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
12494        d.caret = 2;
12495        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12496        d.reload();
12497        assert_eq!(d.caret, 2);
12498    }
12499
12500    #[test]
12501    fn reload_drops_the_undo_history() {
12502        // twig's stack belongs to the buffer, and these are different bytes:
12503        // replaying a step recorded against the old ones would corrupt the file.
12504        let mut d = doc_with("reload_undo", "body\n");
12505        d.insert("x");
12506        std::fs::write(&d.path, "replaced\n").unwrap();
12507        d.reload();
12508        d.undo();
12509        assert_eq!(
12510            d.source, "replaced\n",
12511            "an undo must not resurrect the old buffer"
12512        );
12513        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12514    }
12515
12516    #[test]
12517    fn a_reload_that_cant_read_leaves_the_document_alone() {
12518        let mut d = doc_with("reload_gone", "body\n");
12519        d.insert("x");
12520        std::fs::remove_file(&d.path).unwrap();
12521        d.reload();
12522        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
12523        assert!(d.dirty);
12524        assert!(
12525            d.status.as_deref().unwrap().starts_with("reload failed:"),
12526            "{:?}",
12527            d.status
12528        );
12529
12530        // And an untitled document has nothing to reload from.
12531        let mut d = Doc::blank().unwrap();
12532        d.insert("typed");
12533        d.reload();
12534        assert_eq!(d.source, "typed");
12535        assert_eq!(d.status.as_deref(), Some("no file to reload"));
12536    }
12537
12538    #[test]
12539    fn a_read_only_document_refuses_every_door() {
12540        let mut d = doc_with("readonly", "one two three\n");
12541        d.insert("x");
12542        assert!(d.dirty, "writable first, so the undo step exists");
12543        d.set_read_only(true);
12544        let before = d.source.clone();
12545        d.insert("y");
12546        d.backspace();
12547        d.undo();
12548        d.redo();
12549        assert_eq!(d.source, before, "no door moved a byte");
12550        d.set_read_only(false);
12551        d.undo();
12552        assert_ne!(d.source, before, "off again, the same doors work");
12553    }
12554
12555    #[test]
12556    fn a_selection_quote_carries_its_context_on_char_boundaries() {
12557        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
12558        let start = d.source.find("exact").unwrap();
12559        d.place_caret(start, false);
12560        d.place_caret(start + "exact".len(), true);
12561        let q = d.selection_quote(3).unwrap();
12562        assert_eq!(q.exact, "exact");
12563        assert_eq!(
12564            q.prefix, "你好 ",
12565            "chars, not bytes — the multibyte pair counts as two"
12566        );
12567        assert_eq!(q.suffix, " 世界");
12568        assert_eq!(&d.source[q.start..q.end], "exact");
12569        // At the edges the context clips rather than erring.
12570        d.place_caret(0, false);
12571        d.place_caret(6, true);
12572        let q = d.selection_quote(40).unwrap();
12573        assert_eq!(q.prefix, "");
12574        assert_eq!(q.exact, "before");
12575        // No selection is no quote.
12576        d.place_caret(0, false);
12577        assert!(d.selection_quote(3).is_none());
12578    }
12579
12580    #[test]
12581    fn highlights_are_kept_sorted_and_answer_point_queries() {
12582        let mut d = doc_with("hl", "one two three\n");
12583        d.set_highlights(vec![
12584            Highlight {
12585                start: 8,
12586                end: 13,
12587                id: "b".into(),
12588                color: None,
12589                marker: None,
12590            },
12591            Highlight {
12592                start: 0,
12593                end: 3,
12594                id: "a".into(),
12595                color: Some("#ffe066".into()),
12596                marker: None,
12597            },
12598            Highlight {
12599                start: 5,
12600                end: 5,
12601                id: "empty".into(),
12602                color: None,
12603                marker: None,
12604            },
12605        ]);
12606        assert_eq!(
12607            d.highlights()
12608                .iter()
12609                .map(|h| h.id.as_str())
12610                .collect::<Vec<_>>(),
12611            ["a", "b"],
12612            "sorted by start, the empty range dropped"
12613        );
12614        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
12615        assert_eq!(d.highlight_at(3), None, "end is exclusive");
12616        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
12617        d.set_highlights(Vec::new());
12618        assert!(d.highlights().is_empty(), "a replace is a replace");
12619    }
12620}