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.
19use std::collections::HashMap;
20use std::ops::Range;
21use std::path::PathBuf;
22#[cfg(feature = "fs")]
23use std::path::Path;
24
25use anyhow::{Result, anyhow};
26#[cfg(feature = "fs")]
27use anyhow::Context;
28use twig::{
29    Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture, InlineKind,
30    Kind, MarkdownExtensions, NodeId, QueryMatch,
31};
32use unicode_segmentation::GraphemeCursor;
33
34use crate::html;
35use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
36
37/// Which view the body shows.
38#[derive(Clone, Copy, PartialEq, Eq, Debug)]
39pub enum View {
40    /// The raw document with a caret in source bytes.
41    Source,
42    /// Markup resolved to real styles, caret riding the rendered glyphs.
43    Wysiwyg,
44}
45
46/// How much of the source markup the WYSIWYG view exposes — a per-editor
47/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
48/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
49/// terms, and every rung below is about *delimiters*, whatever grammar spells
50/// them. The examples are Markdown only because that is what most documents are.
51///
52/// A single ladder over two underlying axes, because only three of their four
53/// combinations are coherent:
54///
55/// | | authoring off | authoring on |
56/// |---|---|---|
57/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
58/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
59///
60/// The empty quadrant would show delimiters on the caret's line and then escape
61/// the ones you type — a surface that displays a syntax it refuses to accept.
62/// Someone who wants to read raw markup without authoring it has
63/// [`View::Source`], which is the better tool for it.
64///
65/// The two axes are read separately by the code that cares — see
66/// [`reveals_caret_line`](Self::reveals_caret_line) and
67/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
68/// as a ladder.
69#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
70pub enum MarkupMode {
71    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
72    /// literal — twig escapes anything that would open markup, so formatting
73    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
74    /// reading surface for people who don't write markup by hand; the default,
75    /// and what Diaryx ships.
76    #[default]
77    None,
78    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
79    /// becomes italic and the asterisks disappear into the styling
80    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
81    /// clean surface back once it has been applied.
82    Shortcuts,
83    /// The caret's line shows its raw markup while every other line renders
84    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
85    /// — for people fluent in the document's grammar who want to see and edit
86    /// the delimiters they type.
87    Full,
88}
89
90impl MarkupMode {
91    /// Whether the rich view shows raw delimiters on the line holding the caret.
92    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
93    /// WYSIWYG builder.
94    pub fn reveals_caret_line(self) -> bool {
95        matches!(self, MarkupMode::Full)
96    }
97
98    /// Whether typed markup characters author real formatting. The editing axis
99    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
100    pub fn authors(self) -> bool {
101        !matches!(self, MarkupMode::None)
102    }
103}
104
105/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
106/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
107/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
108/// either flow. The renderer consults it when it lays a block's inline content
109/// into visual rows.
110#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
111pub enum LineFlow {
112    /// A soft break folds into a space and the paragraph reflows to the
113    /// viewport width — flowing prose, where the source's line wrapping is
114    /// insignificant. The default, and what Diaryx ships.
115    #[default]
116    Fold,
117    /// A soft break renders as a line break exactly where it was written, so
118    /// the author's source line structure shows on screen unchanged — the mode
119    /// for people who lay out their prose deliberately (one sentence or clause
120    /// per line, semantic line breaks). The break is still a soft break in the
121    /// source; only its rendering changes.
122    Preserve,
123}
124
125/// What the file behind a document looks like right now, against the bytes leaf
126/// last read from it or wrote to it — the question a frontend asks before it
127/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
128/// happen) or when its window regains focus. See [`Doc::disk_state`].
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum DiskState {
131    /// The file holds exactly the bytes leaf last read or wrote.
132    Unchanged,
133    /// Someone else wrote the file since. Saving overwrites their work; see
134    /// [`Doc::reload`] for the other direction.
135    Changed,
136    /// The file is gone — deleted or renamed away. A save recreates it.
137    Missing,
138    /// There is a path, but the file couldn't be read (permissions, a directory
139    /// in the way): leaf can't tell, and won't guess.
140    Unreadable,
141    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
142    /// changed under a document that was never on disk.
143    Untitled,
144}
145
146/// The inline marks in force at a point in the document — what a toolbar
147/// lights up. A `Copy` bitset rather than a `HashSet`, because
148/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
149/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub struct InlineMarks(u8);
152
153impl InlineMarks {
154    /// Every kind, in the order [`InlineMarks::iter`] yields them.
155    const ALL: [InlineKind; 8] = [
156        InlineKind::Strong,
157        InlineKind::Emph,
158        InlineKind::Verbatim,
159        InlineKind::Mark,
160        InlineKind::Superscript,
161        InlineKind::Subscript,
162        InlineKind::Insert,
163        InlineKind::Delete,
164    ];
165
166    pub const fn empty() -> Self {
167        InlineMarks(0)
168    }
169
170    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
171    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
172    fn insert(&mut self, kind: InlineKind) {
173        self.0 |= Self::bit(kind);
174    }
175
176    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
177    fn flip(&mut self, kind: InlineKind) {
178        self.0 ^= Self::bit(kind);
179    }
180
181    /// The symmetric difference: which marks differ between the two sets. Used
182    /// to resolve the marks already in force at the caret against the pending
183    /// delta — a bit set in the delta flips the base mark for the next keystroke.
184    fn xor(self, other: InlineMarks) -> InlineMarks {
185        InlineMarks(self.0 ^ other.0)
186    }
187
188    /// Whether `kind` is in force — the toolbar's "is Bold active?".
189    pub fn contains(self, kind: InlineKind) -> bool {
190        self.0 & Self::bit(kind) != 0
191    }
192
193    pub fn is_empty(self) -> bool {
194        self.0 == 0
195    }
196
197    /// The marks in force, for a frontend that renders whatever is on rather
198    /// than asking after a fixed list.
199    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
200        Self::ALL.into_iter().filter(move |&k| self.contains(k))
201    }
202
203    fn bit(kind: InlineKind) -> u8 {
204        1 << match kind {
205            InlineKind::Strong => 0,
206            InlineKind::Emph => 1,
207            InlineKind::Verbatim => 2,
208            InlineKind::Mark => 3,
209            InlineKind::Superscript => 4,
210            InlineKind::Subscript => 5,
211            InlineKind::Insert => 6,
212            InlineKind::Delete => 7,
213        }
214    }
215}
216
217impl FromIterator<InlineKind> for InlineMarks {
218    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
219        let mut m = InlineMarks::empty();
220        for k in iter {
221            m.insert(k);
222        }
223        m
224    }
225}
226
227/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
228/// into one undo step (a run of typed characters undoes together); `Other` never
229/// coalesces, so a paste, format toggle, or block change is always its own step.
230#[derive(Clone, Copy, PartialEq, Eq)]
231enum EditKind {
232    Insert,
233    Delete,
234    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
235    /// rather than `Insert`'s because a composition is not typing: each step
236    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
237    /// though no two steps insert the same bytes, and it must not fold into the
238    /// typed characters on either side of it.
239    Compose,
240    Other,
241}
242
243/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
244/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
245/// the caret), `Forward` is Delete (one starting at it).
246#[derive(Clone, Copy)]
247enum BreakEdge {
248    Backward,
249    Forward,
250}
251
252/// A re-spelling of one inline mark run, held ready in case the edit about to
253/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
254/// Every offset in it is in the coordinates the document will have *after* the
255/// plain edit, since that is when it may be applied.
256struct MarkEdgeFix {
257    /// The run's kind, and an offset inside what was its content: together they
258    /// answer "did the plain edit actually break this mark?" — the question that
259    /// decides whether any of this is applied at all.
260    kind: InlineKind,
261    probe: usize,
262    /// The byte range to re-spell (the run's delimiters included) and its new
263    /// spelling, with the edge whitespace moved outside the delimiters.
264    start: usize,
265    end: usize,
266    text: String,
267    /// Where the caret belongs afterwards — the same place on screen it would
268    /// have had, which is now on the other side of a delimiter.
269    caret: usize,
270    /// The marks in force for text typed at that caret. The caret can land
271    /// outside a run it was inside, and the marks have to survive the move or
272    /// the toolbar goes dark mid-word.
273    want: InlineMarks,
274}
275
276/// The caret and selection at one moment — the part of a history step twig's
277/// `Change` cannot carry, because the caret is leaf's state and twig only knows
278/// about bytes. leaf serializes it into the opaque per-state blob twig now
279/// stores in its own undo history (see `record_caret`), so undo and redo hand
280/// back the caret that matches the source they restore.
281#[derive(Clone, Copy)]
282struct CaretState {
283    caret: usize,
284    anchor: Option<usize>,
285}
286
287impl CaretState {
288    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
289    /// then an anchor-present flag and the anchor. twig copies these bytes and
290    /// never reads them.
291    fn to_blob(self) -> [u8; 17] {
292        let mut b = [0u8; 17];
293        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
294        if let Some(a) = self.anchor {
295            b[8] = 1;
296            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
297        }
298        b
299    }
300
301    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
302    /// length — a state twig restored that never had a caret set on it, which
303    /// leaves the caller to fall back to the edit site.
304    fn from_blob(b: &[u8]) -> Option<Self> {
305        let b: &[u8; 17] = b.try_into().ok()?;
306        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
307        let anchor =
308            (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
309        Some(CaretState { caret, anchor })
310    }
311}
312
313/// A footnote reference and the note it names — the answer to
314/// [`Doc::footnote_at`].
315///
316/// The two `Option`s move together: a reference whose definition is missing has
317/// neither a body to show nor a place to jump to, and one that resolved has
318/// both.
319#[derive(Clone, PartialEq, Eq, Debug)]
320pub struct FootnoteRef {
321    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
322    /// spells it a footnote nor the brackets around it.
323    pub label: String,
324    /// The note's body as source bytes (see
325    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
326    /// document defines no `[^label]:` to read one from.
327    pub text: Option<String>,
328    /// Where the note's *body* starts, for a "go to note" that moves the caret
329    /// there. `None` alongside a `None` `text`.
330    ///
331    /// The body rather than the definition, because this is an offset to put a
332    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
333    /// aiming at the definition's first byte snaps to the nearest real stop,
334    /// which is up in the paragraph above the note. It is also simply where a
335    /// reader following a reference wants to land: at the note's first word,
336    /// ready to read or amend it.
337    pub offset: Option<usize>,
338    /// Where the note's body ends, exclusive — so a frontend can ask which
339    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
340    ///
341    /// The rows are the note with its markup resolved: `see *later*` reaches a
342    /// frontend as an italic run, not as asterisks. `text` is the source bytes
343    /// and stays the honest answer for anything that wants the note as written
344    /// (a search index, a copy); this pair of offsets is for anything that wants
345    /// it as *read*. `None` alongside a `None` `offset`.
346    pub end: Option<usize>,
347}
348
349/// A footnote definition and the reference that sends a reader to it — the
350/// answer to [`Doc::footnote_definition_at`], and the other half of the round
351/// trip [`FootnoteRef`] starts.
352///
353/// A note is a place a reader *arrives*, so the useful thing to know while
354/// standing in one is the way back. Without this the jump to a note is a
355/// one-way door: the definitions sit at the foot of the document, so returning
356/// by hand means scrolling back up and finding the sentence again.
357#[derive(Clone, PartialEq, Eq, Debug)]
358pub struct FootnoteDef {
359    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
360    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
361    pub label: String,
362    /// Where the reference's *label* is, for a "back to reference" that moves
363    /// the caret there. `None` for a note nothing refers to — an orphan, which
364    /// is worth being able to say rather than silently doing nothing.
365    ///
366    /// The label rather than the reference's first byte, for
367    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
368    /// and its label is the only part of it the caret can rest on.
369    ///
370    /// The *first* reference, when a label is cited more than once: a repeated
371    /// citation has no one true home, and the first is both the one a reader
372    /// most likely came from and the only choice that doesn't depend on how
373    /// they got here.
374    pub offset: Option<usize>,
375}
376
377/// Where a locator lands — the answer to [`Doc::locate`].
378///
379/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
380/// document, and a place is a span rather than a point: a reader following one
381/// wants the caret at its first byte, and a reader merely *peeking* at one wants
382/// the block it covers drawn. Both are served by carrying the whole span, and
383/// only one of the two can be recovered from an offset alone.
384#[derive(Clone, PartialEq, Eq, Debug)]
385pub struct Landing {
386    /// The first byte of the block the locator names — where a caret goes.
387    pub start: usize,
388    /// One past its last byte, so a frontend can map the pair through
389    /// [`Doc::pos_for_offset`] to the rendered rows the block occupies and draw
390    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
391    pub end: usize,
392}
393
394pub struct Doc {
395    editor: Editor,
396    pub format: Format,
397    pub path: PathBuf,
398    /// Current source, refreshed from the editor after every successful edit.
399    pub source: String,
400    /// The caret, as a byte offset into `source` (always on a char boundary).
401    pub caret: usize,
402    /// The selection's fixed end, if a selection is active; the moving end is
403    /// the caret. `None` means no selection.
404    pub anchor: Option<usize>,
405    pub dirty: bool,
406    pub status: Option<String>,
407    pub view: View,
408    /// How much of the source markup the rich view exposes — a frontend preference (see
409    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
410    /// [`reveal_line`](Self::reveal_line), the editing one by
411    /// [`insert`](Self::insert).
412    markup_mode: MarkupMode,
413    /// Whether soft breaks fold into the reflowed paragraph or render where
414    /// they were written (see [`LineFlow`]) — an independent frontend
415    /// preference the WYSIWYG builder consults when it lays out a block.
416    line_flow: LineFlow,
417    /// The kind of the last edit, for coalescing: twig owns the undo *history*
418    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
419    /// call, so leaf decides when a run continues and tells twig to coalesce.
420    last_edit_kind: Option<EditKind>,
421    /// The inline marks the user has toggled *at a collapsed caret* with no
422    /// selection — "start typing bold here". Held as the XOR delta from the marks
423    /// already in force at [`pending_at`](Self::pending_at): a set bit means
424    /// "flip this kind for the next typed text", so it both turns a mark on where
425    /// none is (type into bold) and off where one already covers the caret (type
426    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
427    /// freshly typed text and then clears it — a mark once realised is carried by
428    /// the caret sitting inside the run, not by this delta.
429    pending_marks: InlineMarks,
430    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
431    /// delta is live only while the caret still stands here with no selection;
432    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
433    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
434    pending_at: Option<usize>,
435    /// The source as of the last open/save — `dirty` is `source != clean_source`,
436    /// so undoing back to the saved state correctly clears the modified flag.
437    clean_source: String,
438    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
439    /// while the document has no file behind it. [`Doc::disk_state`] compares
440    /// the file against this to catch an edit made *outside* leaf before a save
441    /// silently overwrites it — `clean_source` only knows what leaf itself did.
442    ///
443    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
444    /// writes inside one filesystem timestamp tick are indistinguishable, a
445    /// clock that steps backwards (or a writer that restores an mtime) hides a
446    /// real change, and a `touch` invents one. The whole point of the watermark
447    /// is to not clobber someone's work, so it reads the bytes and compares what
448    /// is actually there. That costs a file read per question, which is why the
449    /// question is asked on a user event (focus, save) and not every frame.
450    disk_hash: Option<u64>,
451    /// The "sticky" display column vertical motion aims for, in the active
452    /// view's grid. Set on the first `move_up`/`move_down` of a run and
453    /// reused by every subsequent one in that run, so passing through a
454    /// shorter line doesn't permanently forget the original column. Any
455    /// horizontal motion or edit clears it.
456    ///
457    /// A column, not a character index: dropping down a line of `你好` onto one
458    /// of ASCII has to land under the glyph the caret was drawn beneath, which
459    /// is the only thing the user can see to aim by. Where the goal falls inside
460    /// a wide character on the target line, the mapping resolves it to that
461    /// character — the caret lands on it rather than between its cells.
462    goal_col: Option<usize>,
463    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
464    /// and clicks read it to stay in visible space.
465    pub vmap: VisualMap,
466    /// Everything the map is built from, as one number: bumped whenever the
467    /// document's text changes, and never by a motion, a selection, or a save.
468    /// A frontend can hold work against it — see [`Doc::revision`].
469    revision: u64,
470    /// What `vmap` was built from, or `None` before the first build. The map is
471    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
472    /// moved, rebuilding it produces the identical map — see
473    /// [`Doc::build_visual`].
474    ///
475    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
476    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
477    /// text and width alone, and a caret motion still rebuilds nothing.
478    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
479    /// Per-block row cache backing the incremental rebuild: when the text
480    /// changes, only the top-level blocks whose bytes moved are re-rendered and
481    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
482    /// builds; a pure accelerator, so it's never read for correctness.
483    block_cache: wysiwyg::BlockCache,
484    /// How many visual rows each block image reserves, keyed by its destination —
485    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
486    /// measured the pictures. Core does no image I/O, so this is the only way it
487    /// learns a picture's height; a destination not in the map reserves the bare
488    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
489    /// sizes each placeholder, and folded into `vmap_key` so a height change
490    /// rebuilds the map.
491    media_rows: HashMap<String, usize>,
492
493    // View geometry the renderer stamps each frame, so mouse events can map a
494    // screen cell back to a byte offset.
495    pub scroll: usize,
496    pub body_origin: (u16, u16),
497    pub body_height: u16,
498    /// The caret as of the last frame drawn, or `None` before the first.
499    ///
500    /// Scrolling is the viewport's business, not the caret's: the view follows
501    /// the caret when the caret *moves*, but a wheel that doesn't touch the
502    /// caret has to be free to scroll away from it — otherwise the view is
503    /// pinned to the caret and stops dead at the edge of the document you can
504    /// see. Comparing against this is what tells the two apart, and it catches a
505    /// caret set by any route, including a frontend assigning the field itself.
506    pub drawn_caret: Option<usize>,
507}
508
509/// The Markdown extensions every leaf document is parsed with. `html_elements`
510/// and `directives` depart from twig's defaults. `html_elements` promotes
511/// embedded raw HTML (`<img>`, `<picture>`, `<source>`, …) into semantic AST
512/// nodes, so a picture becomes a real `image` node the frontends can frame and
513/// rasterize instead of opaque `raw_block` text. `directives` turns on generic
514/// `:::name{.class}` fenced-div containers (`directive` nodes), which a host
515/// app uses for its own semantics (diaryx's `:::vis{.audience}` visibility
516/// blocks) — core renders any directive as a plain tinted container, agnostic
517/// of `name`. Both flags are inert for non-Markdown formats, so it's safe to
518/// pass them unconditionally. Threading this through every constructor (not
519/// just `open`) keeps `from_source`, `blank`, and `reload` parsing the same
520/// document the same way — twig reparses with these same flags after each edit.
521fn parse_extensions() -> MarkdownExtensions {
522    MarkdownExtensions { html_elements: true, directives: true, ..Default::default() }
523}
524
525/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
526/// mapping twig's error into the `anyhow` context every constructor shares.
527fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
528    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
529}
530
531/// Does `format` spell a table as a **pipe table** — the one grid twig's table
532/// editor knows how to emit?
533///
534/// This is the single capability leaf still has to answer for itself, and the
535/// only hand-maintained format list left in this file. Every other gesture is
536/// [`Format::supports`], which is twig's own answer read across the C ABI — but
537/// twig deliberately leaves the table ops out of that query, because they read
538/// no `Syntax` table at all. They rewrite a grid that is already in the source
539/// and refuse on *position*, never on format. Handed a caret inside an HTML
540/// `<table>`, `table_insert_row` therefore re-emits the whole element as
541/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
542/// `dirty` flag, and nothing downstream able to tell it from a good edit.
543///
544/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
545/// wildcard answers "no" for a format leaf has never heard of: a new twig
546/// language that *does* spell pipe tables loses its grid controls until this
547/// line is updated, which shows up as a missing button. The other default hands
548/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
549fn spells_pipe_tables(format: Format) -> bool {
550    matches!(format, Format::Markdown | Format::Djot)
551}
552
553/// Which of leaf's authoring controls this document's format can actually
554/// spell — one flag per toolbar button, resolved once so a frontend can build
555/// its chrome instead of discovering each refusal on a click.
556///
557/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
558/// the matching [`Doc`] method calls, so this record cannot drift from what the
559/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
560/// export.
561///
562/// **The formats are ragged, and that is the point.** A single per-document
563/// boolean was enough while the two authorable formats were Markdown and djot
564/// and everything else spelled nothing. HTML is neither: it writes seven of the
565/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
566/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
567/// no link — because its versions of those have a different *shape*, not a
568/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
569/// one flag can say that. Markdown and djot differ from each other too:
570/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
571#[derive(Clone, Copy, Debug, Eq, PartialEq)]
572pub struct Capabilities {
573    /// ⌘B — `InlineKind::Strong`.
574    pub bold: bool,
575    /// ⌘I — `InlineKind::Emph`.
576    pub italic: bool,
577    /// Inline code — `InlineKind::Verbatim`.
578    pub code: bool,
579    /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
580    pub mark: bool,
581    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
582    pub underline: bool,
583    /// Strikethrough — `InlineKind::Delete`.
584    pub strike: bool,
585    pub superscript: bool,
586    pub subscript: bool,
587    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
588    pub heading: bool,
589    pub blockquote: bool,
590    pub bullet_list: bool,
591    pub ordered_list: bool,
592    /// The checkbox controls: giving an item a box, and ticking one.
593    pub task: bool,
594    pub link: bool,
595    /// Covers [`Doc::insert_media`] too — see the note there on why the three
596    /// media kinds stand or fall together.
597    pub image: bool,
598    /// The horizontal-rule button. HTML spells this one (`<hr>`).
599    pub thematic_break: bool,
600    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
601    /// the pair; HTML has no footnote of its own, so the button goes away rather
602    /// than writing brackets that would render as brackets.
603    pub footnote: bool,
604    /// Setting a fenced block's language — a control only ever offered with the
605    /// caret already in a fence.
606    pub code_language: bool,
607    /// The grid controls: insert/delete/move a row or column, set a column's
608    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
609    /// question — an HTML `<table>` holds the caret and still can't be edited.
610    pub table: bool,
611    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
612    /// idiomatic in-cell break.
613    pub cell_line_break: bool,
614}
615
616impl Capabilities {
617    /// Resolve every flag for `format`. Pure and cheap — twig computes each from
618    /// a static table — but a frontend that wants to hold them can.
619    pub fn of(format: Format) -> Self {
620        let inline = |k| format.supports(Gesture::ToggleInline(k));
621        let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
622        Self {
623            bold: inline(InlineKind::Strong),
624            italic: inline(InlineKind::Emph),
625            code: inline(InlineKind::Verbatim),
626            mark: inline(InlineKind::Mark),
627            underline: inline(InlineKind::Insert),
628            strike: inline(InlineKind::Delete),
629            superscript: inline(InlineKind::Superscript),
630            subscript: inline(InlineKind::Subscript),
631            heading: format.supports(Gesture::SetBlock),
632            blockquote: container(BlockContainerKind::BlockQuote),
633            bullet_list: container(BlockContainerKind::BulletList),
634            ordered_list: container(BlockContainerKind::OrderedList),
635            // Both halves of the checkbox story, and leaf offers no control that
636            // needs only one: the item gesture mints the box, the checked one
637            // ticks it, and a format spelling a `task_marker` spells both.
638            task: format.supports(Gesture::ToggleTaskItem)
639                && format.supports(Gesture::ToggleTaskChecked),
640            link: format.supports(Gesture::InsertLink),
641            image: format.supports(Gesture::InsertImage),
642            thematic_break: format.supports(Gesture::InsertThematicBreak),
643            footnote: format.supports(Gesture::InsertFootnote),
644            code_language: format.supports(Gesture::SetCodeLanguage),
645            table: spells_pipe_tables(format),
646            cell_line_break: format.supports(Gesture::InsertLineBreak),
647        }
648    }
649}
650
651impl Doc {
652    #[cfg(feature = "fs")]
653    pub fn open(path: PathBuf) -> Result<Self> {
654        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
655        let format = detect_format(&path)?;
656        let editor = new_editor(&bytes, format)?;
657        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
658        let disk_hash = Some(hash_bytes(source.as_bytes()));
659        // Store the document's *absolute* path. A relative one (`leaf README.md`)
660        // has an empty parent, so a frontend can't resolve a relative image
661        // destination (`![](pic.png)`) against the document's directory and the
662        // picture silently falls back to its text placeholder. `absolute` is
663        // purely lexical — it prefixes the current directory and normalizes, but
664        // reads nothing and resolves no symlinks — so `file_name` and save are
665        // unchanged; it only gives `path.parent()` something to join against.
666        let path = std::path::absolute(&path).unwrap_or(path);
667        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
668    }
669
670    /// Build a document from an in-memory string, the format named explicitly —
671    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
672    /// path and sniffs the format from its extension). A wasm or FFI host, which
673    /// has no path to read, uses this: it hands over bytes it fetched however it
674    /// could, and later persists [`Doc::source`] however it can (a browser
675    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
676    ///
677    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
678    /// true) exactly like a [`Doc::blank`] that has been given content.
679    pub fn from_source(source: String, format: Format) -> Result<Self> {
680        let editor = new_editor(source.as_bytes(), format)?;
681        Ok(Doc::from_parts(editor, format, PathBuf::new(), source, None))
682    }
683
684    /// An untitled, empty document — the `+` button and a `leaf` launched with
685    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
686    ///
687    /// It is Markdown, because a format has to be chosen before a name exists to
688    /// read one from: `detect_format` reads the extension and an untitled
689    /// document has neither. Markdown is what leaf's own files are, what its
690    /// block markers are already written for (`insert_block_prefix`), and the
691    /// extension a Save As will overwhelmingly pick — a wrong guess here would
692    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
693    /// *doesn't* revisit this: see [`Doc::save_as`].
694    pub fn blank() -> Result<Self> {
695        let format = Format::Markdown;
696        let editor = new_editor(b"", format)?;
697        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
698        // field two frontends already read; making it an `Option` to say this
699        // would break both). `is_untitled` is the question to ask, not the
700        // representation to copy.
701        Ok(Doc::from_parts(editor, format, PathBuf::new(), String::new(), None))
702    }
703
704    /// The fields every constructor agrees on, so `open` and `blank` can't drift
705    /// apart in the ones neither of them has an opinion about.
706    fn from_parts(
707        editor: Editor,
708        format: Format,
709        path: PathBuf,
710        source: String,
711        disk_hash: Option<u64>,
712    ) -> Self {
713        Doc {
714            editor,
715            format,
716            path,
717            disk_hash,
718            clean_source: source.clone(),
719            source,
720            caret: 0,
721            anchor: None,
722            dirty: false,
723            status: None,
724            // leaf opens in the rich-text (WYSIWYG) view by default — the
725            // markup-resolved surface is leaf's differentiator. Frontends can
726            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
727            // toggles at runtime.
728            view: View::Wysiwyg,
729            // `None` by default — the clean surface Diaryx ships, with typed
730            // syntax kept literal; a markup-fluent frontend can climb the
731            // ladder to `Shortcuts` or `Full`.
732            markup_mode: MarkupMode::default(),
733            // Fold by default — flowing prose that reflows to the viewport, the
734            // behaviour every frontend had before this preference existed.
735            line_flow: LineFlow::default(),
736            last_edit_kind: None,
737            pending_marks: InlineMarks::empty(),
738            pending_at: None,
739            goal_col: None,
740            vmap: VisualMap::default(),
741            revision: 0,
742            // No map yet — the first `build_visual` always builds.
743            vmap_key: None,
744            block_cache: wysiwyg::BlockCache::default(),
745            media_rows: HashMap::new(),
746            scroll: 0,
747            body_origin: (0, 0),
748            body_height: 0,
749            drawn_caret: None,
750        }
751    }
752
753    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
754    /// has never been saved. The question a ⌘S handler asks to know it should
755    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
756    /// header asks to know the name it shows is a placeholder.
757    pub fn is_untitled(&self) -> bool {
758        self.path.as_os_str().is_empty()
759    }
760
761    pub fn toggle_view(&mut self) {
762        self.view = match self.view {
763            View::Source => View::Wysiwyg,
764            View::Wysiwyg => View::Source,
765        };
766        self.scroll = 0;
767        self.status = None;
768        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
769        // lift it to the first rendered offset.
770        self.clamp_caret();
771    }
772
773    /// The current markup-exposure preference (see [`MarkupMode`]).
774    pub fn markup_mode(&self) -> MarkupMode {
775        self.markup_mode
776    }
777
778    /// Set the markup-exposure preference. Both of its axes take effect at
779    /// once: the editing one on the next [`insert`](Self::insert), and the
780    /// rendering one on the next build — which is why this drops the cached
781    /// visual map and the per-block render cache, exactly as
782    /// [`set_line_flow`](Self::set_line_flow) does.
783    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
784        if self.markup_mode == mode {
785            return;
786        }
787        self.markup_mode = mode;
788        // Neither cache is keyed on the mode, and moving between `Full` and the
789        // hidden modes changes every row the caret's line renders to — so
790        // invalidate both explicitly.
791        self.vmap_key = None;
792        self.block_cache = wysiwyg::BlockCache::default();
793    }
794
795    /// The source byte range of the line the caret sits on, when that line
796    /// should render its raw delimiters — `None` in every mode and view that
797    /// hides them, which is what the builder reads as "reveal nothing".
798    ///
799    /// A *source* line (newline to newline), not a visual row: a wrapped
800    /// paragraph and a `LineFlow::Preserve` soft break both split one source
801    /// line across several rows, and revealing half a delimiter pair because the
802    /// other half wrapped would be worse than revealing neither. The range
803    /// excludes the terminating newline and is empty-but-present on a blank
804    /// line, which reveals nothing but still keys the caches correctly.
805    ///
806    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
807    /// there is nothing there to reveal.
808    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
809        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
810            return None;
811        }
812        Some(source_line_range(&self.source, self.caret))
813    }
814
815    /// The current soft-break flow preference (see [`LineFlow`]).
816    pub fn line_flow(&self) -> LineFlow {
817        self.line_flow
818    }
819
820    /// Set the soft-break flow preference. The mode changes how every block lays
821    /// out, so a change drops the cached visual map and the per-block render
822    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
823    ///
824    /// [`build_visual`]: Self::build_visual
825    pub fn set_line_flow(&mut self, mode: LineFlow) {
826        if self.line_flow == mode {
827            return;
828        }
829        self.line_flow = mode;
830        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
831        // so invalidate them explicitly, or the next build would reuse rows laid
832        // out under the old flow.
833        self.vmap_key = None;
834        self.block_cache = wysiwyg::BlockCache::default();
835    }
836
837    pub fn view_name(&self) -> &'static str {
838        match self.view {
839            View::Source => "source",
840            View::Wysiwyg => "wysiwyg",
841        }
842    }
843
844    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
845    /// (called by the renderer each frame it's in the WYSIWYG view).
846    /// Build the WYSIWYG map, wrapped at `width` display columns.
847    ///
848    /// Cheap to call every frame, which is what both frontends do: the map is a
849    /// pure function of the document and the wrap width, so a call that would
850    /// rebuild the same map returns the one already built. Only an edit (or a
851    /// resize) pays.
852    ///
853    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
854    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
855    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
856    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
857    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
858    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
859    pub fn build_visual(&mut self, width: usize) {
860        self.build_map(Some(width));
861    }
862
863    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
864    /// frontend (the GUI) that wraps at its own proportional pixel width rather
865    /// than a fixed character column.
866    pub fn build_visual_unwrapped(&mut self) {
867        self.build_map(None);
868    }
869
870    /// Tell the model how many visual rows each block image should reserve, keyed
871    /// by the image's destination. A terminal frontend calls this once it has
872    /// decoded and measured its pictures — core does no image I/O, so this is the
873    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
874    /// placeholder out that tall (the label row plus blank filler rows the
875    /// frontend paints the raster over). A destination left out of the map falls
876    /// back to the bare one-row placeholder, which is also what a frontend that
877    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
878    /// by never calling this.
879    ///
880    /// Cheap to call every frame with the same map: only a *change* invalidates
881    /// the built map (and the block-row cache, since a height isn't part of a
882    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
883    /// no-op, so a frontend can just hand over its current measurements each frame.
884    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
885        if self.media_rows == rows {
886            return;
887        }
888        self.media_rows = rows;
889        // A height lives outside the block's source bytes, so the content-keyed
890        // block cache would hand back the old-height rows on a hit. Drop it (and
891        // the splice layout it carries) so the next build re-renders every block
892        // at the new heights, and force that build by clearing the map key.
893        self.block_cache = wysiwyg::BlockCache::default();
894        self.vmap_key = None;
895    }
896
897    /// The revision the document's text is at — bumped by every edit, undo,
898    /// redo, and reload, and by nothing else. A frontend caches against this to
899    /// tell a repaint that needs new work from one that doesn't.
900    ///
901    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
902    /// lands on the same text two revisions later. Work is only ever rebuilt
903    /// needlessly, never wrongly reused.
904    pub fn revision(&self) -> u64 {
905        self.revision
906    }
907
908    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
909    /// runs on every call: the caret moves without the document changing, and
910    /// keeping it on a legal stop is this function's job either way.
911    fn build_map(&mut self, wrap: Option<usize>) {
912        // Under `MarkupMode::Full` the map is a function of the caret's *line*
913        // as well as the text, so the line joins the key: moving within a line
914        // still reuses the map, and crossing into another one rebuilds it. In
915        // every other mode `reveal_line` is `None` and the key is what it was,
916        // so caret motion goes on costing nothing.
917        let reveal = self.reveal_line();
918        let key = (self.revision, wrap, reveal.clone());
919        if self.vmap_key.as_ref() != Some(&key) {
920            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
921            // A subtree is pulled only for the block(s) that actually changed, so
922            // the FFI marshal shrinks from O(document) to O(edited block).
923            let top = self.top_blocks();
924
925            // Fast path: when twig reports a dirty byte range, try to patch the
926            // previous map in place — a single-block edit moves the prefix,
927            // shifts the suffix, and re-renders only one block. `build_spliced`
928            // returns `None` (and we fall back to the always-correct full rebuild)
929            // whenever the edit reshaped the block structure, hit a table, or
930            // there's no previous map to patch.
931            // Preserve soft breaks as written when the flow preference asks for
932            // it — the builder renders each as its own visual row instead of
933            // folding it into the reflowed paragraph.
934            let preserve_soft = self.line_flow == LineFlow::Preserve;
935            let spliced = match self.editor.dirty_range() {
936                Some(dirty) => {
937                    let prev = std::mem::take(&mut self.vmap);
938                    let source = &self.source;
939                    let cache = &mut self.block_cache;
940                    let media_rows = &self.media_rows;
941                    let editor = &mut self.editor;
942                    wysiwyg::build_spliced(prev, source, wrap, preserve_soft, &top, dirty, media_rows, reveal.clone(), cache, |id| {
943                        editor.subtree(NodeId(id)).unwrap_or_default()
944                    })
945                }
946                None => None,
947            };
948            self.vmap = spliced.unwrap_or_else(|| {
949                let source = &self.source;
950                let cache = &mut self.block_cache;
951                let media_rows = &self.media_rows;
952                let editor = &mut self.editor;
953                wysiwyg::build_cached(&top, source, wrap, preserve_soft, media_rows, reveal, cache, |id| {
954                    editor.subtree(NodeId(id)).unwrap_or_default()
955                })
956            });
957            // Acknowledge the dirty range so the next edit's range starts fresh.
958            self.editor.clear_dirty();
959            self.vmap_key = Some(key);
960        }
961        self.clamp_caret();
962    }
963
964    fn nodes(&mut self) -> Vec<FlatNode> {
965        self.editor.nodes().unwrap_or_default()
966    }
967
968    /// The document's top-level blocks for the incremental render. See
969    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
970    fn top_blocks(&mut self) -> Vec<QueryMatch> {
971        wysiwyg::top_blocks(&mut self.editor)
972    }
973
974    pub fn format_name(&self) -> &'static str {
975        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
976        // required. It also covers `Asciidoc`, which twig parses but cannot
977        // serialize — leaf never opens a document in it (see `Doc::open`).
978        match self.format {
979            Format::Djot => "djot",
980            Format::Markdown => "markdown",
981            Format::Xml => "xml",
982            Format::Html => "html",
983            _ => "unknown",
984        }
985    }
986
987    /// Whether this document's format offers *any* door in — `false` only for a
988    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
989    /// a frontend may as well open the file read-only.
990    ///
991    /// This is a much weaker claim than the name suggests, and driving per-button
992    /// state from it is exactly the mistake to avoid: HTML answers `true` because
993    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
994    /// while a heading, a quote, a list, a task box, a link and a code fence all
995    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
996    /// [`supports`](Self::supports) — per control.
997    pub fn authorable(&self) -> bool {
998        self.format.is_authorable()
999    }
1000
1001    /// Whether this document's format can spell `gesture`, which is twig's own
1002    /// answer rather than a copy of it: `Format::supports` reads the same
1003    /// `Syntax` table the `Editor` method consults before refusing.
1004    ///
1005    /// It is a fact about the *format*, not about the caret. `true` does not
1006    /// promise the gesture succeeds where it is standing — a link over a table
1007    /// border still fails — only that it will not fail with
1008    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1009    /// will work here".
1010    pub fn supports(&self, gesture: Gesture) -> bool {
1011        self.format.supports(gesture)
1012    }
1013
1014    /// Every control's enabled state in one read — what a toolbar builds itself
1015    /// from when a document opens or its format changes. See [`Capabilities`].
1016    pub fn capabilities(&self) -> Capabilities {
1017        Capabilities::of(self.format)
1018    }
1019
1020    /// Refuse a gesture this document's format cannot spell, saying so in the
1021    /// status line. `true` means the caller must return without calling twig.
1022    ///
1023    /// Most of these refusals duplicate one twig would make anyway, and they are
1024    /// made here regardless because a message naming the *document's* format
1025    /// reads better than one naming twig's internals. Two of them are not
1026    /// duplicates and are the reason this is a guard rather than an error
1027    /// translation:
1028    ///
1029    /// - The table family (see [`table_op`](Self::table_op)) consults no
1030    ///   `Syntax` table, so twig does not refuse it at all.
1031    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1032    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1033    ///   could not keep.
1034    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1035        self.refuse_unless(what, self.supports(gesture))
1036    }
1037
1038    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1039    /// answers itself — today only [`spells_pipe_tables`].
1040    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1041        if supported {
1042            return false;
1043        }
1044        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1045        true
1046    }
1047
1048    /// The name to show for this document. An untitled one has no file to name
1049    /// it, and both frontends put this straight on screen — an empty path
1050    /// renders as an empty header, so it says so instead.
1051    pub fn file_name(&self) -> String {
1052        if self.is_untitled() {
1053            return "untitled".into();
1054        }
1055        self.path
1056            .file_name()
1057            .map(|s| s.to_string_lossy().into_owned())
1058            .unwrap_or_else(|| self.path.display().to_string())
1059    }
1060
1061    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1062    /// caret and anchor coincide (an empty selection is no selection).
1063    pub fn selection(&self) -> Option<(usize, usize)> {
1064        self.anchor
1065            .map(|a| (a.min(self.caret), a.max(self.caret)))
1066            .filter(|(s, e)| s != e)
1067    }
1068
1069    /// The selected text, or `None` when there's no selection — the source
1070    /// slice a copy/cut hands to the system clipboard.
1071    pub fn selected_text(&self) -> Option<&str> {
1072        self.selection().map(|(s, e)| &self.source[s..e])
1073    }
1074
1075    /// The AST breadcrumb at the caret (root → deepest), e.g.
1076    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1077    pub fn breadcrumb(&mut self) -> String {
1078        match self.editor.ancestors_at(self.caret) {
1079            Ok(chain) => chain
1080                .iter()
1081                .map(|m| m.kind.as_str())
1082                .collect::<Vec<_>>()
1083                .join(" › "),
1084            Err(_) => String::new(),
1085        }
1086    }
1087
1088    // ── editing ──────────────────────────────────────────────────────────────
1089
1090    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1091    /// after it. The public form of the internal splice — a pixel frontend that
1092    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1093    /// edits through this, the same twig `edit_range` the caret ops use.
1094    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1095        self.splice(start, end, text, EditKind::Other);
1096    }
1097
1098    /// Insert typed `text` at the caret, replacing the selection if there is one.
1099    /// A single typed character coalesces with the run of typing before it; a
1100    /// newline or a multi-character insert is its own undo step.
1101    ///
1102    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1103    pub fn insert(&mut self, text: &str) {
1104        // Typing against a block picture would dissolve it — see
1105        // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1106        // what the caret was standing beside stays a picture.
1107        self.open_paragraph_at_block_media(text);
1108        // Armed sticky marks (⌘b with no selection) turn the next typed text
1109        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1110        // the exception: it takes no mark of its own and keeps the delta armed
1111        // for the character behind it — see `insert_space_with_marks`.
1112        let pending = self.pending_here();
1113        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1114            if text.trim().is_empty() {
1115                self.insert_space_with_marks(self.caret, text, pending);
1116            } else {
1117                self.insert_with_marks(self.caret, text, pending);
1118            }
1119            return;
1120        }
1121        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1122        // anything that would open markup, so a Diaryx user never mints
1123        // formatting by keyboard (it comes from commands instead). The other two
1124        // rungs of the ladder author markup from what you type, which is the
1125        // whole difference between them and this one. Only in the rendered view
1126        // (source view is for typing raw markup) and only where the format has a
1127        // literal spelling at all: escaping is a backslash before a byte from the
1128        // format's own alphabet, and a format with no such alphabet (HTML escapes
1129        // with entities, XML spells nothing) would have `\&` written into it,
1130        // which is two literal characters and not an escape. Marks (⌘b) still
1131        // format — that path returned above; and leaf's own structural inserts go
1132        // through `insert_raw`, never here, so a list marker or quote gutter is
1133        // written as the markup it is.
1134        if !self.markup_mode.authors()
1135            && self.view == View::Wysiwyg
1136            && !text.is_empty()
1137            && self.supports(Gesture::InsertLiteral)
1138        {
1139            self.insert_literal_typed(text);
1140            return;
1141        }
1142        self.insert_raw(text);
1143    }
1144
1145    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1146    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1147    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1148    /// markup by design and must not be escaped.
1149    fn insert_raw(&mut self, text: &str) {
1150        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1151        self.splice(s, e, text, typed_edit_kind(text));
1152    }
1153
1154    /// Open a paragraph for text about to be inserted at one of a block media's
1155    /// two caret stops, and leave the caret standing in it.
1156    ///
1157    /// A block image is a paragraph whose entire content is the picture, and the
1158    /// caret's only homes on it are in front of it and just past it (see
1159    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1160    /// *that* paragraph — and a paragraph holding anything besides the image is
1161    /// no longer a block image but a line of text with an inline one in it. The
1162    /// frontend that was painting a photo there paints a text run instead; the
1163    /// picture is still in the file, and nothing said a word. Those two offsets
1164    /// are also exactly where a click on the picture lands, so the whole accident
1165    /// is one tap and one keystroke.
1166    ///
1167    /// So the break goes in first and the text lands in the new empty paragraph —
1168    /// what pressing Return before typing would have done, which is a habit no
1169    /// one should have to learn from losing a photo. A no-op everywhere else, and
1170    /// over a selection (which is replaced, not joined into).
1171    ///
1172    /// A picture inside a quote or a list leaves its container, because `\n\n`
1173    /// ends the block. The alternative is worse: the `\n> ` / next-item
1174    /// continuation [`newline`](Self::newline) writes stays in the same
1175    /// *paragraph*, which is the thing being prevented.
1176    ///
1177    /// Only in the rendered view. Source view is for typing raw markup, where
1178    /// putting a character against an image is exactly what it looks like.
1179    fn open_paragraph_at_block_media(&mut self, text: &str) {
1180        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1181            return;
1182        }
1183        if self.selection().is_some() {
1184            return;
1185        }
1186        // The map may be a revision behind (nothing has drawn since the last
1187        // edit), and this asks it about offsets — a stale answer would splice a
1188        // break into the wrong place. Free when it is already current, which it
1189        // is whenever a frontend drew a frame between keystrokes.
1190        self.rebuild_map();
1191        let at = self.caret;
1192        let Some((side, _)) = self.vmap.block_media_stop(at) else {
1193            return;
1194        };
1195        if !self.splice(at, at, "\n\n", EditKind::Other) {
1196            return;
1197        }
1198        // The break is part of the keystroke, not an edit of its own: leave the
1199        // run marked as typing so the character about to arrive folds into it and
1200        // one undo puts the document back the way it was found. (A paste, or a
1201        // multi-character insert, is `EditKind::Other` and stays its own step —
1202        // as it would have been anywhere else in the document.)
1203        self.last_edit_kind = Some(EditKind::Insert);
1204        if side == MediaStop::Before {
1205            // The break went in above the picture and the caret rode to the end
1206            // of it — which is still hard against the picture. Step back onto the
1207            // blank line it opened, so the text lands above rather than in front.
1208            self.caret = at;
1209        }
1210    }
1211
1212    /// A delete key pressed at one of a block picture's two caret stops, handled
1213    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1214    /// the key was consumed.
1215    ///
1216    /// The caret rests in front of a block image and just past it, never inside
1217    /// its markup — which the rendered view doesn't show. So the byte a delete
1218    /// key nominally takes there is one the writer cannot see, and taking it
1219    /// leaves the picture as broken markup rather than as anything anyone asked
1220    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1221    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1222    /// missing from a document with nobody having touched it — the same
1223    /// dissolution [`open_paragraph_at_block_media`](Self::open_paragraph_at_block_media)
1224    /// prevents from the typing side, and it cost this repository's own test vault
1225    /// a photo before it was found.
1226    ///
1227    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1228    /// when it is behind the caret, Delete when it is in front — which is what
1229    /// every editor does with an embed, and one undo away. The key aimed *away*
1230    /// from it would otherwise delete the paragraph break and merge a neighbour
1231    /// into the picture's own paragraph, which dissolves it just as surely; it
1232    /// steps the caret over the boundary instead and leaves the
1233    /// next press to delete in the block it has reached — the same "first press
1234    /// steps out of the atom, second press deletes" every delete key here gets,
1235    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1236    /// above, and reaches it on the second press rather than taking the break and
1237    /// the picture with it on the first).
1238    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1239        // The map answers about offsets, so it has to be this revision's — see
1240        // the same call in `open_paragraph_at_block_media`.
1241        self.rebuild_map();
1242        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1243            return false;
1244        };
1245        let aimed_at_it = side
1246            == if forward {
1247                MediaStop::Before
1248            } else {
1249                MediaStop::After
1250            };
1251        if !aimed_at_it {
1252            let over = if forward {
1253                self.vmap.stop_after(self.caret)
1254            } else {
1255                self.vmap.stop_before(self.caret)
1256            };
1257            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1258                self.caret = off;
1259                self.anchor = None;
1260                self.goal_col = None;
1261            }
1262            return true;
1263        }
1264        // Take the break that held the picture apart from its neighbour with it,
1265        // so the delete doesn't leave a blank paragraph standing where the
1266        // picture was. The last arm is a picture that is the whole document.
1267        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1268            (span.start - 2, span.end)
1269        } else if self.source[span.end..].starts_with("\n\n") {
1270            (span.start, span.end + 2)
1271        } else {
1272            (span.start, span.end)
1273        };
1274        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1275        true
1276    }
1277
1278    /// The Hidden-mode typing path: replace any selection, then insert `text`
1279    /// escaped so it stays literal. When it replaces a selection the two edits
1280    /// fold into one undo step, so an overwrite undoes atomically (and restores
1281    /// the selection) exactly as a plain one does.
1282    fn insert_literal_typed(&mut self, text: &str) {
1283        let kind = typed_edit_kind(text);
1284        match self.selection() {
1285            Some((s, e)) => {
1286                if !self.splice(s, e, "", EditKind::Other) {
1287                    return;
1288                }
1289                // Typing over a whole marked run takes its delimiters with it
1290                // (the empty content couldn't hold them — see
1291                // `repair_mark_edges`) and leaves its marks armed at the caret.
1292                // The text taking the run's place inherits them, exactly as it
1293                // would have by landing inside a run that survived.
1294                let pending = self.pending_here();
1295                if !pending.is_empty() && !text.trim().is_empty() {
1296                    self.insert_with_marks(self.caret, text, pending);
1297                    return;
1298                }
1299                self.insert_literal_at(self.caret, text, kind, true);
1300            }
1301            None => {
1302                self.insert_literal_at(self.caret, text, kind, false);
1303            }
1304        }
1305    }
1306
1307    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1308    /// at a collapsed caret, but only while the caret still stands where they
1309    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1310    /// never styles text it wasn't meant for.
1311    fn pending_here(&self) -> InlineMarks {
1312        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1313            self.pending_marks
1314        } else {
1315            InlineMarks::empty()
1316        }
1317    }
1318
1319    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1320    /// this, so "start bold here" only ever applies at the exact spot it was
1321    /// asked for.
1322    fn clear_pending(&mut self) {
1323        self.pending_marks = InlineMarks::empty();
1324        self.pending_at = None;
1325    }
1326
1327    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1328    /// force is wrapped around the freshly typed text; a mark the caret already
1329    /// stands inside is *shed* — the text is inserted past the run's end so it
1330    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1331    /// added runs, so continued typing inherits the marks with no re-wrapping,
1332    /// and the delta is cleared: the marks now live in the document, not here.
1333    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1334        let base = self.mark_spans_at(at);
1335        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1336        // Nothing to shed, and a run of exactly these marks standing just behind
1337        // the caret: carry on writing *that* run rather than opening a second
1338        // one beside it.
1339        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1340            return;
1341        }
1342        // Shed the marks we're turning off: step the insertion point past the
1343        // end of each run the caret sits in, so the new text falls outside it.
1344        let mut ins_at = at;
1345        for (kind, span) in &base {
1346            if marks.contains(*kind) {
1347                ins_at = ins_at.max(span.end);
1348            }
1349        }
1350        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1351            return;
1352        }
1353        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1354        // is the content every added mark wraps.
1355        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1356        for kind in marks.iter() {
1357            if !base_set.contains(kind) {
1358                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1359                cs = ncs;
1360                ce = nce;
1361            }
1362        }
1363        self.caret = ce.min(self.source.len());
1364        self.anchor = None;
1365        self.last_edit_kind = None;
1366        // Realised: the marks are in the document now, and the caret sits inside
1367        // them, so there is no delta left to carry. Arm nothing, but remember the
1368        // spot so a *further* toggle before typing starts a clean delta here.
1369        self.pending_marks = InlineMarks::empty();
1370        self.pending_at = Some(self.caret);
1371        self.clamp_caret();
1372        self.record_caret();
1373    }
1374
1375    /// Carry on the marked run just behind `at` — moving its closing delimiters
1376    /// out past the new text — instead of opening a second run of the same marks
1377    /// beside it. Returns whether it did.
1378    ///
1379    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1380    /// A space typed after a bold word steps the caret out of the run, because
1381    /// `**bold **` is not bold; the next character has to step back *in*, or the
1382    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1383    /// runs that read the same to a reader but spell the file in a way nobody
1384    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1385    /// words it isn't marking), and the marks behind it must be exactly the ones
1386    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1387    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1388        if text.is_empty() || text.trim() != text {
1389            return false;
1390        }
1391        let gap_at = self.source[..at].trim_end_matches(|c: char| c == ' ' || c == '\t').len();
1392        // Walk in through the delimiters stacked at that point, innermost last:
1393        // `***both*** ` closes two runs with one `***`, and rejoining means
1394        // getting behind all of them.
1395        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1396        loop {
1397            let Some((kind, content_end)) = self
1398                .editor
1399                .ancestors_at(prev_boundary(&self.source, cut))
1400                .unwrap_or_default()
1401                .into_iter()
1402                .filter(|m| m.span.end == cut)
1403                .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1404            else {
1405                break;
1406            };
1407            if content_end >= cut {
1408                break; // a mark with no closing delimiter to step behind
1409            }
1410            kinds.insert(kind);
1411            cut = content_end;
1412        }
1413        if cut == gap_at || kinds != marks {
1414            return false;
1415        }
1416        // Re-spell the tail: the gap, then the new text, then the delimiters that
1417        // used to close in front of them — read out of the document rather than
1418        // written from a table, so whatever twig spells them with is what moves.
1419        let tail = format!("{}{text}{}", &self.source[gap_at..at], &self.source[cut..gap_at]);
1420        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1421            return false;
1422        }
1423        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1424        self.anchor = None;
1425        self.last_edit_kind = None;
1426        self.pending_marks = InlineMarks::empty();
1427        self.pending_at = Some(self.caret);
1428        self.clamp_caret();
1429        self.record_caret();
1430        true
1431    }
1432
1433    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1434    /// never itself wrapped: a mark around a space draws nothing a reader can
1435    /// see, and in Markdown and Djot it draws its own delimiters instead
1436    /// (`** **`). So the space goes in unmarked — outside any run the armed
1437    /// marks are shedding — and the marks stay armed for the character after it,
1438    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1439    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1440        let base = self.mark_spans_at(at);
1441        // What the *next* character carries: the armed delta resolved against the
1442        // marks in force here, which the space must not quietly drop.
1443        let want = base.iter().map(|(k, _)| *k).collect::<InlineMarks>().xor(marks);
1444        let mut ins_at = at;
1445        for (kind, span) in &base {
1446            if marks.contains(*kind) {
1447                ins_at = ins_at.max(span.end);
1448            }
1449        }
1450        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1451            return;
1452        }
1453        self.rearm(want);
1454        self.record_caret();
1455    }
1456
1457    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1458    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1459    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1460    /// added split evenly around the content — half the growth on each side.
1461    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1462        match self.editor.toggle_inline(s, e, kind) {
1463            Ok(change) => {
1464                self.last_edit_kind = None;
1465                self.refresh();
1466                self.dirty = self.source != self.clean_source;
1467                let added = (change.new.end - change.new.start).saturating_sub(e - s);
1468                let half = added / 2;
1469                (change.new.start + half, change.new.end - half)
1470            }
1471            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1472            // rather than lose the keystroke.
1473            Err(e2) => {
1474                self.status = Some(format!("{kind:?}: {e2}"));
1475                (s, e)
1476            }
1477        }
1478    }
1479
1480    /// The safe offset to splice a block-level break at, given a caret that may
1481    /// sit exactly between an inline mark's content and its own closing
1482    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1483    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1484    /// with nothing following it on the line: the closing `**` renders no
1485    /// glyph of its own, so the caret's "end of line" offset lands right
1486    /// before it). Splicing a paragraph/list/quote break at `off` itself would
1487    /// sever the delimiter from its content, stranding it alone on the new
1488    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1489    /// nested marks closing at the same point (`**_x_**`) all clear together.
1490    /// A no-op everywhere else — mid-run, or past real trailing content, no
1491    /// mark's `content_span` ends exactly at `off`.
1492    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1493        let off = off.min(self.source.len());
1494        self.editor
1495            .ancestors_at(off)
1496            .unwrap_or_default()
1497            .into_iter()
1498            .filter(|m| inline_kind(&m.kind).is_some())
1499            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1500            .map(|m| m.span.end)
1501            .max()
1502            .unwrap_or(off)
1503    }
1504
1505    /// The offset a *delete* aimed at the character before `off` should stop at,
1506    /// when `off` is the start of a run's text and the bytes behind it are that
1507    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1508    /// byte behind the caret at the start of a bold word is not a character the
1509    /// writer can see, let alone one they aimed Backspace at: taking it leaves
1510    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1511    /// delete steps over the whole delimiter to the visible character in front of
1512    /// it instead. Walks out to the *outermost* mark opening there, so
1513    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1514    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1515        let off = off.min(self.source.len());
1516        self.editor
1517            .ancestors_at(off)
1518            .unwrap_or_default()
1519            .into_iter()
1520            .filter(|m| inline_kind(&m.kind).is_some())
1521            .filter(|m| m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off))
1522            .map(|m| m.span.start)
1523            .min()
1524            .unwrap_or(off)
1525    }
1526
1527    /// `off` moved *inside* the run whose closing delimiters end there — the
1528    /// other offset the rich view draws in the same place, since a `**` renders
1529    /// no glyph of its own. `**bold**` has a caret home on each side of its
1530    /// closing delimiter, one column apart on screen and eight bytes and a whole
1531    /// run apart in the file, and a plain ← lands on the outer one whenever a
1532    /// space follows the phrase. The inner one is what the writer is pointing at
1533    /// there: the end of their bold word. Walks in through every mark closing at
1534    /// that point, innermost last, so `***both***` lands inside both. A no-op
1535    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1536    fn step_inside_close_delims(&mut self, off: usize) -> usize {
1537        let mut off = off.min(self.source.len());
1538        loop {
1539            let inner = self
1540                .editor
1541                .ancestors_at(prev_boundary(&self.source, off))
1542                .unwrap_or_default()
1543                .into_iter()
1544                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1545                .filter_map(|m| m.content_span.clone().map(|c| c.end))
1546                .filter(|&end| end < off)
1547                .max();
1548            match inner {
1549                Some(end) => off = end,
1550                None => return off,
1551            }
1552        }
1553    }
1554
1555    /// The mirror at the opening edge: `off` moved inside the run whose
1556    /// delimiters *start* there, onto the first character of its text. See
1557    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1558    fn step_inside_open_delims(&mut self, off: usize) -> usize {
1559        let mut off = off.min(self.source.len());
1560        loop {
1561            let inner = self
1562                .editor
1563                .ancestors_at(off)
1564                .unwrap_or_default()
1565                .into_iter()
1566                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
1567                .filter_map(|m| m.content_span.clone().map(|c| c.start))
1568                .filter(|&start| start > off)
1569                .min();
1570            match inner {
1571                Some(start) => off = start,
1572                None => return off,
1573            }
1574        }
1575    }
1576
1577    /// The inline mark kinds whose span covers `off`, each with that span — the
1578    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
1579    /// ids instead. Used to shed a mark by stepping past the end of its run.
1580    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
1581        let off = off.min(self.source.len());
1582        self.editor
1583            .ancestors_at(off)
1584            .unwrap_or_default()
1585            .into_iter()
1586            .filter(|m| off < m.span.end)
1587            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
1588            .collect()
1589    }
1590
1591    /// Insert clipboard `text` at the caret, replacing the selection if there is
1592    /// one — always its own undo step, whatever its length.
1593    ///
1594    /// Provenance is the whole point, and only the caller has it. `insert` reads
1595    /// a lone character as a keystroke and folds it into the run around it,
1596    /// which is right for typing and wrong for a one-character paste: that paste
1597    /// would vanish mid-run on an undo it was never part of, and the characters
1598    /// the user actually typed would go with it. Length can't tell the two
1599    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
1600    /// caller comes through is what says which happened.
1601    pub fn paste(&mut self, text: &str) {
1602        // Pasting against a block picture dissolves it exactly as typing does,
1603        // and for the same reason — see `open_paragraph_at_block_media`.
1604        self.open_paragraph_at_block_media(text);
1605        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1606        self.splice(s, e, text, EditKind::Other);
1607    }
1608
1609    /// Replace `[start, end)` with `text` as one step of an IME composition —
1610    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
1611    /// folds into a single undo.
1612    ///
1613    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
1614    /// dozen calls here, each replacing the last one's provisional bytes, and an
1615    /// undo step per call means undoing a word means pressing ⌘Z until the reading
1616    /// unspools backwards through kana — the intermediate states were never text
1617    /// the user wrote. Only the frontend knows a call is provisional (the bytes
1618    /// look like any other edit), so the door the caller comes through is what
1619    /// says so, exactly as it is for [`paste`](Self::paste) versus
1620    /// [`insert`](Self::insert).
1621    ///
1622    /// Pair with [`end_composition`](Self::end_composition), or the *next*
1623    /// composition folds into this one.
1624    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
1625        self.splice(start, end, text, EditKind::Compose);
1626    }
1627
1628    /// Close the open composition run, so the next one is its own undo step.
1629    /// Call when the IME commits or withdraws a composition.
1630    ///
1631    /// Only clears a *composition* run: a frontend that reports an end it never
1632    /// began (some IMEs unmark unprompted) would otherwise split the run of
1633    /// typing around it into two undo steps for no reason the user can see.
1634    pub fn end_composition(&mut self) {
1635        if self.last_edit_kind == Some(EditKind::Compose) {
1636            self.last_edit_kind = None;
1637        }
1638    }
1639
1640    // ── the clipboard's rich flavor ──────────────────────────────────────────
1641
1642    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
1643    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
1644    /// nothing is selected, or when the selection doesn't render (the caller
1645    /// still has [`selected_text`](Self::selected_text), which is what to publish
1646    /// as `text/plain` either way).
1647    ///
1648    /// **The fragment is a source substring, and that is the honest limit here.**
1649    /// It's parsed standalone, so a selection whose meaning depends on its
1650    /// surroundings converts as what it literally says rather than what it looks
1651    /// like on screen: half a list item is a paragraph, a row torn out of a table
1652    /// is the text of a row, the `**` of a bold run selected without its closing
1653    /// `**` is two asterisks. Every one of those still *renders* — there's no
1654    /// error to report — it just renders as the fragment and not as the document.
1655    /// Widening the range to whole blocks would publish text the user didn't
1656    /// select, which is a worse lie than a fragment being a fragment; the plain
1657    /// flavor has the same substring, so the two flavors at least agree.
1658    pub fn selection_html(&mut self) -> Option<String> {
1659        let (start, end) = self.selection()?;
1660        let inline = self.selection_is_inline(start, end);
1661        let html = html::render_fragment(&self.source[start..end], self.format)?;
1662        Some(match inline {
1663            true => html::strip_sole_paragraph(html),
1664            false => html,
1665        })
1666    }
1667
1668    /// Paste the clipboard's `text/html` flavor, converting it to this document's
1669    /// format first. Its own undo step, like any [`paste`](Self::paste).
1670    ///
1671    /// Returns whether it landed. `false` means the HTML didn't convert to
1672    /// anything worth pasting — the caller should fall back to the plain flavor
1673    /// rather than treat it as an error. The `html` module has the full list of
1674    /// what that covers: a table twig won't build, markup it doesn't recognise,
1675    /// an empty result.
1676    pub fn paste_html(&mut self, html: &str) -> bool {
1677        match html::parse_fragment(html, self.format) {
1678            Some(source) => {
1679                self.paste(&source);
1680                true
1681            }
1682            None => false,
1683        }
1684    }
1685
1686    /// Does the selection live *inside* a single top-level block?
1687    ///
1688    /// The question [`selection_html`](Self::selection_html) needs and the
1689    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
1690    /// whether the user selected one word of a sentence or a whole paragraph, and
1691    /// only the document knows which. Selecting a word and pasting into Docs
1692    /// should extend the line you paste into; selecting the paragraph should make
1693    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
1694    /// is an artifact of standalone parsing), and one that covers a whole block —
1695    /// or spans two — keeps its structure.
1696    ///
1697    /// Reads the block from twig rather than guessing from the bytes:
1698    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
1699    /// block containing an offset, and two ends inside the same one cannot have
1700    /// crossed a block boundary.
1701    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
1702        // The last *character*, not `end - 1`: the selection's end is exclusive
1703        // and may sit mid-codepoint's-worth of bytes past the last char.
1704        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
1705            return false;
1706        };
1707        let (Some(head), Some(tail)) = (self.top_block_span(start), self.top_block_span(start + off))
1708        else {
1709            return false;
1710        };
1711        head == tail && !(start <= head.start && end >= head.end)
1712    }
1713
1714    /// The byte span of the top-level block containing `offset`, or `None` at an
1715    /// offset that belongs to no block (the blank line between two of them).
1716    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
1717        self.editor
1718            .ancestors_at(offset)
1719            .ok()?
1720            .get(1)
1721            .map(|m| m.span.clone())
1722    }
1723
1724    // ── indentation ──────────────────────────────────────────────────────────
1725
1726    /// One indent level.
1727    ///
1728    /// Two spaces, not the four both frontends type for Tab today, because in a
1729    /// markdown document four columns isn't a width — it's a *meaning*. Four
1730    /// spaces at the head of a line is markdown's indented-code-block marker, so
1731    /// one Tab on a paragraph would reparse it into code and style it as such;
1732    /// two cannot, and the line stays the prose it was. Two is also exactly
1733    /// where a `- ` bullet's content starts, so an indented line lands under its
1734    /// parent item's text instead of beside it — the column a list-aware indent
1735    /// has to hit anyway, which keeps this width from being relitigated later.
1736    const INDENT: &'static str = "  ";
1737
1738    /// Indent the selected lines — or the caret's line, with no selection — by
1739    /// one level (Tab).
1740    pub fn indent(&mut self) {
1741        self.reindent(true);
1742        // Nesting changes an ordered list's numbering (the nested item restarts,
1743        // its old siblings resume) — keep the source markers in step.
1744        self.renumber_here();
1745        // Nesting an empty `-` item under a text line reparses that text as a
1746        // setext heading; swap the dash for a `*` before it can (a no-op unless
1747        // the collapse actually happened).
1748        self.avoid_setext_collapse();
1749    }
1750
1751    /// Take one indent level back off the selected lines, or the caret's line
1752    /// (Shift+Tab). A line with no indentation is left exactly as it is.
1753    ///
1754    /// A line with *less* than a full level gives back what it has rather than
1755    /// refusing: outdent's job is to walk a line left, and real documents — hand
1756    /// written, or reflowed by some other editor — are full of indentation that
1757    /// was never a clean multiple of anything. Refusing there would strand the
1758    /// line at a depth Shift+Tab couldn't undo.
1759    pub fn outdent(&mut self) {
1760        self.reindent(false);
1761        self.renumber_here();
1762    }
1763
1764    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
1765    ///
1766    /// One splice across the whole line range, never one per line: a Tab is one
1767    /// thing the user did, so it has to be one undo step and one reparse. Per
1768    /// line, twig would reparse the document once per line and leave a stack of
1769    /// steps that Shift+⌘Z walks back one line at a time.
1770    fn reindent(&mut self, add: bool) {
1771        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
1772        let start = source_line_range(&self.source, sel_start).start;
1773        let end = source_line_range(&self.source, sel_end).end;
1774        let region = self.source[start..end].to_string();
1775        let lines: Vec<&str> = region.split('\n').collect();
1776        // A blank line has no text to move, and padding it would leave nothing
1777        // but trailing whitespace — but Tab on a blank line *is* a request for
1778        // indentation to type into, so the skip only applies where the op has
1779        // other lines to do real work on.
1780        let skip_blank = add && lines.len() > 1;
1781
1782        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
1783        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
1784        let mut line_off = start;
1785        for (i, full) in lines.iter().enumerate() {
1786            if i > 0 {
1787                out.push('\n');
1788            }
1789            // A list item moves by having its whole leading prefix *replaced*,
1790            // never by having spaces pushed in front of the line. twig spells
1791            // both prefixes, so the quote markers, the parent's indent and an
1792            // ordered marker's extra column all come out right without leaf
1793            // measuring any of them — and a line that only looks like an item
1794            // (a Djot continuation) reports no marker and is left to the plain
1795            // path, where a Tab is just a Tab.
1796            let marker = self.list_marker_on_line(line_off);
1797            let own = marker
1798                .as_ref()
1799                .map(|m| m.marker_start - m.line_start)
1800                .unwrap_or(0);
1801            let delta = if add {
1802                if skip_blank && full.trim().is_empty() {
1803                    out.push_str(full);
1804                    0
1805                } else if marker.is_some() && self.first_item_of_list(line_off) {
1806                    // The first item of a list has no preceding sibling to nest
1807                    // under, so a Tab here can't spell a sub-list — twig would
1808                    // reparse the shoved-over marker as the same list, only
1809                    // indented, which Shift+Tab then can't cleanly undo. Leave the
1810                    // item where it is, the way every list editor refuses to
1811                    // over-indent a list's first line.
1812                    out.push_str(full);
1813                    0
1814                } else if marker.is_some() {
1815                    // Nesting means standing where a *continuation* of this line
1816                    // would stand: past the parent's marker, inside its content
1817                    // column. That is `continuation_prefix`, less a checkbox.
1818                    let new = self.nesting_prefix_at(line_off);
1819                    let delta = new.len() as isize - own as isize;
1820                    out.push_str(&new);
1821                    out.push_str(&full[own..]);
1822                    delta
1823                } else {
1824                    out.push_str(Self::INDENT);
1825                    out.push_str(full);
1826                    Self::INDENT.len() as isize
1827                }
1828            } else if marker.is_some() {
1829                // Unnesting is the mirror: stand where the parent item's own
1830                // line starts, which drops exactly the level it contributed.
1831                let new = self.outdent_prefix_at(line_off);
1832                let delta = new.len() as isize - own as isize;
1833                out.push_str(&new);
1834                out.push_str(&full[own..]);
1835                delta
1836            } else {
1837                // A plain line gives back the ordinary step.
1838                let strip = outdent_width(full, Self::INDENT.len());
1839                out.push_str(&full[strip..]);
1840                -(strip as isize)
1841            };
1842            deltas.push(delta);
1843            line_off += full.len() + 1;
1844        }
1845        // Nothing to give back. Returning before the splice keeps an outdent at
1846        // column zero from spending an undo step on a document it never changed.
1847        if deltas.iter().all(|d| *d == 0) {
1848            return;
1849        }
1850
1851        // Every line's text keeps its offset *within the line*, so the caret is
1852        // remapped by its column, not by its byte offset — which the prefixes on
1853        // the lines above it have already invalidated.
1854        let remap = |off: usize| -> usize {
1855            let (mut old_ls, mut new_ls) = (start, start);
1856            for (line, delta) in lines.iter().zip(&deltas) {
1857                let old_le = old_ls + line.len();
1858                let new_len = (line.len() as isize + delta) as usize;
1859                if off <= old_le {
1860                    let col = (off - old_ls) as isize;
1861                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
1862                }
1863                old_ls = old_le + 1;
1864                new_ls += new_len + 1;
1865            }
1866            start + out.len()
1867        };
1868        let placed = match self.selection() {
1869            // Keep the rewritten region selected, the way a container toggle
1870            // keeps its own: it leaves a second Tab aimed at the same lines
1871            // rather than at whatever the shifted offsets now happen to cover.
1872            Some(_) => (start + out.len(), Some(start)),
1873            None => (remap(self.caret), None),
1874        };
1875
1876        // A rolled-back splice leaves the old source in place, where every offset
1877        // computed above addresses text that was never written.
1878        if !self.splice(start, end, &out, EditKind::Other) {
1879            return;
1880        }
1881        // `splice` re-anchors to the end of the `Change`, which for a whole-region
1882        // rewrite is the last line's end — nowhere the caret was. Place it, then
1883        // re-record the caret so this is the state redo restores, not the one
1884        // `splice` left behind from the `Change`.
1885        self.caret = placed.0.min(self.source.len());
1886        self.anchor = placed.1;
1887        self.clamp_caret();
1888        self.record_caret();
1889    }
1890
1891    /// The Enter key.
1892    ///
1893    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
1894    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
1895    /// caret is in decides what actually gets written.
1896    ///
1897    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
1898    ///                            block at the caret and reopens its container
1899    ///   - list item            → likewise: the next item, its indent, quote
1900    ///                            prefix and `[ ]` box all reproduced by twig —
1901    ///                            except an *empty* item, which exits the list
1902    ///   - block quote          → likewise: a new paragraph inside the quote
1903    ///   - heading              → a new *paragraph*, not another heading
1904    ///   - code block           → a literal newline (stay in the block)
1905    ///   - blank line           → a literal newline (one Backspace undoes it)
1906    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
1907    ///                            visible line
1908    ///
1909    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
1910    /// and it is better at it: it drops the whitespace the caret was sitting in
1911    /// front of instead of stranding it at the head of the second half, and it
1912    /// knows continuations leaf's marker scan never covered — a checklist item
1913    /// continues as an *unchecked* checklist item rather than a plain bullet.
1914    ///
1915    /// The exceptions above are exceptions because `split_block` is either wrong
1916    /// there or refuses: parting a fence yields two fences with the code split
1917    /// between them, parting a heading yields a second heading where every editor
1918    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
1919    /// table all report an error rather than a split.
1920    pub fn newline(&mut self) {
1921        if self.view == View::Source {
1922            self.insert_raw("\n");
1923            return;
1924        }
1925        // Enter over a selection replaces it with a paragraph break.
1926        if let Some((s, e)) = self.selection() {
1927            self.splice(s, e, "\n\n", EditKind::Other);
1928            return;
1929        }
1930        // A caret resting exactly between an inline mark's content and its own
1931        // closing delimiter (`**bold**` with nothing after it on the line —
1932        // the WYSIWYG caret's natural end-of-line position) must not splice a
1933        // block break there: every path below eventually does via
1934        // `insert_raw`/`self.caret`, and splicing before the hidden closing
1935        // delimiter would strand it alone on the new line.
1936        self.caret = self.skip_trailing_close_delims(self.caret);
1937        // The block the caret is in. `block_offset_for_caret` nudges off a line
1938        // end (where the caret sits at the doc level); on a bare line (e.g. an
1939        // empty list item) fall back to the caret so the enclosing list/quote is
1940        // still visible in the ancestors.
1941        let off = self.block_offset_for_caret().unwrap_or(self.caret);
1942        let kinds: Vec<Kind> = self
1943            .editor
1944            .ancestors_at(off)
1945            .map(|c| c.into_iter().map(|m| m.kind).collect())
1946            .unwrap_or_default();
1947        let has = |k: Kind| kinds.contains(&k);
1948
1949        if has(Kind::CodeBlock) {
1950            self.insert_raw("\n");
1951            return;
1952        }
1953        // An *empty* list item exits the list — the standard double-Enter — which
1954        // `split_block` reports as an error rather than a split (there is no
1955        // content to part), so it stays leaf's. `list_marker_on_line` is itself
1956        // the AST gate — it answers from the tree, so a `- ` that reads as a
1957        // marker byte-for-byte but opens no item (a setext underline, a Djot
1958        // continuation line) never reaches here.
1959        if let Some(marker) = self.list_marker_on_line(self.caret)
1960            && self.item_is_empty(&marker)
1961        {
1962            self.exit_list(&marker);
1963            return;
1964        }
1965        // On an *empty* paragraph line, a lone Enter should add a single blank line,
1966        // not another full paragraph break — so it moves down one line and one
1967        // Backspace undoes it, not two. (`split_block` errors here too.)
1968        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
1969        let line_end = self.source[self.caret..]
1970            .find('\n')
1971            .map_or(self.source.len(), |i| self.caret + i);
1972        if self.source[line_start..line_end].trim().is_empty() {
1973            self.insert_raw("\n");
1974            return;
1975        }
1976        // In `Preserve` flow a soft break is a *visible* line the author means to
1977        // make, so Enter writes a single `\n` and typing continues the same
1978        // paragraph on the next line — the behaviour of an ordinary text editor.
1979        // A second Enter then lands on the blank line above and takes the
1980        // empty-line branch, so double-Enter still promotes to a full paragraph
1981        // break; and Backspace, which deletes a lone `\n` over a soft break,
1982        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
1983        // render as an invisible space, so Enter keeps making the paragraph break
1984        // that actually shows.
1985        //
1986        // Only in running prose. A list or a quote has a continuation of its own
1987        // to write, and a `\n` there is not a soft line but a lost container.
1988        let in_container =
1989            has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
1990        if self.line_flow == LineFlow::Preserve && !in_container {
1991            self.insert_raw("\n");
1992            return;
1993        }
1994        // A heading gets a *paragraph*, never a second heading: Enter at the end
1995        // of a title is how every editor is asked for the body under it, and
1996        // `split_block` would repeat the `#` instead. Whitespace at the split
1997        // point goes with the break rather than opening the new paragraph, which
1998        // is what `split_block` does everywhere else.
1999        if has(Kind::Heading) {
2000            let mut end = self.caret;
2001            while self.source.as_bytes().get(end) == Some(&b' ') {
2002                end += 1;
2003            }
2004            self.splice(self.caret, end, "\n\n", EditKind::Other);
2005            return;
2006        }
2007        self.split_block_here();
2008    }
2009
2010    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2011    /// the caret in the second half.
2012    ///
2013    /// twig reopens whatever the first half was inside of — the bullet with its
2014    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2015    /// reason this replaced the markup leaf used to spell from the line's bytes.
2016    /// It renumbers nothing, though: a new item mid-list is written with its
2017    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2018    /// behind it, folded into the same undo step.
2019    ///
2020    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2021    /// shape still moves the caret down rather than swallowing the keystroke.
2022    fn split_block_here(&mut self) {
2023        match self.editor.split_block(self.caret) {
2024            Ok(change) => {
2025                self.last_edit_kind = None;
2026                self.refresh();
2027                self.anchor = None;
2028                self.caret = change.new.end;
2029                self.dirty = self.source != self.clean_source;
2030                self.status = None;
2031                self.clamp_caret();
2032                self.record_caret();
2033                // Aimed at the new block's *start*: the caret twig leaves is one
2034                // past the marker it wrote, where there is no list in reach.
2035                self.renumber_at(change.new.start);
2036            }
2037            Err(_) => self.insert_raw("\n\n"),
2038        }
2039    }
2040
2041    /// Whether the item on the marker's line carries no content — the shape
2042    /// double-Enter reads as "I'm done with this list."
2043    fn item_is_empty(&self, line: &ListMarker) -> bool {
2044        let content_start = line.content_start().min(self.source.len());
2045        let line_end = self.source[self.caret..]
2046            .find('\n')
2047            .map(|i| self.caret + i)
2048            .unwrap_or(self.source.len());
2049        self.source[content_start..line_end.max(content_start)]
2050            .trim()
2051            .is_empty()
2052    }
2053
2054    /// Leave the list: replace the empty item's marker with a blank line, so the
2055    /// caret lands in a fresh paragraph below it.
2056    ///
2057    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2058    /// quote), and the caret's new line keeps the `> ` it was already behind —
2059    /// leaving the list without also leaving the quote.
2060    fn exit_list(&mut self, line: &ListMarker) {
2061        let prefix = self.quote_prefix_at(line.marker_start);
2062        let blank = prefix.trim_end();
2063        self.splice(
2064            line.line_start,
2065            self.caret,
2066            &format!("{blank}\n{prefix}"),
2067            EditKind::Other,
2068        );
2069    }
2070
2071    /// What a line continuing the containers at `off` has to open with — the
2072    /// quote markers reproduced, each enclosing item's marker as its width in
2073    /// spaces. Also the column a nested item's marker stands in, which is what
2074    /// makes it Tab's answer.
2075    fn continuation_prefix_at(&mut self, off: usize) -> String {
2076        self.editor
2077            .document()
2078            .and_then(|mut d| d.continuation_prefix(off))
2079            .map(|p| p.text)
2080            .unwrap_or_default()
2081    }
2082
2083    /// The column a *nested list* may open at inside the item at `off` — which
2084    /// is not always where the item's own text continues.
2085    ///
2086    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2087    /// it is markup a rich view hides, and the item's own wrapped text does
2088    /// stand past it. But a nested list may only open at the *list* marker's
2089    /// column, and four columns further in is an indented continuation of the
2090    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2091    /// So the box's own width goes back.
2092    ///
2093    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2094    /// reports the list marker's column apart from the box; `checked` is what
2095    /// says a box is there at all, so only its width is being measured here.
2096    fn nesting_prefix_at(&mut self, off: usize) -> String {
2097        let cont = self.continuation_prefix_at(off);
2098        let Some(item) = self.innermost_list_item(off) else {
2099            return cont;
2100        };
2101        if item.checked.is_none() {
2102            return cont;
2103        }
2104        let box_width = item
2105            .marker_span
2106            .and_then(|m| self.source.get(m))
2107            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2108            .unwrap_or(0);
2109        // The trailing columns are the ones the item's own marker contributed,
2110        // so trimming from the end leaves any quote prefix standing.
2111        cont[..cont.len().saturating_sub(box_width)].to_string()
2112    }
2113
2114    /// Where the line of the item *containing* the item at `off` begins — the
2115    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2116    /// parent contributed. The quote prefix alone for a top-level item, which
2117    /// has no level left to give.
2118    fn outdent_prefix_at(&mut self, off: usize) -> String {
2119        let items: Vec<usize> = self
2120            .editor
2121            .document()
2122            .and_then(|mut d| d.ancestors_at_caret(off))
2123            .map(|c| {
2124                c.into_iter()
2125                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2126                    .map(|m| m.span.start)
2127                    .collect()
2128            })
2129            .unwrap_or_default();
2130        // The second-innermost item is the parent; its own line's indent is the
2131        // target. `list_marker_on_line` gives that line's prefix directly.
2132        let parent = items.len().checked_sub(2).map(|i| items[i]);
2133        match parent.and_then(|p| self.list_marker_on_line(p)) {
2134            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2135            None => self.quote_prefix_at(off),
2136        }
2137    }
2138
2139    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2140    /// inside one, `"> > "` inside two.
2141    ///
2142    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2143    /// the `>` and the space after it are twig's spelling rather than leaf's.
2144    /// The whole line prefix can't answer this: it also carries the indent of
2145    /// whatever the quote holds, which a blank separator line must *not* repeat.
2146    fn quote_prefix_at(&mut self, off: usize) -> String {
2147        let Ok(chain) = self
2148            .editor
2149            .document()
2150            .and_then(|mut d| d.ancestors_at_caret(off))
2151        else {
2152            return String::new();
2153        };
2154        let quotes: Vec<usize> = chain
2155            .iter()
2156            .filter(|m| m.kind == Kind::BlockQuote)
2157            .map(|m| m.node_id as usize)
2158            .collect();
2159        let Ok(nodes) = self.editor.nodes() else {
2160            return String::new();
2161        };
2162        quotes
2163            .iter()
2164            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2165            .filter_map(|s| self.source.get(s))
2166            .collect()
2167    }
2168
2169    /// Whether the item at `off` sits inside another one — the test Backspace
2170    /// uses to choose between outdenting and dropping the marker.
2171    ///
2172    /// Counted from the AST rather than from the line's leading whitespace,
2173    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2174    fn item_is_nested(&mut self, off: usize) -> bool {
2175        self.editor
2176            .document()
2177            .and_then(|mut d| d.ancestors_at_caret(off))
2178            .map(|c| {
2179                c.into_iter()
2180                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2181                    .count()
2182                    > 1
2183            })
2184            .unwrap_or(false)
2185    }
2186
2187    /// The innermost list item containing `probe`, under twig's **caret**
2188    /// containment rule — a block's end is inside it.
2189    ///
2190    /// Half-open containment can't answer this. An empty item's span is exactly
2191    /// its marker, so the caret sitting after `- ` is one past the end and the
2192    /// item it is plainly in tests as out of reach; that is the shape
2193    /// double-Enter has to recognise to leave the list.
2194    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2195        let chain = self
2196            .editor
2197            .document()
2198            .and_then(|mut d| d.ancestors_at_caret(probe))
2199            .ok()?;
2200        let id = chain
2201            .iter()
2202            .rev()
2203            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2204            .node_id as usize;
2205        self.editor.nodes().ok()?.get(id).cloned()
2206    }
2207
2208    /// The list marker opening `off`'s line, per twig — `None` when that line
2209    /// opens no list item.
2210    ///
2211    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2212    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2213    /// and it is `None` on a *continuation* line, which opens nothing. That last
2214    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2215    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2216    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2217    /// parser knows which document it is looking at.
2218    ///
2219    /// The item's own marker is separated out via its
2220    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2221    /// the containers around it contribute and what the item does.
2222    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2223        let off = off.min(self.source.len());
2224        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2225        // The prefix belongs to a list only when an item's marker closes it —
2226        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2227        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2228        let marker = item.marker_span.clone()?;
2229        if marker.end != prefix.end {
2230            return None;
2231        }
2232        Some(ListMarker {
2233            line_start: prefix.start,
2234            marker_start: marker.start,
2235            text: self.source.get(prefix)?.to_string(),
2236        })
2237    }
2238
2239    /// Whether the list item on `line_start`'s line is the **first item** of its
2240    /// list — the one Tab must not nest, because nesting needs a preceding
2241    /// sibling to become the new parent and a first item has none. `false` for a
2242    /// line that isn't a list item, and for an item with a sibling above it (the
2243    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2244    /// the same in a setext underline that opens no list at all.
2245    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2246        let Some(marker) = self.list_marker_on_line(line_start) else {
2247            return false;
2248        };
2249        // Probe just inside the marker, where the item's own node is in reach —
2250        // the marker offset itself can resolve to the enclosing list, not the
2251        // `list_item`, whose span starts at the marker.
2252        let probe = marker.content_start().min(self.source.len());
2253        let Some(item) = self.innermost_list_item(probe) else {
2254            return false;
2255        };
2256        let Ok(nodes) = self.editor.nodes() else {
2257            return false;
2258        };
2259        match item.parent {
2260            // First when the parent list opens with this very item.
2261            Some(pid) => nodes
2262                .get(pid.0 as usize)
2263                .is_some_and(|p| p.first_child == Some(item.id)),
2264            // A parentless item is trivially the first (and only) one.
2265            None => true,
2266        }
2267    }
2268
2269    pub fn backspace(&mut self) {
2270        if let Some((s, e)) = self.selection() {
2271            self.splice(s, e, "", EditKind::Other);
2272            return;
2273        }
2274        // WYSIWYG: Backspace at the very start of a list item's content is a
2275        // structural key, not a character delete — it walks the "un-indent, then
2276        // un-list" ladder every list editor gives that keystroke (outdent a
2277        // nested item, strip a top-level one's marker to a paragraph). In source
2278        // view the `- ` is visible text the user is deleting a byte of, so it
2279        // keeps its literal meaning there, like Enter does.
2280        if self.view != View::Source && self.backspace_list_start() {
2281            return;
2282        }
2283        // WYSIWYG: and the same at the start of a heading's content — the `# `
2284        // there is markup the rich view hides, not text the user typed.
2285        if self.view != View::Source && self.backspace_heading_start() {
2286            return;
2287        }
2288        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2289        // the markup apart under a caret that cannot see it — see
2290        // `delete_around_block_media`.
2291        if self.view != View::Source && self.delete_around_block_media(false) {
2292            return;
2293        }
2294        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2295        // stop, not a single newline. On a line with no text of its own, the byte
2296        // before the caret is a `\n` that spells part of a block boundary — the gap
2297        // between two blocks, drawn but never a caret home. Removing just it strands
2298        // the caret in that gap and leaves an odd blank line the eye reads as one
2299        // separator but the caret can't land on: the "extra newline" left behind
2300        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2301        // Deleting to the previous stop instead collapses the whole break at once,
2302        // landing the caret at the end of the block above. Two blank lines in a row
2303        // are one stop apart, so this still removes exactly one — the lone-Enter /
2304        // lone-Backspace symmetry the empty-line case is built on is untouched.
2305        if self.view != View::Source
2306            && self.caret > self.caret_floor()
2307            && self.caret_on_blank_line()
2308            && let Some(stop) = self.vmap.stop_before(self.caret)
2309        {
2310            let stop = stop.max(self.caret_floor());
2311            if stop < self.caret {
2312                self.splice(stop, self.caret, "", EditKind::Delete);
2313                return;
2314            }
2315        }
2316        if self.caret > self.caret_floor() {
2317            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2318            // takes the whole tag — a single-byte step would leave a broken `<br`
2319            // showing in the cell. Rich view only (source view edits the literal).
2320            if self.view != View::Source
2321                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2322            {
2323                let start = start.max(self.caret_floor());
2324                if start < end {
2325                    self.splice(start, end, "", EditKind::Delete);
2326                    return;
2327                }
2328            }
2329            // Aim the delete at the character the writer can *see* behind the
2330            // caret, never at a delimiter the rich view drew nothing for. Two
2331            // steps, and either can apply: from the far side of a run's closing
2332            // `**` step back into the run (the caret is drawn at the end of its
2333            // word), and at the start of a run's text step out past its opening
2334            // `**` to the character in front of it, leaving the run standing.
2335            // Without them a plain Backspace unspells the phrase it is editing
2336            // and leaves a literal asterisk on screen.
2337            let end = if self.view == View::Source {
2338                self.caret
2339            } else {
2340                let inside = self.step_inside_close_delims(self.caret);
2341                self.skip_leading_open_delims(inside).max(self.caret_floor())
2342            };
2343            // Never delete back across the floor — that would eat hidden
2344            // frontmatter the WYSIWYG caret can't even see.
2345            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2346            // Take a hidden escape backslash with the char it escapes: the rich
2347            // view draws `\*` as a single `*`, so Backspace over it must delete
2348            // both bytes, never strand the `\` as a lone visible backslash (the
2349            // mirror of the Hidden-mode typing that wrote the escape). Source view
2350            // shows the `\`, so there it is an ordinary character.
2351            if self.view != View::Source
2352                && prev > self.caret_floor()
2353                && self.is_hidden_escape(prev - 1)
2354            {
2355                prev -= 1;
2356            }
2357            if prev < end {
2358                self.splice(prev, end, "", EditKind::Delete);
2359            }
2360        }
2361    }
2362
2363    /// Whether the caret's own source line holds nothing but whitespace — an
2364    /// empty paragraph, or the blank line a block boundary is spelled with. The
2365    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2366    /// no text of its own, so the newline before the caret belongs to the gap
2367    /// between blocks rather than to any word the caret is editing.
2368    fn caret_on_blank_line(&self) -> bool {
2369        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2370        let line_end = self.source[self.caret..]
2371            .find('\n')
2372            .map_or(self.source.len(), |i| self.caret + i);
2373        self.source[line_start..line_end].trim().is_empty()
2374    }
2375
2376    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2377    /// `edge` side — the byte range to delete whole. A table row is one source
2378    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2379    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2380    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2381    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2382    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2383    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2384    /// apart — no ancestor walk needed. Rich view only; source view shows the
2385    /// literal tag and deletes it a byte at a time.
2386    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2387        let caret = self.caret;
2388        let nodes = self.nodes();
2389        let src = self.source.as_bytes();
2390        nodes
2391            .iter()
2392            .find(|n| {
2393                n.kind == Kind::HardBreak
2394                    && n.span.start < n.span.end
2395                    && src.get(n.span.start) == Some(&b'<')
2396                    && match edge {
2397                        BreakEdge::Backward => n.span.end == caret,
2398                        BreakEdge::Forward => n.span.start == caret,
2399                    }
2400            })
2401            .map(|n| (n.span.start, n.span.end))
2402    }
2403
2404    /// Whether the source byte at `off` is a backslash twig consumed as an escape
2405    /// (hidden in the rich view), as against a literal backslash (drawn). A
2406    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2407    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2408    /// round-trip needed.
2409    fn is_hidden_escape(&self, off: usize) -> bool {
2410        let b = self.source.as_bytes();
2411        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2412    }
2413
2414    /// Backspace's list behaviour: when the caret sits exactly at the start of a
2415    /// list item's content (right after its marker), outdent the item if it's
2416    /// nested, else strip the marker so it becomes a paragraph. Returns whether
2417    /// it acted — `false` leaves Backspace its ordinary character delete.
2418    fn backspace_list_start(&mut self) -> bool {
2419        let Some(marker) = self.list_marker_on_line(self.caret) else {
2420            return false;
2421        };
2422        // Only right after the marker. That the line opens a real item is
2423        // already settled: `list_marker_on_line` answers from the tree.
2424        if self.caret != marker.content_start() {
2425            return false;
2426        }
2427        if self.item_is_nested(marker.marker_start) {
2428            // Nested: give back one level, keeping the marker and carrying the
2429            // caret with it.
2430            self.outdent();
2431        } else {
2432            // Top level: drop the marker, leaving a paragraph, then renumber the
2433            // siblings the removed item was counted among. Only the marker goes —
2434            // a quote prefix in front of it still has a quote to hold up.
2435            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2436            self.renumber_here();
2437        }
2438        true
2439    }
2440
2441    /// Backspace's heading behaviour: with the caret exactly at the start of an
2442    /// ATX heading's content — right after the `#` marker the rich view hides —
2443    /// strip the marker so the line becomes a paragraph. The peer of
2444    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2445    /// reasoning: hidden block markup is structure, so the keystroke over it is
2446    /// structural.
2447    ///
2448    /// Without this the ordinary delete takes the space out of `# Title` and
2449    /// leaves `#Title`, which is no longer a heading at all — the hash the view
2450    /// had been hiding surfaces as literal text the user has to delete a second
2451    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2452    /// other end) goes with the marker for the same reason.
2453    ///
2454    /// Returns whether it acted; `false` leaves Backspace its character delete.
2455    fn backspace_heading_start(&mut self) -> bool {
2456        let caret = self.caret;
2457        // The heading whose content opens exactly at the caret. A bare `#` has no
2458        // content span at all — its content starts (and ends) where the line does.
2459        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2460            let (start, end) = match &n.content_span {
2461                Some(c) => (c.start, c.end),
2462                None => (n.span.end, n.span.end),
2463            };
2464            (n.kind == Kind::Heading && start == caret)
2465                .then(|| (n.span.clone(), end, n.marker_span.clone()))
2466        }) else {
2467            return false;
2468        };
2469        // twig reports the marker's own extent, so there is nothing to walk back
2470        // over and no `#` in this file. A setext heading has no marker — its
2471        // content opens the line — so it falls through to the ordinary delete,
2472        // as does anything else sitting at a content start.
2473        // `m.end == caret` is what excludes a setext heading, whose marker is the
2474        // underline *after* the content rather than a prefix before it.
2475        let Some(marker) = marker.filter(|m| m.end == caret) else {
2476            return false;
2477        };
2478        let start = marker.start;
2479        // A closing `#` sequence is hidden too, so it can't be left behind. Only
2480        // when the tail really is one: trailing spaces alone are nothing to strip.
2481        let tail = &self.source[content_end..span.end];
2482        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2483            let kept = self.source[caret..content_end].to_string();
2484            self.splice(start, span.end, &kept, EditKind::Other);
2485            // The splice leaves the caret past the text it re-wrote; the caret
2486            // belongs where the content now starts, which is where it already was.
2487            self.caret = start;
2488            self.record_caret();
2489        } else {
2490            self.splice(start, caret, "", EditKind::Other);
2491        }
2492        true
2493    }
2494
2495    pub fn delete_forward(&mut self) {
2496        if let Some((s, e)) = self.selection() {
2497            self.splice(s, e, "", EditKind::Other);
2498        } else if self.caret < self.source.len() {
2499            // The mirror of Backspace's: forward-delete in front of a picture
2500            // would eat the `!` off its markup and leave a link where a photo was.
2501            if self.view != View::Source && self.delete_around_block_media(true) {
2502                return;
2503            }
2504            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2505            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2506            // strands a broken `<br` in the cell.
2507            if self.view != View::Source
2508                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2509            {
2510                self.splice(start, end, "", EditKind::Delete);
2511                return;
2512            }
2513            // The mirror of Backspace's two steps: from in front of a run's
2514            // opening `**` step into it, onto the first letter of its text, and
2515            // at the end of a run's text step out past its closing `**` to the
2516            // character beyond. Either way Delete takes the character it looks
2517            // like it is pointing at, and never a delimiter drawn as nothing.
2518            // The caret then settles back inside the run it was standing in —
2519            // see `settle_inside_close_delims`.
2520            let from = if self.view == View::Source {
2521                self.caret
2522            } else {
2523                let inside = self.step_inside_open_delims(self.caret);
2524                self.skip_trailing_close_delims(inside)
2525            };
2526            let next = next_boundary(&self.source, from);
2527            if from < next {
2528                self.splice(from, next, "", EditKind::Delete);
2529            }
2530        }
2531    }
2532
2533    /// Delete from the caret back to the start of the previous word (⌥⌫ /
2534    /// Ctrl+⌫). Deletes the selection instead when one is active.
2535    pub fn delete_word_back(&mut self) {
2536        if let Some((s, e)) = self.selection() {
2537            self.splice(s, e, "", EditKind::Other);
2538        } else {
2539            // A word back from just past a picture is a word *of its markup*, and
2540            // a word back from in front of one runs through the paragraph break
2541            // into the prose above — dissolving the picture either way. See
2542            // `delete_around_block_media`.
2543            if self.view != View::Source && self.delete_around_block_media(false) {
2544                return;
2545            }
2546            let start = self.word_left_from(self.caret).max(self.caret_floor());
2547            if start < self.caret {
2548                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2549                self.splice(s, e, "", EditKind::Delete);
2550            }
2551        }
2552    }
2553
2554    /// Delete from the caret forward to the end of the next word (⌥⌦ /
2555    /// Ctrl+Del). Deletes the selection instead when one is active.
2556    pub fn delete_word_forward(&mut self) {
2557        if let Some((s, e)) = self.selection() {
2558            self.splice(s, e, "", EditKind::Other);
2559        } else {
2560            // The mirror: a word forward from in front of a picture is its markup.
2561            if self.view != View::Source && self.delete_around_block_media(true) {
2562                return;
2563            }
2564            let end = self.word_right_from(self.caret);
2565            if end > self.caret {
2566                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2567                self.splice(s, e, "", EditKind::Delete);
2568            }
2569        }
2570    }
2571
2572    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
2573    /// selection instead when one is active, as every other delete here does.
2574    ///
2575    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
2576    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
2577    /// stops at the first character and this takes the indentation with it, the
2578    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
2579    /// leave an indent behind that nothing can then ask to delete, where a caret
2580    /// left at column 0 is one press of Home away from either.
2581    pub fn delete_to_line_start(&mut self) {
2582        if let Some((s, e)) = self.selection() {
2583            self.splice(s, e, "", EditKind::Other);
2584            return;
2585        }
2586        // Never back across the floor: hidden frontmatter isn't on this line, or
2587        // on any line the WYSIWYG caret can see.
2588        let (start, _) = self.line_span();
2589        let start = start.max(self.caret_floor());
2590        if start < self.caret {
2591            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2592            self.splice(s, e, "", EditKind::Delete);
2593        }
2594    }
2595
2596    /// Kill from the caret to the end of its line (^K). Deletes the selection
2597    /// instead when one is active.
2598    ///
2599    /// At the end of the line it does nothing, rather than pulling the line
2600    /// below up into this one. Joining has no meaning to give it in both views
2601    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
2602    /// there is nothing there to delete, while the newline a *source* line ends
2603    /// with is only half of the blank line that separates two paragraphs —
2604    /// deleting one leaves a soft break, which is not the join it looks like.
2605    /// The views agreeing is worth more than emacs' second press, and Delete is
2606    /// already the key that joins.
2607    pub fn delete_to_line_end(&mut self) {
2608        if let Some((s, e)) = self.selection() {
2609            self.splice(s, e, "", EditKind::Other);
2610            return;
2611        }
2612        let (_, end) = self.line_span();
2613        if end > self.caret {
2614            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2615            self.splice(s, e, "", EditKind::Delete);
2616        }
2617    }
2618
2619    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
2620    ///
2621    /// A glyph-space range covers what the user can see, which for `**bold**` is
2622    /// the word and never the delimiters around it — so deleting the word on its
2623    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
2624    /// word, and the styling was the word's; the two go together. Only the
2625    /// node's delimiters are taken, and those are hidden here anyway, so nothing
2626    /// visible outside the range is lost.
2627    ///
2628    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
2629    /// the strong, and only then is the strong empty too.
2630    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
2631        if self.view == View::Source {
2632            return (start, end);
2633        }
2634        let nodes = self.nodes();
2635        let (mut s, mut e) = (start, end);
2636        loop {
2637            let mut grew = false;
2638            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
2639                let Some(text) = inline_content_span(n, &self.source) else {
2640                    continue;
2641                };
2642                // Some of its text survives, so the node still has a job.
2643                if text.start < s || text.end > e {
2644                    continue;
2645                }
2646                if n.span.start < s || n.span.end > e {
2647                    s = s.min(n.span.start);
2648                    e = e.max(n.span.end);
2649                    grew = true;
2650                }
2651            }
2652            if !grew {
2653                return (s, e);
2654            }
2655        }
2656    }
2657
2658    /// One splice of document text, keeping the **mark-edge rule**: an inline
2659    /// mark's content never begins or ends with whitespace. In Markdown and Djot
2660    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
2661    /// is four literal asterisks around a word, and a rich view drawing the
2662    /// document faithfully has no choice but to show them. That is correct
2663    /// rendering of what the file says, and nobody typing a space after a bold
2664    /// word meant to say it.
2665    ///
2666    /// So the space goes *outside* the run instead — `**bold** ` — which is the
2667    /// same document to a reader and a live one to a parser. The caret follows it
2668    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
2669    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
2670    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
2671    ///
2672    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
2673    /// through here, so the rule holds however the whitespace arrives at the
2674    /// edge. The repair is decided *after* the plain edit, by asking whether the
2675    /// mark actually died: a code span's backticks aren't whitespace-sensitive
2676    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
2677    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2678        let fix = self.mark_edge_fix(start, end, text);
2679        if !self.splice_exact(start, end, text, kind) {
2680            return false;
2681        }
2682        if let Some(fix) = fix {
2683            self.repair_mark_edges(fix);
2684        }
2685        if text.is_empty() && end > start {
2686            self.settle_inside_close_delims();
2687        }
2688        true
2689    }
2690
2691    /// After a delete, take a caret left standing past a run's closing delimiters
2692    /// back inside the run.
2693    ///
2694    /// A delete leaves the caret where the deleted bytes began, and when those
2695    /// bytes were the last thing after a marked phrase — the space the mark-edge
2696    /// rule pushed out of `**bold** `, say — that spot is the far side of the
2697    /// closing `**`. The rich view has nothing to draw there: the delimiters are
2698    /// hidden, so the caret shows at the end of the word either way, and the two
2699    /// offsets are one place on screen with two different meanings. Typing at the
2700    /// outer one lands past the run, so the writer who backspaced a space out of
2701    /// their bold phrase watches the next character come out plain, and the
2702    /// toolbar button go dark, with the caret never appearing to move.
2703    ///
2704    /// The end of the run's text is the caret's home there — a delete that took
2705    /// away everything after a phrase leaves the caret at the end of that phrase,
2706    /// which is inside it — so it settles onto that
2707    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
2708    /// walk, through every mark closing at the point): the word stays bold, the
2709    /// button stays lit, and the next character carries on the phrase.
2710    ///
2711    /// Rich view only, and only where a mark really closes at the caret — mid-run
2712    /// or in plain prose no span ends there and the caret stays put. The opening
2713    /// edge is left alone on purpose: a caret in front of a run inherits from the
2714    /// text on its left, which is the plain text outside.
2715    fn settle_inside_close_delims(&mut self) {
2716        if self.view != View::Wysiwyg {
2717            return;
2718        }
2719        let at = self.step_inside_close_delims(self.caret);
2720        if at != self.caret {
2721            self.caret = at;
2722            self.clear_pending();
2723            self.record_caret();
2724        }
2725    }
2726
2727    /// The splice exactly as asked, with no mark-edge repair — for the callers
2728    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
2729    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
2730    /// the bytes they inserted.
2731    ///
2732    /// One `edit_range` through twig, then re-anchor the caret from the returned
2733    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
2734    /// Markdown/Djot) leaves the document untouched and reports.
2735    ///
2736    /// Returns whether the edit landed — for a caller that has offsets of its
2737    /// own to place afterwards, which a rolled-back splice would leave pointing
2738    /// into text that never came to exist.
2739    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2740        // twig records an undo step for every edit; when this one continues a
2741        // run of the same kind (typing, deleting), tell twig to fold it into the
2742        // step before it so the whole run undoes at once.
2743        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
2744        // Hand twig the pre-edit caret before the splice, so the undo step it
2745        // retires carries where the caret was standing.
2746        self.record_caret();
2747        match self.editor.edit_range(start, end, text) {
2748            Ok(change) => {
2749                if coalesce {
2750                    let _ = self.editor.coalesce_last_undo();
2751                }
2752                self.last_edit_kind = Some(kind);
2753                self.refresh();
2754                self.caret = change.new.end;
2755                self.anchor = None;
2756                self.goal_col = None;
2757                self.clear_pending();
2758                self.dirty = self.source != self.clean_source;
2759                self.status = None;
2760                // And the post-edit caret, so a later redo restores it.
2761                self.record_caret();
2762                true
2763            }
2764            // The edit was rolled back, so twig's history did not move and
2765            // neither may ours: pushing here would leave a step with no edit
2766            // under it and shift every later undo onto the wrong caret.
2767            Err(e) => {
2768                self.status = Some(format!("edit: {e}"));
2769                false
2770            }
2771        }
2772    }
2773
2774    /// The re-spelling that would keep the mark-edge rule for the edit
2775    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
2776    /// against a delimiter and the plain splice is already right. Computed
2777    /// *before* the edit, while the run's spans and delimiters can still be read
2778    /// off the document; applied afterwards, and only if the mark really died —
2779    /// see [`repair_mark_edges`](Self::repair_mark_edges).
2780    ///
2781    /// Rich view only. Source view is for typing raw markup, where a space put
2782    /// against a `**` is exactly the character it looks like.
2783    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
2784        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
2785            return None;
2786        }
2787        // Every inline mark standing over the edit, outermost first, with the
2788        // content span that says where its delimiters are.
2789        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
2790            .editor
2791            .ancestors_at(start)
2792            .unwrap_or_default()
2793            .into_iter()
2794            .filter_map(|m| {
2795                let kind = inline_kind(&m.kind)?;
2796                let content = m.content_span.clone()?;
2797                Some((kind, m.span.clone(), content))
2798            })
2799            .collect();
2800        // The innermost run whose *content* holds the whole edit: the one whose
2801        // text is being changed, rather than one the edit merely sits under.
2802        let (kind, span, content) = chain
2803            .iter()
2804            .rev()
2805            .find(|(_, _, c)| c.start <= start && end <= c.end)?
2806            .clone();
2807        // What that content becomes. Whitespace at either end of it is what
2808        // would put out the mark.
2809        let body = format!(
2810            "{}{text}{}",
2811            &self.source[content.start..start],
2812            &self.source[end..content.end]
2813        );
2814        let (lead, trail) = if body.trim().is_empty() {
2815            // Nothing but whitespace left: there is no content to mark at all,
2816            // and the delimiters go with it rather than closing on a space.
2817            (body.len(), 0)
2818        } else {
2819            (
2820                body.len() - body.trim_start().len(),
2821                body.len() - body.trim_end().len(),
2822            )
2823        };
2824        // Nothing against a delimiter, and something still between them: the
2825        // plain edit stands. An emptied run is broken just as surely (`**b**`
2826        // with the `b` deleted is the literal `****`) and is re-spelt as the
2827        // nothing it now says.
2828        if lead == 0 && trail == 0 && !body.is_empty() {
2829            return None;
2830        }
2831        // Marks that open or close exactly where this one does — `***both***` is
2832        // two runs sharing an edge — spell their delimiters as one run of bytes,
2833        // so the whitespace has to clear all of them together.
2834        let (mut open_at, mut close_at) = (span.start, span.end);
2835        for _ in 0..chain.len() {
2836            match chain.iter().find(|(_, _, c)| c.start == open_at) {
2837                Some((_, s, _)) => open_at = s.start,
2838                None => break,
2839            }
2840        }
2841        for _ in 0..chain.len() {
2842            match chain.iter().find(|(_, _, c)| c.end == close_at) {
2843                Some((_, s, _)) => close_at = s.end,
2844                None => break,
2845            }
2846        }
2847        let open = &self.source[open_at..content.start];
2848        let close = &self.source[content.end..close_at];
2849        let core = &body[lead..body.len() - trail];
2850        let respelt = if core.is_empty() {
2851            body.clone()
2852        } else {
2853            format!("{}{open}{core}{close}{}", &body[..lead], &body[body.len() - trail..])
2854        };
2855        // The caret sits just past the inserted text within the new content —
2856        // which, when that lands in the whitespace, is now outside the delimiters.
2857        let pos = (start - content.start) + text.len();
2858        let caret = if core.is_empty() || pos <= lead {
2859            open_at + pos
2860        } else if pos >= lead + core.len() {
2861            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
2862        } else {
2863            open_at + lead + open.len() + (pos - lead)
2864        };
2865        Some(MarkEdgeFix {
2866            kind,
2867            probe: content.start,
2868            start: open_at,
2869            end: close_at + text.len() - (end - start),
2870            text: respelt,
2871            caret,
2872            // The marks in force here, resolved against any armed sticky delta —
2873            // what the writer is typing in, and so what has to still be true on
2874            // the far side of the delimiter the caret just stepped over.
2875            want: chain
2876                .iter()
2877                .filter(|(_, s, _)| start < s.end)
2878                .map(|(k, _, _)| *k)
2879                .collect::<InlineMarks>()
2880                .xor(self.pending_here()),
2881        })
2882    }
2883
2884    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
2885    /// did break the mark. Whether whitespace at a delimiter is fatal is the
2886    /// format's business, not leaf's: `**bold **` is no longer strong, while
2887    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
2888    /// don't care either. Asking the parser afterwards settles it for every kind
2889    /// and format at once, and costs a re-spelling only where one is due.
2890    ///
2891    /// The repair rides along with the edit that caused it — one undo step puts
2892    /// back what the writer typed, not a delimiter shuffle they never saw.
2893    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
2894        if fix.end > self.source.len() {
2895            return;
2896        }
2897        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
2898            return; // still a mark: these delimiters don't mind the whitespace
2899        }
2900        let resumed = self.last_edit_kind;
2901        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
2902            return;
2903        }
2904        let _ = self.editor.coalesce_last_undo();
2905        // The keystroke owns the undo step, so the run of typing it belongs to
2906        // keeps coalescing over the repair rather than breaking in two here.
2907        self.last_edit_kind = resumed;
2908        self.caret = fix.caret.min(self.source.len());
2909        self.anchor = None;
2910        self.goal_col = None;
2911        self.rearm(fix.want);
2912        self.clamp_caret();
2913        self.record_caret();
2914    }
2915
2916    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
2917    /// writer is typing in, carried across an edit that moved the caret out of
2918    /// the run holding them. Arms nothing when the caret already stands in
2919    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
2920    /// a clean delta here (see [`toggle`](Self::toggle)).
2921    fn rearm(&mut self, want: InlineMarks) {
2922        let here: InlineMarks = self.marks_at(self.caret).into_iter().map(|(k, _)| k).collect();
2923        self.pending_marks = want.xor(here);
2924        self.pending_at = Some(self.caret);
2925    }
2926
2927    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
2928    /// which backslash-escapes any character that would otherwise open markup in
2929    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
2930    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
2931    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
2932    /// a collapsed point — a selection is deleted by the caller first, since
2933    /// `insert_literal` inserts rather than replaces.
2934    fn insert_literal_at(&mut self, at: usize, text: &str, kind: EditKind, force_coalesce: bool) -> bool {
2935        // `force_coalesce` folds this into the immediately preceding edit (the
2936        // selection-delete of an overwrite) so the pair is one undo step; else it
2937        // coalesces only when it continues a run of the same-kind typing.
2938        let coalesce = force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
2939        // The mark-edge rule holds for typed text however it is spelled — see
2940        // `splice`. Only an insert twig passed through unchanged can use it,
2941        // since a fix is measured in the bytes that actually land, and an escape
2942        // adds bytes this couldn't have counted.
2943        let fix = self.mark_edge_fix(at, at, text);
2944        self.record_caret();
2945        match self.editor.insert_literal(at, text) {
2946            Ok(change) => {
2947                if coalesce {
2948                    let _ = self.editor.coalesce_last_undo();
2949                }
2950                self.last_edit_kind = Some(kind);
2951                self.refresh();
2952                self.caret = change.new.end;
2953                self.anchor = None;
2954                self.goal_col = None;
2955                self.clear_pending();
2956                self.dirty = self.source != self.clean_source;
2957                self.status = None;
2958                self.record_caret();
2959                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
2960                    self.repair_mark_edges(fix);
2961                }
2962                true
2963            }
2964            Err(e) => {
2965                self.status = Some(format!("edit: {e}"));
2966                false
2967            }
2968        }
2969    }
2970
2971    /// After a structural list edit (a new item, a nest/unnest), renumber the
2972    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
2973    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
2974    /// renumber as its own edit; fold it into the edit that triggered it so the
2975    /// two undo as one, and only when it actually changed the source (a no-op or
2976    /// a caret outside any ordered list must not coalesce the real edit into the
2977    /// step before it).
2978    fn renumber_here(&mut self) {
2979        self.renumber_at(self.caret);
2980    }
2981
2982    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
2983    /// caret — for an edit that leaves the caret one past the item it just wrote,
2984    /// where twig resolves no list to renumber.
2985    fn renumber_at(&mut self, off: usize) {
2986        let before = self.source.clone();
2987        if self.editor.renumber_ordered_lists(off).is_err() {
2988            return; // not inside an ordered list — nothing to renumber
2989        }
2990        self.refresh();
2991        if self.source != before {
2992            let _ = self.editor.coalesce_last_undo();
2993            self.dirty = self.source != self.clean_source;
2994            self.clamp_caret();
2995            self.record_caret();
2996        }
2997    }
2998
2999    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3000    /// sub-item written directly beneath a text line reparses that text as a
3001    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3002    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3003    /// bullets can't underline anything, so swap the dash for a `*`: the item
3004    /// stays an empty nested bullet, the parent stays prose, and the source
3005    /// round-trips instead of hiding a heading the user never asked for. Folded
3006    /// into the triggering edit's undo step, the way renumbering is.
3007    ///
3008    /// Gated on the collapse having actually happened (the swapped dash was
3009    /// swallowed into a `heading`), so a real setext heading the author wrote —
3010    /// or a `- x` with content, which can't underline anything — is never
3011    /// touched. This has to live in the *edit*, not the renderer: leaving the
3012    /// hazardous bytes on disk and only painting over them would ship a file
3013    /// every other CommonMark tool reads as a heading.
3014    ///
3015    /// This one keeps its own byte scan, and has to: the hazard is precisely
3016    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3017    /// which asks twig which lines open an item — reports nothing here. There is
3018    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3019    /// purpose rather than for want of an answer; once twig spells continuations
3020    /// itself, avoiding the trap becomes twig's, and this goes.
3021    ///
3022    /// [`list_marker_on_line`]: Self::list_marker_on_line
3023    fn avoid_setext_collapse(&mut self) {
3024        let caret = self.caret.min(self.source.len());
3025        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3026        let bytes = self.source.as_bytes();
3027        let mut dash = line_start;
3028        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3029            dash += 1;
3030        }
3031        // A dash bullet is the only marker that doubles as a setext underline.
3032        if bytes.get(dash) != Some(&b'-') {
3033            return;
3034        }
3035        // Only an *empty* item is a bare underline; `- x` carries content and
3036        // can't fold the line above into a heading.
3037        let line_end = self.source[dash..]
3038            .find('\n')
3039            .map_or(self.source.len(), |i| dash + i);
3040        if !self.source[dash + 1..line_end].trim().is_empty() {
3041            return;
3042        }
3043        // The tell: that dash was swallowed into a `heading`. A properly nested
3044        // empty item sits under a `list_item`, with no heading in reach. Probe
3045        // the dash byte itself (well inside the heading), not the caret, whose
3046        // end-of-line offset can fall on the half-open span boundary.
3047        let collapsed = self
3048            .editor
3049            .ancestors_at(dash)
3050            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3051            .unwrap_or(false);
3052        if !collapsed {
3053            return;
3054        }
3055        let caret = self.caret;
3056        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3057            // Same width, so the caret keeps its column; fold into the edit that
3058            // triggered this so Tab stays one undo step.
3059            let _ = self.editor.coalesce_last_undo();
3060            self.caret = caret.min(self.source.len());
3061            self.clamp_caret();
3062            self.record_caret();
3063        }
3064    }
3065
3066    fn snapshot(&self) -> CaretState {
3067        CaretState {
3068            caret: self.caret,
3069            anchor: self.anchor,
3070        }
3071    }
3072
3073    /// Hand twig the current caret and selection as the blob for the live
3074    /// document state. Called before an edit — so the step twig retires records
3075    /// where the caret was, and undo can restore it — and again once the op has
3076    /// placed the caret, so redo restores where the edit left it.
3077    ///
3078    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3079    /// caret through its own history, so coalescing falls out for free (folding
3080    /// two twig steps into one drops the intermediate blob, keeping the run's
3081    /// first) and the parallel stacks that had to march in lockstep — and could
3082    /// silently drift out of it — are gone.
3083    fn record_caret(&mut self) {
3084        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3085    }
3086
3087    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3088    /// the toggled region selected so a second press cleanly reverses it.
3089    pub fn toggle(&mut self, kind: InlineKind) {
3090        // Ahead of the no-selection branch below: arming a mark for text not yet
3091        // typed is a promise `insert` cannot keep in a format with no delimiters
3092        // to spell it with. Per *kind*, not per format — Markdown spells three
3093        // of the eight marks, djot all eight, HTML seven.
3094        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3095            return;
3096        }
3097        let Some((s, e)) = self.selection() else {
3098            // No selection: arm the mark for the next text typed here, the way a
3099            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3100            // in the flow of typing without ever selecting anything — the delta
3101            // is realised onto the freshly typed text by `insert`. A fresh caret
3102            // position starts the delta over from the marks actually in force.
3103            if self.pending_at != Some(self.caret) {
3104                self.pending_marks = InlineMarks::empty();
3105                self.pending_at = Some(self.caret);
3106            }
3107            self.pending_marks.flip(kind);
3108            self.status = None;
3109            return;
3110        };
3111        // Whitespace at the edge of a selection is not part of what was chosen —
3112        // a double-click takes the space after the word with it — and a mark
3113        // cannot close against one anyway: `**word **` is four literal asterisks
3114        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3115        let picked = &self.source[s..e];
3116        let (s, e) = (
3117            s + (picked.len() - picked.trim_start().len()),
3118            e - (picked.len() - picked.trim_end().len()),
3119        );
3120        if s >= e {
3121            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3122            return;
3123        }
3124        // Styling a selection is a one-shot act, not a sticky mode.
3125        self.clear_pending();
3126        self.record_caret();
3127        match self.editor.toggle_inline(s, e, kind) {
3128            Ok(change) => {
3129                self.last_edit_kind = None; // structural edit is its own undo step
3130                self.refresh();
3131                self.anchor = Some(change.new.start);
3132                self.caret = change.new.end;
3133                self.dirty = self.source != self.clean_source;
3134                self.status = None;
3135                self.record_caret();
3136            }
3137            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3138        }
3139    }
3140
3141    /// Convert the block at the caret to a heading level or paragraph.
3142    pub fn set_block(&mut self, kind: BlockKind) {
3143        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3144            return;
3145        }
3146        self.record_caret();
3147        // A blank line has no node to convert, and twig opens a block there
3148        // rather than declining — so the caret's own offset is the right thing
3149        // to hand it when `block_offset_for_caret` finds nothing.
3150        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3151        match self.editor.set_block(offset, kind) {
3152            Ok(change) => {
3153                self.last_edit_kind = None;
3154                self.refresh();
3155                // Opening a block on a blank line writes a marker the caret
3156                // belongs *after*; converting an existing one moves nothing.
3157                self.caret = self.caret.max(change.new.end);
3158                self.clamp_caret();
3159                self.anchor = None;
3160                self.dirty = self.source != self.clean_source;
3161                self.status = None;
3162                self.record_caret();
3163            }
3164            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3165        }
3166    }
3167
3168    /// Whether `off` is inside a text block (paragraph, heading, code block…).
3169    fn has_block_at(&mut self, off: usize) -> bool {
3170        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3171            chain
3172                .iter()
3173                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3174        })
3175    }
3176
3177    /// The offset to hand twig's `set_block`: the caret when it is already inside
3178    /// a block, otherwise nudged onto the previous character (a caret at a line
3179    /// end sits at the doc level, outside the block). `None` when the caret is on
3180    /// a blank line — a new paragraph with no block node to convert.
3181    fn block_offset_for_caret(&mut self) -> Option<usize> {
3182        let caret = self.caret.min(self.source.len());
3183        if self.has_block_at(caret) {
3184            return Some(caret);
3185        }
3186        // Nudge to the previous character — but never across a newline: that would
3187        // target the previous block, and a blank line genuinely has no block.
3188        if let Some((i, ch)) = self.source[..caret].char_indices().next_back() {
3189            if ch != '\n' && self.has_block_at(i) {
3190                return Some(i);
3191            }
3192        }
3193        None
3194    }
3195
3196    /// The heading level of the text block at the caret, or `None` when that
3197    /// block is not a heading.
3198    pub fn current_heading_level(&mut self) -> Option<u32> {
3199        let caret = self.caret;
3200        self.nodes()
3201            .into_iter()
3202            .filter(|n| n.kind == Kind::Heading)
3203            .find(|n| n.span.start <= caret && caret <= n.span.end)
3204            .and_then(|n| n.level)
3205    }
3206
3207    /// The inline marks in force at the caret (or over the selection) — what a
3208    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3209    /// inline counterpart. Cheap enough to call every frame: one twig
3210    /// `ancestors_at` query per caret (two with a selection), each walking root
3211    /// → deepest node at one offset. It never snapshots the tree the way
3212    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3213    /// the only allocation is twig's own small ancestor `Vec`.
3214    ///
3215    /// **A selection reports a mark only when the mark covers *all* of it.**
3216    /// That's what every real toolbar means by an active button — Bold lit over
3217    /// a half-bold selection would claim a press turns bold *off*, when
3218    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3219    /// Whole-coverage is asked as "is the same mark node standing over both the
3220    /// first and the last character?": inline nodes are contiguous, so one node
3221    /// covering both ends covers every byte between them. Two touching runs
3222    /// (`**a****b**`) are two nodes, and correctly light nothing.
3223    ///
3224    /// At a bare caret a mark is active when the caret stands inside the mark's
3225    /// span — `span.start <= caret < span.end`, delimiters included, which is
3226    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3227    /// opening `*` (2) through the last byte of the closing `**` (9) are all
3228    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3229    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3230    /// typing would actually land inside the marked run. The offset one past the
3231    /// mark (10) is the text after it and reports nothing, at the end of the
3232    /// buffer exactly as in the middle.
3233    pub fn active_inline_marks(&mut self) -> InlineMarks {
3234        let Some((start, end)) = self.selection() else {
3235            // The marks actually in force at the caret, flipped by any armed
3236            // sticky delta — so `⌘b` at a bare caret lights the Bold button
3237            // immediately, before a single character is typed.
3238            let base: InlineMarks = self.marks_at(self.caret).into_iter().map(|(k, _)| k).collect();
3239            return base.xor(self.pending_here());
3240        };
3241        // The selection's *last character*, not its exclusive end: `end` is the
3242        // offset one past the selection, which for a selection ending exactly at
3243        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3244        // entirely bold, but offset 10 is the space after).
3245        let last = prev_boundary(&self.source, end);
3246        let head = self.marks_at(start);
3247        let tail = self.marks_at(last);
3248        head.into_iter()
3249            .filter(|m| tail.contains(m))
3250            .map(|(k, _)| k)
3251            .collect()
3252    }
3253
3254    /// The inline marks whose span covers `off`, each with the id of the node
3255    /// carrying it — the id is what lets a selection tell one mark node from
3256    /// another of the same kind.
3257    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3258        let off = off.min(self.source.len());
3259        self.editor
3260            .ancestors_at(off)
3261            .unwrap_or_default()
3262            .into_iter()
3263            // `span.end` is the offset one *past* the mark, so it isn't in it.
3264            // twig already resolves a boundary to whatever starts there — in
3265            // `**bold** x` offset 8 is the following text, not the strong — but
3266            // when nothing follows, the tie has nobody to break for and the
3267            // chain still ends at the mark. That would make the answer at the
3268            // last offset of the document depend on whether the file happens to
3269            // end in a newline; the rule is `span.start <= off < span.end`, and
3270            // it's the same rule at the end of a buffer as in the middle.
3271            .filter(|m| off < m.span.end)
3272            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3273            .collect()
3274    }
3275
3276    /// Toggle a heading at the caret: if the block is already this heading level,
3277    /// revert it to a paragraph; otherwise convert it to this heading level.
3278    /// This gives the heading commands the same toggle feel as bold/italic/code —
3279    /// re-applying a heading a line already has turns it back into body text.
3280    pub fn toggle_heading(&mut self, level: u32) {
3281        if self.current_heading_level() == Some(level) {
3282            self.set_block(BlockKind::Paragraph);
3283        } else {
3284            self.set_block(BlockKind::Heading(level));
3285        }
3286    }
3287
3288    /// Toggle a block quote around the selection, or around the block at the
3289    /// caret — the toolbar's Quote button.
3290    pub fn toggle_blockquote(&mut self) {
3291        self.toggle_container(BlockContainerKind::BlockQuote);
3292    }
3293
3294    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3295    /// over the block at the caret — one op with the kind as a flag, the way
3296    /// `toggle_heading` takes its level, so a frontend needs no twig type to
3297    /// name the two buttons.
3298    ///
3299    /// Pressing the *other* list's button while in a list converts in place
3300    /// rather than nesting, so the pair reads as one three-state control
3301    /// (bulleted / numbered / neither) rather than two independent wrappers.
3302    pub fn toggle_list(&mut self, ordered: bool) {
3303        self.toggle_container(if ordered {
3304            BlockContainerKind::OrderedList
3305        } else {
3306            BlockContainerKind::BulletList
3307        });
3308    }
3309
3310    // ── Task list items ──────────────────────────────────────────────────────
3311    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3312    // inline content of the item's first paragraph rather than part of its
3313    // marker, so adding or removing one must leave the item's continuation
3314    // indentation alone, and an item inside a quote is found past the quote
3315    // markers. leaf names the gesture and the offset; the spelling is twig's.
3316
3317    /// Whether the list item at the caret carries a checkbox, and which way it
3318    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3319    /// item or no item at all. What a toolbar reads to light its checkbox button.
3320    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3321        self.task_checked_at(self.caret)
3322    }
3323
3324    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3325    /// offset — what a frontend asks before deciding a click landed on a box.
3326    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3327        self.innermost_list_item(offset.min(self.source.len()))?
3328            .checked
3329    }
3330
3331    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3332    /// A no-op with a reported reason when the caret is in no task item — minting
3333    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3334    pub fn toggle_task_checked(&mut self) {
3335        self.toggle_task_at(self.caret);
3336    }
3337
3338    /// Tick or untick the task item covering `offset` — what a *click* on a
3339    /// rendered checkbox is. Separate from the caret form because a click carries
3340    /// its own offset and must not first move the caret there: ticking a box
3341    /// three paragraphs away should not take the cursor with it.
3342    pub fn toggle_task_at(&mut self, offset: usize) {
3343        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3344            return;
3345        }
3346        let offset = offset.min(self.source.len());
3347        self.record_caret();
3348        match self.editor.toggle_task_checked(offset) {
3349            Ok(_) => self.after_task_edit(),
3350            Err(e) => self.status = Some(format!("task: {e}")),
3351        }
3352    }
3353
3354    /// Give the list item at the caret a checkbox, or take its checkbox away —
3355    /// the gesture that converts between a plain bullet and a task. A new box
3356    /// arrives unticked.
3357    pub fn toggle_task_item(&mut self) {
3358        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3359            return;
3360        }
3361        let caret = self.caret.min(self.source.len());
3362        self.record_caret();
3363        match self.editor.toggle_task_item(caret) {
3364            Ok(_) => self.after_task_edit(),
3365            Err(e) => self.status = Some(format!("task: {e}")),
3366        }
3367    }
3368
3369    /// Settle after a task gesture. The caret rides its old byte offset and is
3370    /// clamped back in: a box is three or four bytes on the item's first line, so
3371    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3372    /// real stop either way.
3373    fn after_task_edit(&mut self) {
3374        self.last_edit_kind = None;
3375        self.refresh();
3376        self.anchor = None;
3377        self.dirty = self.source != self.clean_source;
3378        self.status = None;
3379        self.clamp_caret();
3380        self.record_caret();
3381    }
3382
3383    // ── Tables ───────────────────────────────────────────────────────────────
3384    // A table is a grid, and twig edits it as one — add/remove/move a row or
3385    // column, set a column's alignment — re-spelling the whole table in a single
3386    // splice. Every gesture is anchored at the caret's cell. leaf just names the
3387    // gesture and re-reads the result; the whole table's numbering, borders, and
3388    // delimiter are twig's to keep straight.
3389
3390    /// Whether the caret is inside a table — what a frontend asks to enable or
3391    /// disable its table controls.
3392    ///
3393    /// An HTML `<table>` still answers `true`: the caret really is in a table,
3394    /// and the reason the grid controls stay dark there is
3395    /// [`Capabilities::table`], which is a fact about the document's format
3396    /// rather than about the caret. A frontend needs both.
3397    pub fn caret_in_table(&mut self) -> bool {
3398        let caret = self.caret.min(self.source.len());
3399        self.editor
3400            .ancestors_at(caret)
3401            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3402            .unwrap_or(false)
3403    }
3404
3405    /// One grid op, guarded and settled — the shared body of the seven below.
3406    ///
3407    /// The guard is why this exists rather than seven copies of the same three
3408    /// lines, and it is the one guard leaf cannot delegate to twig. The table
3409    /// editor is the gesture family that consults no `Syntax` table (it spells a
3410    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3411    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3412    /// grid as a *pipe table* and reports success, swapping the element out for
3413    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3414    /// downstream could tell that from a successful edit — the splice is real,
3415    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3416    /// stopping at the door rather than detecting after the fact. See
3417    /// [`spells_pipe_tables`].
3418    fn table_op(
3419        &mut self,
3420        what: &str,
3421        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3422    ) {
3423        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3424            return;
3425        }
3426        self.record_caret();
3427        let at = self.caret;
3428        let r = op(&mut self.editor, at);
3429        self.apply_table(r, what);
3430    }
3431
3432    /// Insert an empty row below (`below`) or above the caret's row.
3433    pub fn table_insert_row(&mut self, below: bool) {
3434        self.table_op("table row", |e, at| e.table_insert_row(at, below));
3435    }
3436
3437    /// Delete the caret's row (not the header, not the last body row).
3438    pub fn table_delete_row(&mut self) {
3439        self.table_op("table row", |e, at| e.table_delete_row(at));
3440    }
3441
3442    /// Insert an empty column right (`right`) or left of the caret's column.
3443    pub fn table_insert_column(&mut self, right: bool) {
3444        self.table_op("table column", |e, at| e.table_insert_column(at, right));
3445    }
3446
3447    /// Delete the caret's column (unless it is the only one).
3448    pub fn table_delete_column(&mut self) {
3449        self.table_op("table column", |e, at| e.table_delete_column(at));
3450    }
3451
3452    /// Set the caret's column to `alignment`.
3453    pub fn table_set_alignment(&mut self, alignment: Alignment) {
3454        self.table_op("table alignment", |e, at| e.table_set_alignment(at, alignment));
3455    }
3456
3457    /// Move the caret's row one place down (`down`) or up, within the body rows.
3458    pub fn table_move_row(&mut self, down: bool) {
3459        self.table_op("table row", |e, at| e.table_move_row(at, down));
3460    }
3461
3462    /// Move the caret's column one place right (`right`) or left.
3463    pub fn table_move_column(&mut self, right: bool) {
3464        self.table_op("table column", |e, at| e.table_move_column(at, right));
3465    }
3466
3467    /// Settle the caret and document flags after a table op (or report its
3468    /// error). twig re-spells the whole table, so the caret rides its old byte
3469    /// offset and is clamped back into the rebuilt bytes — near enough to where
3470    /// it was, since the op preserves the cells' content and order around it.
3471    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3472        match result {
3473            Ok(()) => {
3474                self.last_edit_kind = None;
3475                self.refresh();
3476                self.anchor = None;
3477                self.clamp_caret();
3478                self.dirty = self.source != self.clean_source;
3479                self.status = None;
3480                self.record_caret();
3481            }
3482            Err(e) => self.status = Some(format!("{what}: {e}")),
3483        }
3484    }
3485
3486    /// One `toggle_block_container` over the block-level target.
3487    ///
3488    /// leaf says *where*; twig decides everything else — which blocks the range
3489    /// covers, whether that means wrapping, unwrapping, nesting or converting,
3490    /// and how this document's format spells the prefix. The rule that a
3491    /// container only comes off when the range covers every block it holds is
3492    /// what the re-anchoring below is built around.
3493    fn toggle_container(&mut self, kind: BlockContainerKind) {
3494        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3495            return;
3496        }
3497        let selected = self.selection();
3498        // A blank line holds no block, and twig opens an *empty* container on one
3499        // — since 3.2.0; it used to decline the range with `NotFound`, which is
3500        // why this used to lend it a scratch paragraph to wrap. Worth knowing
3501        // here because the line-for-line caret mapping below cannot describe it:
3502        // opening one under a paragraph writes the blank line the format needs
3503        // above the marker too, so the rewritten region has a line the old one
3504        // didn't, and "the same line, the same distance from its end" lands on
3505        // that new blank instead of in the container.
3506        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
3507        // Without a selection the target is the caret's own block, resolved the
3508        // way `set_block` resolves it — a caret at a line end sits at the doc
3509        // level and has to be nudged back onto the block it looks like it's in.
3510        // An empty range is enough: twig widens to the whole lines it touches.
3511        let (start, end) = match selected {
3512            Some(range) => range,
3513            None => {
3514                let off = self.block_offset_for_caret().unwrap_or(self.caret);
3515                (off, off)
3516            }
3517        };
3518        self.record_caret();
3519        match self.editor.toggle_block_container(start, end, kind) {
3520            Ok(change) => {
3521                // Read the caret's place out of the *pre-edit* source, before
3522                // `refresh` swaps that source out from under it.
3523                let place = (selected.is_none() && !opened_empty)
3524                    .then(|| self.caret_line_tail(&change.old));
3525                self.last_edit_kind = None; // structural edit is its own undo step
3526                self.refresh();
3527                match place {
3528                    // Both land the caret at the far end of what twig wrote, and
3529                    // differ only in what they leave selected.
3530                    //
3531                    // From a selection: select what the container now holds, the
3532                    // way `toggle` keeps its marked region selected — and for a
3533                    // stronger reason than symmetry: a container comes *off* only
3534                    // a range covering every block it holds, so a selection left
3535                    // on its old bytes (now short by a prefix per line) would nest
3536                    // on the second press instead of reversing the first.
3537                    //
3538                    // From a blank line: nothing to select, and the end of the
3539                    // region is exactly past the bare `> ` / `- ` twig wrote —
3540                    // the caret standing inside the container that was asked for.
3541                    None => {
3542                        self.anchor = (!opened_empty).then_some(change.new.start);
3543                        self.caret = change.new.end;
3544                    }
3545                    Some(place) => {
3546                        self.anchor = None;
3547                        self.caret = self.line_tail_offset(&change.new, place);
3548                    }
3549                }
3550                self.dirty = self.source != self.clean_source;
3551                self.status = None;
3552                self.clamp_caret();
3553                self.record_caret();
3554            }
3555            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3556        }
3557    }
3558
3559    /// The caret's place inside the region a container toggle is rewriting, in
3560    /// the only terms the rewrite preserves: which of the region's lines it sits
3561    /// on, and how many bytes of that line lie ahead of it.
3562    ///
3563    /// A container's markup goes in at column 0 and never touches what follows
3564    /// on the line, so that pair survives the edit exactly where a byte offset
3565    /// does not — a caret left on its old offset slides back by one prefix per
3566    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
3567    /// `> ` it just asked for.
3568    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
3569        let caret = self.caret.clamp(old.start, old.end);
3570        let line = self.source[old.start..caret].matches('\n').count();
3571        let end = self.source[caret..old.end]
3572            .find('\n')
3573            .map_or(old.end, |i| caret + i);
3574        (line, end - caret)
3575    }
3576
3577    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
3578    /// region: the offset `tail` bytes back from the end of the region's `line`.
3579    ///
3580    /// Both walks are clamped rather than trusted, because the one op that does
3581    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
3582    /// the items back apart with blank lines between them — and a caret landing
3583    /// on the nearest line of the right item beats one landing out of the region
3584    /// entirely.
3585    fn line_tail_offset(&self, new: &std::ops::Range<usize>, (line, tail): (usize, usize)) -> usize {
3586        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3587        let mut start = 0;
3588        for _ in 0..line {
3589            match region[start..].find('\n') {
3590                Some(i) => start += i + 1,
3591                None => break,
3592            }
3593        }
3594        let end = region[start..].find('\n').map_or(region.len(), |i| start + i);
3595        new.start + end.saturating_sub(tail).max(start)
3596    }
3597
3598    /// Link the selection to `destination` — the toolbar's Link button. With no
3599    /// selection it acts at the caret, which re-points a link the caret is
3600    /// already standing in (twig replaces an existing link's destination and
3601    /// keeps its text) and otherwise spells a link that has no text of its own:
3602    /// an autolink (`<https://x.dev>`) where the destination is one, and
3603    /// `[destination](destination)` where it isn't.
3604    ///
3605    /// `destination` reaches twig raw. Escaping it is format knowledge and the
3606    /// two formats genuinely disagree — Markdown ends a destination at the first
3607    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
3608    /// itself — so the side holding the document is the side that gets to spell
3609    /// it. A destination twig can't carry at all (one with a newline) comes back
3610    /// as an error rather than a quietly rewritten URL.
3611    pub fn insert_link(&mut self, destination: &str) {
3612        if self.refuse_unsupported("link", Gesture::InsertLink) {
3613            return;
3614        }
3615        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3616        self.record_caret();
3617        match self.editor.insert_link(start, end, destination) {
3618            Ok(change) => {
3619                self.last_edit_kind = None;
3620                self.refresh();
3621                match self.link_text_span(change.new.start) {
3622                    // A link with text of its own: select it, so typing replaces
3623                    // a `[dest](dest)`'s stand-in label and a second press
3624                    // re-points what the first one linked.
3625                    Some(text) => {
3626                        self.anchor = (text.start != text.end).then_some(text.start);
3627                        self.caret = text.end;
3628                    }
3629                    // An autolink is finished the moment it's written — its text
3630                    // *is* the URL. Leaving it selected would aim the next press
3631                    // at the one shape twig still wraps instead of re-points.
3632                    None => {
3633                        self.anchor = None;
3634                        self.caret = change.new.end;
3635                    }
3636                }
3637                self.dirty = self.source != self.clean_source;
3638                self.status = None;
3639                self.clamp_caret();
3640                self.record_caret();
3641            }
3642            Err(e) => self.status = Some(format!("link: {e}")),
3643        }
3644    }
3645
3646    /// Insert a block-level image at the caret: `![alt](destination)`. Any
3647    /// selection becomes the alt text (so "select a caption, insert image" labels
3648    /// it); with no selection, `alt` is used — empty for none. The caret lands
3649    /// just past the inserted image.
3650    ///
3651
3652    /// Both halves go through twig (`insert_literal` for the alt text,
3653    /// `insert_image` for the image), so neither is spelled here. That used to be a
3654    /// `format!`, and it was wrong the first time an app inserted a real filename:
3655    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
3656    /// not an image at all — and the fix is per-format, since moving into the
3657    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
3658    pub fn insert_image(&mut self, destination: &str, alt: &str) {
3659        if self.refuse_unsupported("image", Gesture::InsertImage) {
3660            return;
3661        }
3662        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3663        self.record_caret();
3664        // With no selection and an explicit `alt`, the alt text has to exist in the
3665        // document before it can be the image's — and it is raw caller input, so
3666        // it goes in through `insert_literal`, which escapes it for the format
3667        // rather than letting a `]` in someone's caption close the image early.
3668        let (start, end) = if start == end && !alt.is_empty() {
3669            match self.editor.insert_literal(start, alt) {
3670                Ok(change) => (change.new.start, change.new.end),
3671                Err(e) => {
3672                    self.status = Some(format!("image: {e}"));
3673                    return;
3674                }
3675            }
3676        } else {
3677            (start, end)
3678        };
3679        match self.editor.insert_image(start, end, destination) {
3680            Ok(change) => {
3681                self.last_edit_kind = None;
3682                self.refresh();
3683                // Just past the image, nothing selected — where a caret belongs
3684                // after inserting one.
3685                self.anchor = None;
3686                self.caret = change.new.end;
3687                self.dirty = self.source != self.clean_source;
3688                self.status = None;
3689                self.clamp_caret();
3690                self.record_caret();
3691            }
3692            Err(e) => self.status = Some(format!("image: {e}")),
3693        }
3694    }
3695
3696    /// Insert a block-level image, video, or audio at the caret. The image case
3697    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
3698    /// HTML elements, which is the only spelling Markdown and Djot have for them:
3699    ///
3700    /// ```text
3701    /// <video src="clip.mp4" controls>alt</video>
3702    /// <audio src="take.mp3" controls>alt</audio>
3703    /// ```
3704    ///
3705    /// HTML rather than a `::video{…}` directive deliberately. A directive means
3706    /// something only to an app that knows the vocabulary, so the document would
3707    /// read as literal punctuation everywhere else; `<video>` is what every other
3708    /// renderer already understands, and what leaf's own reader picks back up
3709    /// through `html_elements` promotion (see [`parse_extensions`]).
3710    ///
3711    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
3712    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
3713    /// `html_elements`. Before that only the multi-line form parsed as a block at
3714    /// all, and this wrote three lines to work around it.
3715    ///
3716    /// `controls` is always written: a player with no transport is a still frame
3717    /// the reader can't do anything with. Any selection becomes the element's
3718    /// fallback text, exactly as it becomes an image's alt.
3719    ///
3720    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
3721    /// applies, and bites harder here: a `"` in `destination` closes the
3722    /// attribute. A frontend taking these from a file picker is fine; one taking
3723    /// them from free text should keep them tame.
3724    ///
3725    /// [`MediaInfo`]: crate::MediaInfo
3726    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
3727        if kind == MediaKind::Image {
3728            return self.insert_image(destination, alt);
3729        }
3730        // Gated on the *image* gesture, not on one of its own — there isn't one,
3731        // since the bytes below are spelled here rather than by twig, and an HTML
3732        // document would in fact parse them. The button is one control with three
3733        // kinds behind it, and two of them working in a format where the third
3734        // cannot is a worse surface than three that agree — especially as
3735        // `insert_image` is the kind anyone reaches for first.
3736        if self.refuse_unsupported("media", Gesture::InsertImage) {
3737            return;
3738        }
3739        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3740        let alt_text = self
3741            .selected_text()
3742            .map(str::to_string)
3743            .unwrap_or_else(|| alt.to_string());
3744        let tag = match kind {
3745            MediaKind::Audio => "audio",
3746            _ => "video",
3747        };
3748        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
3749        self.edit(start, end, &markup);
3750    }
3751
3752    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
3753    /// button. Spelling and placement are both twig's; leaf used to write `---`
3754    /// itself, which was the Markdown spelling in a djot document too.
3755    ///
3756    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
3757    /// mid-paragraph and lands it after the caret's whole block. To get a rule
3758    /// *at* the caret — the paragraph parted in two around it, which is what a
3759    /// rule button is understood to do — the paragraph is first divided with
3760    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
3761    /// the offset `split_block` returns puts the rule after the *second* half
3762    /// instead, which is a rule in the right document and the wrong place.
3763    ///
3764    /// Only a plain paragraph is split. Everywhere else the rule simply lands
3765    /// after the block, which is both twig's own answer and the better one:
3766    /// splitting a fenced code block would leave two fences with a rule between
3767    /// them, and splitting a list item would mint an item nobody asked for on the
3768    /// way to a rule that lands after the list regardless. A table and a setext
3769    /// heading refuse the split outright, so they take the same path by
3770    /// themselves.
3771    pub fn insert_thematic_break(&mut self) {
3772        if self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak) {
3773            return;
3774        }
3775        self.caret = self.skip_trailing_close_delims(self.caret);
3776        // A selection is replaced by the rule, so collapse it first and let the
3777        // split-and-rule below run from the caret it leaves behind.
3778        if let Some((s, e)) = self.selection() {
3779            self.splice(s, e, "", EditKind::Other);
3780        }
3781        self.anchor = None;
3782        self.record_caret();
3783        let at = self.caret;
3784        if self.caret_in_bare_paragraph() {
3785            // A failure here is not fatal: the rule still lands after the block,
3786            // which is exactly what this call was trying to improve on.
3787            let _ = self.editor.split_block(at);
3788        }
3789        match self.editor.insert_thematic_break(at) {
3790            Ok(change) => {
3791                self.last_edit_kind = None;
3792                self.refresh();
3793                self.anchor = None;
3794                self.caret = change.new.end;
3795                self.dirty = self.source != self.clean_source;
3796                self.status = None;
3797                self.clamp_caret();
3798                self.record_caret();
3799            }
3800            Err(e) => self.status = Some(format!("thematic break: {e}")),
3801        }
3802    }
3803
3804    /// Whether the caret sits in a paragraph and nothing else — no list item, no
3805    /// quote, no fence, no table. The one shape where parting the block around
3806    /// the caret is unambiguously what a rule button means; see
3807    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
3808    /// container is left to take the rule after itself.
3809    fn caret_in_bare_paragraph(&mut self) -> bool {
3810        let caret = self.caret.min(self.source.len());
3811        let Ok(chain) = self.editor.ancestors_at(caret) else { return false };
3812        let mut in_para = false;
3813        for m in chain {
3814            match m.kind {
3815                Kind::Para => in_para = true,
3816                Kind::ListItem
3817                | Kind::TaskListItem
3818                | Kind::BlockQuote
3819                | Kind::CodeBlock
3820                | Kind::Table => return false,
3821                _ => {}
3822            }
3823        }
3824        in_para
3825    }
3826
3827    /// The destination of the link under the caret — what a Link prompt shows so
3828    /// ⌘K on an existing link edits its URL instead of asking for it again.
3829    /// `None` when the caret stands in no link.
3830    ///
3831    /// An autolink carries no separate destination: its text *is* the URL, so
3832    /// that's what comes back for one.
3833    pub fn link_destination_at_caret(&mut self) -> Option<String> {
3834        self.link_destination_at(self.caret)
3835    }
3836
3837    /// The destination of the link at `off`.
3838    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
3839    /// the caret isn't.
3840    ///
3841    /// The offset form exists for the same reason
3842    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
3843    /// the document somewhere else — a footnote's text in a popover, say — has
3844    /// rows and runs but no caret in them, and still needs to know which of those
3845    /// runs a reader can follow.
3846    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
3847        self.nodes()
3848            .into_iter()
3849            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
3850            .filter(|n| n.span.start <= off && off < n.span.end)
3851            .max_by_key(|n| n.span.start)
3852            .and_then(|n| n.destination.or(n.text))
3853    }
3854
3855    /// Where the locator `id` lands in this document — the `#v2` half of a
3856    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
3857    /// answers to it.
3858    ///
3859    /// The other end of a link, and the reason this exists: without it a
3860    /// destination has only file granularity, so following a citation into a
3861    /// chapter drops the reader at the top of it to hunt for the verse. Which is
3862    /// also why it is a *document* query rather than a caret one — the document
3863    /// being asked is usually not the one the reader is in.
3864    ///
3865    /// Three readings, tried in order, because the same `#some-heading` is
3866    /// written three ways across the formats leaf opens:
3867    ///
3868    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
3869    ///    the auto-ids djot mints for its headings. The only exact answer, so it
3870    ///    goes first — a document that says `{#v1}` has settled the question.
3871    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
3872    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
3873    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
3874    ///    authored anywhere land on a djot heading.
3875    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
3876    ///    none and `{#custom}` is literal text in a Markdown heading — so for
3877    ///    the format most vaults are written in, the heading's own words are the
3878    ///    only thing a fragment can name. This is the rule every Markdown
3879    ///    renderer already follows, which is what makes `#a-heading` mean in
3880    ///    diaryx what it means on the web.
3881    ///
3882    /// Ties go to the earliest match, then to the widest: a duplicated id is the
3883    /// document's mistake and the first one is the answer every anchor
3884    /// implementation gives, while preferring the wider span picks the section
3885    /// over the heading that opens it — more for a peek to show, same place to
3886    /// land.
3887    pub fn locate(&mut self, id: &str) -> Option<Landing> {
3888        let id = id.trim();
3889        if id.is_empty() {
3890            return None;
3891        }
3892        let nodes = self.nodes();
3893
3894        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
3895        // is picking, among nodes that start together, the one that ends last.
3896        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
3897            matches
3898                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
3899                .map(|n| Landing { start: n.span.start, end: n.span.end })
3900        };
3901
3902        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
3903            return Some(landing);
3904        }
3905        let want = slug(id);
3906        if want.is_empty() {
3907            return None;
3908        }
3909        if let Some(landing) =
3910            pick(&mut nodes.iter().filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)))
3911        {
3912            return Some(landing);
3913        }
3914
3915        // A heading by its words. Its span is one line, so the end comes from
3916        // where the *section* it opens gives out — the next heading that is not
3917        // under it, or the end of the document. A Markdown heading has no
3918        // section node to ask (twig only builds those for djot), and a peek that
3919        // showed the heading alone would answer "what does that say" with the
3920        // title of the thing it says.
3921        let heading = nodes
3922            .iter()
3923            .filter(|n| n.kind == Kind::Heading)
3924            .filter(|n| {
3925                n.content_span
3926                    .clone()
3927                    .and_then(|s| self.source.get(s))
3928                    .is_some_and(|text| slug(text) == want)
3929            })
3930            .min_by_key(|n| n.span.start)?;
3931        let level = heading.level.unwrap_or(u32::MAX);
3932        let end = nodes
3933            .iter()
3934            .filter(|n| n.kind == Kind::Heading)
3935            .filter(|n| n.span.start > heading.span.start)
3936            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
3937            .map(|n| n.span.start)
3938            .min()
3939            .unwrap_or(self.source.len());
3940        Some(Landing { start: heading.span.start, end })
3941    }
3942
3943    /// Write a footnote at the caret — the toolbar's Footnote button, and the
3944    /// one gesture in the footnote story that *authors* rather than follows.
3945    ///
3946    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
3947    /// the `[^1]:` definition at the end of the document. Half a footnote is not
3948    /// a footnote — a bare reference with nothing defining it renders as literal
3949    /// brackets — so a single button that wrote only the reference would leave
3950    /// the author to hand-spell the other half in a document that had just
3951    /// stopped showing them what the first half meant. One edit also means one
3952    /// undo takes both back.
3953    ///
3954    /// The definition's body is left empty and **the caret lands in it**, which
3955    /// is the whole point of pressing the button: nobody wants a reference to a
3956    /// note they have not written yet. Getting back to where they were writing
3957    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
3958    /// the same return leg a reader following a reference already uses, so the
3959    /// author is left standing on the near end of a round trip that works.
3960    ///
3961    /// A selection collapses to its *end* rather than being replaced: a
3962    /// reference annotates the words before it, so "select the claim, add a
3963    /// footnote" should mark that claim, not consume it.
3964    pub fn insert_footnote(&mut self) {
3965        if self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
3966            return;
3967        }
3968        let at = self.selection().map_or(self.caret, |(_, end)| end);
3969        self.anchor = None;
3970        self.caret = at;
3971        self.record_caret();
3972        let label = self.next_footnote_label();
3973        match self.editor.insert_footnote(at, &label) {
3974            Ok(change) => {
3975                self.last_edit_kind = None;
3976                self.refresh();
3977                self.anchor = None;
3978                // `change.new` runs from the reference to the end of the
3979                // document, so its start is the `[^1]` just written and
3980                // `footnote_at` resolves it to the note the same way a reader's
3981                // tap does — and to the note's *body*, which is already a caret
3982                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
3983                // and has none), so this needs no snap on top. The fallback is
3984                // the reference's own offset: a format that spelled the pair some
3985                // way leaf can't read back should still leave the caret on the
3986                // edit rather than at the far end of a document it just grew.
3987                self.caret = self
3988                    .footnote_at(change.new.start)
3989                    .and_then(|note| note.offset)
3990                    .unwrap_or(change.new.start);
3991                self.dirty = self.source != self.clean_source;
3992                self.status = None;
3993                self.clamp_caret();
3994                self.record_caret();
3995            }
3996            Err(e) => self.status = Some(format!("footnote: {e}")),
3997        }
3998    }
3999
4000    /// The label to give a footnote the author has not named: the lowest counting
4001    /// number no footnote in the document is already wearing.
4002    ///
4003    /// twig takes the label rather than minting one, because it holds no opinion
4004    /// about what a document's footnotes should be called — and it is right not
4005    /// to. Numbering them is what every author of a numbered note expects, and
4006    /// re-using a taken number would silently point the new reference at somebody
4007    /// else's note (twig reuses an existing definition rather than appending a
4008    /// second one, which is the right rule for citing a note twice on purpose and
4009    /// exactly the wrong accident to have by default).
4010    ///
4011    /// *References* are counted alongside definitions, not just definitions: a
4012    /// document carrying a dangling `[^2]` has a 2 that means something to
4013    /// whoever wrote it, and minting a definition for it here would answer a
4014    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4015    /// count entirely — they take no number, so they block none.
4016    fn next_footnote_label(&mut self) -> String {
4017        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4018            .into_iter()
4019            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4020            .filter_map(|label| label.parse().ok())
4021            .collect();
4022        taken.extend(
4023            self.nodes()
4024                .into_iter()
4025                .filter(|n| n.kind == Kind::FootnoteReference)
4026                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4027                .filter_map(|label| label.parse::<u32>().ok()),
4028        );
4029        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4030    }
4031
4032    /// The footnote reference under the caret, resolved to the note it names.
4033    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4034    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4035        self.footnote_at(self.caret)
4036    }
4037
4038    /// The footnote reference at `off`, resolved to the note it names — what a
4039    /// frontend shows when a reader activates a `[^1]`.
4040    ///
4041    /// A reference is not a link node, so
4042    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4043    /// (and should not) answer for one: a link names a destination to leave for,
4044    /// a reference names a note that is already in this document. Following one
4045    /// is a move within the page, which is why this hands back an `offset`
4046    /// rather than something to open.
4047    ///
4048    /// Offset-based rather than caret-only because the gesture that wants this
4049    /// most is the one that must not move the caret: a pointer hovering a `[1]`
4050    /// asks what note it names without disturbing where the reader was typing.
4051    /// The caret is just the offset a click already placed —
4052    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4053    ///
4054    /// `None` when `off` stands in no reference. A reference whose note the
4055    /// document never defines is *not* `None` — it answers with the label it
4056    /// looked for and no text, which is what lets a frontend say so instead of
4057    /// silently doing nothing.
4058    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4059        // Innermost-wins by latest start, the rule its link sibling uses.
4060        let span = self
4061            .nodes()
4062            .into_iter()
4063            .filter(|n| n.kind == Kind::FootnoteReference)
4064            .filter(|n| n.span.start <= off && off < n.span.end)
4065            .max_by_key(|n| n.span.start)?
4066            .span;
4067        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4068
4069        // The note itself. Definitions are roots beside `doc` rather than
4070        // children of it, so they're asked for directly — see
4071        // `wysiwyg::footnote_definitions`.
4072        let note = wysiwyg::footnote_definitions(&mut self.editor)
4073            .into_iter()
4074            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4075        let Some(note) = note else {
4076            return Some(FootnoteRef { label, text: None, offset: None, end: None });
4077        };
4078        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4079        Some(FootnoteRef {
4080            label,
4081            text: body.clone().and_then(|b| self.source.get(b)).map(str::to_string),
4082            // The body's start, not the definition's — see `FootnoteRef::offset`.
4083            offset: body.clone().map(|b| b.start),
4084            end: body.map(|b| b.end),
4085        })
4086    }
4087
4088    /// The footnote *definition* the caret stands in, and where the reference
4089    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4090    /// at the caret's offset.
4091    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4092        self.footnote_definition_at(self.caret)
4093    }
4094
4095    /// The footnote definition spanning `off`, and where the reference that
4096    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4097    ///
4098    /// The mirror image, deliberately: the same gesture that takes a reader from
4099    /// `[1]` down to the note takes them from the note back up to `[1]`, so
4100    /// following a footnote is a round trip rather than a fall. It needs no
4101    /// memory of how the reader arrived — the document says where the reference
4102    /// is — which is what makes it work for a reader who scrolled to the notes
4103    /// themselves, and what keeps it right after an edit moves either end.
4104    ///
4105    /// `None` when `off` stands in no definition. A definition nothing cites is
4106    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4107    /// its label and no offset, so a frontend can say "nothing refers to this"
4108    /// rather than offer a jump that goes nowhere.
4109    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4110        // Definitions are roots beside `doc`, so `nodes()` — which walks the
4111        // document body — never reports one. They're asked for directly, the way
4112        // `footnote_at` asks for the note it resolves to.
4113        //
4114        // Closed at the end, unlike the half-open test its neighbours use. A
4115        // definition's span stops at its last content byte — the newline ending
4116        // the line is outside it — so `span.end` is the caret stop at the end of
4117        // the note's own row, not the first byte of anything after. Excluding it
4118        // meant the one caret an author is guaranteed to have, the one left
4119        // sitting at the end of the note they just typed, was in no definition at
4120        // all: writing a note and then asking to go back to its reference
4121        // answered nothing. Two definitions in a row still can't both match —
4122        // there is a blank line between them — and `max_by_key` decides anyway.
4123        let note = wysiwyg::footnote_definitions(&mut self.editor)
4124            .into_iter()
4125            .filter(|m| m.span.start <= off && off <= m.span.end)
4126            .max_by_key(|m| m.span.start)?;
4127        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4128
4129        // The earliest reference carrying this label. `min` rather than a `find`,
4130        // because `nodes()` reports a flattened walk whose order is twig's
4131        // business, not document order. Bound first: the walk needs `&mut self`
4132        // and reading the labels back out needs `&self.source`.
4133        let nodes = self.nodes();
4134        let offset = nodes
4135            .into_iter()
4136            .filter(|n| n.kind == Kind::FootnoteReference)
4137            .filter(|n| {
4138                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4139            })
4140            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4141            .map(|n| n.span.start + 2)
4142            .min();
4143        Some(FootnoteDef { label, offset })
4144    }
4145
4146    /// The destination of the image under the caret — what an image prompt shows
4147    /// so editing an existing image starts from its current URL instead of blank,
4148    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4149    /// `None` when the caret stands in no image. A caret resting just after a
4150    /// block image (its trailing stop) is still "in" it — the half-open span test
4151    /// excludes that offset, which is the intended precision: past the image is
4152    /// past it.
4153    pub fn image_destination_at_caret(&mut self) -> Option<String> {
4154        let off = self.caret;
4155        self.nodes()
4156            .into_iter()
4157            .filter(|n| n.kind == Kind::Image)
4158            .filter(|n| n.span.start <= off && off < n.span.end)
4159            .max_by_key(|n| n.span.start)
4160            .and_then(|n| n.destination)
4161    }
4162
4163    /// The language of the fenced code block the caret stands in — what a
4164    /// language prompt shows so editing it starts from the current value rather
4165    /// than blank. `None` when the caret is in no code block, or in one whose
4166    /// fence carries no language (or an indented block, which has no fence).
4167    pub fn code_language_at_caret(&mut self) -> Option<String> {
4168        let start = self.code_block_start_at_caret()?;
4169        wysiwyg::code_language(&self.source, start)
4170    }
4171
4172    /// Whether the caret stands in a fenced code block — the one a language
4173    /// prompt could edit. A frontend gates its "set language" affordance on this
4174    /// (an indented block, which can't carry a language, reports `false`).
4175    pub fn caret_in_fenced_code(&mut self) -> bool {
4176        self.code_block_start_at_caret()
4177            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4178    }
4179
4180    /// Set (or clear, with `""`) the language of the fenced code block the caret
4181    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4182    /// block, and a reported error for a language the format's fence cannot
4183    /// carry.
4184    ///
4185    /// twig rewrites the info string, so the fence's own width — measured
4186    /// against a body neither side touches — is kept, and a language holding a
4187    /// space, a line end or the fence character is refused rather than written
4188    /// out to reparse as something else. Leaf used to splice over the info span
4189    /// itself and `trim()` the input, which handled the one bad case it had
4190    /// thought of.
4191    pub fn set_code_language(&mut self, lang: &str) {
4192        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4193            return;
4194        }
4195        if self.code_block_start_at_caret().is_none() {
4196            return;
4197        }
4198        let lang = lang.trim();
4199        // `None` clears the info string; `Some("")` asks for an empty one. Both
4200        // write a bare fence, and the prompt's empty value means "clear".
4201        let want = (!lang.is_empty()).then_some(lang);
4202        self.record_caret();
4203        match self.editor.set_code_language(self.caret, want) {
4204            Ok(_) => {
4205                self.last_edit_kind = None;
4206                self.refresh();
4207                self.anchor = None;
4208                self.dirty = self.source != self.clean_source;
4209                self.status = None;
4210                self.clamp_caret();
4211                self.record_caret();
4212            }
4213            Err(e) => self.status = Some(format!("code language: {e}")),
4214        }
4215    }
4216
4217    /// The `span.start` of the code block covering the caret — the anchor
4218    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4219    /// in none.
4220    fn code_block_start_at_caret(&mut self) -> Option<usize> {
4221        let off = self.caret;
4222        self.nodes()
4223            .into_iter()
4224            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4225            .max_by_key(|n| n.span.start)
4226            .map(|n| n.span.start)
4227    }
4228
4229    /// The source range of the text inside the link covering `off` — what sits
4230    /// between its `[` and `]`. `None` when twig reports no link there.
4231    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4232        self.nodes()
4233            .into_iter()
4234            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4235            // the other's `span.start`; the link that starts latest at or before
4236            // `off` is the one `off` is actually in.
4237            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4238            .max_by_key(|n| n.span.start)
4239            .and_then(|n| n.content_span)
4240    }
4241
4242    // ── undo / redo ───────────────────────────────────────────────────────────
4243    // twig owns the history of *bytes* (it owns the buffer) and now carries the
4244    // caret through it too: `record_caret` stashes each state's caret in twig's
4245    // opaque per-step blob, and undo/redo hand it back with the source they
4246    // restore. So leaf keeps no history of its own — no parallel stacks to march
4247    // in lockstep and silently drift out of it.
4248
4249    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4250    /// where they were when that step began.
4251    pub fn undo(&mut self) {
4252        match self.editor.undo() {
4253            Ok(Some(change)) => self.after_history(change),
4254            Ok(None) => self.status = Some("nothing to undo".into()),
4255            Err(e) => self.status = Some(format!("undo: {e}")),
4256        }
4257    }
4258
4259    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4260    /// selection back where that step originally left them.
4261    pub fn redo(&mut self) {
4262        match self.editor.redo() {
4263            Ok(Some(change)) => self.after_history(change),
4264            Ok(None) => self.status = Some("nothing to redo".into()),
4265            Err(e) => self.status = Some(format!("redo: {e}")),
4266        }
4267    }
4268
4269    /// Refresh the cached source and put the caret back where the step being
4270    /// undone/redone had it, clearing any active run.
4271    ///
4272    /// The caret comes from twig's blob for the restored state (what
4273    /// `record_caret` stored). `change` is only the fallback for a state with no
4274    /// blob — a caret at the end of the restored text, which is where this always
4275    /// landed before the blobs were kept. It is the edit site, not where the user
4276    /// was standing, so it's a floor and not the behaviour: undoing should hand
4277    /// back the document *and* the place you were working, which for an edit made
4278    /// anywhere but under the caret are two different places.
4279    fn after_history(&mut self, change: Change) {
4280        self.refresh();
4281        match self.editor.caret_blob().ok().and_then(|b| CaretState::from_blob(&b)) {
4282            Some(state) => {
4283                self.caret = state.caret.min(self.source.len());
4284                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4285            }
4286            None => {
4287                self.caret = change.new.end.min(self.source.len());
4288                self.anchor = None;
4289            }
4290        }
4291        self.goal_col = None;
4292        self.last_edit_kind = None;
4293        self.dirty = self.source != self.clean_source;
4294        self.status = None;
4295        self.clamp_caret();
4296    }
4297
4298    // ── the file ──────────────────────────────────────────────────────────────
4299
4300    #[cfg(feature = "fs")]
4301    pub fn save(&mut self) {
4302        if self.is_untitled() {
4303            // No path to write and no name to invent: ⌘S on an untitled document
4304            // is a Save As, and only a frontend has a picker to ask with. Say so
4305            // rather than failing at the filesystem with an empty path.
4306            self.status = Some("untitled — save as…".into());
4307            return;
4308        }
4309        let path = self.path.clone();
4310        if self.write(&path) {
4311            self.mark_saved();
4312        }
4313    }
4314
4315    /// Save As: write the document to `path` and *move* it there — `self.path`
4316    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4317    /// what Save As means; a copy would leave the user editing a document whose
4318    /// name is no longer where their keystrokes go.
4319    ///
4320    /// The move only happens if the bytes actually landed. A failed write leaves
4321    /// the path, `dirty`, and the disk watermark exactly as they were, with the
4322    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4323    /// must never come away believing it was saved.
4324    ///
4325    /// An existing `path` is overwritten, and the caller is the one that knows
4326    /// whether to ask first: a Save As picker has already run that prompt, and a
4327    /// second confirmation from down here would be the same question twice.
4328    ///
4329    /// `format` does **not** follow the new extension. The buffer is parsed as
4330    /// the format it was opened with, and re-reading it as another one is a
4331    /// conversion — a different, lossy operation that would throw away the undo
4332    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4333    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4334    /// until it's reopened.
4335    #[cfg(feature = "fs")]
4336    pub fn save_as(&mut self, path: PathBuf) {
4337        if !self.write(&path) {
4338            return;
4339        }
4340        self.path = path;
4341        self.mark_saved();
4342    }
4343
4344    /// Put `source` on disk at `path`, reporting whether it got there. The one
4345    /// place leaf writes a document, so a save and a Save As can't disagree
4346    /// about what a failure looks like.
4347    #[cfg(feature = "fs")]
4348    fn write(&mut self, path: &Path) -> bool {
4349        match std::fs::write(path, self.source.as_bytes()) {
4350            Ok(()) => true,
4351            Err(e) => {
4352                self.status = Some(format!("save failed: {e}"));
4353                false
4354            }
4355        }
4356    }
4357
4358    /// Re-base the document's saved watermark to the current bytes: clears
4359    /// `dirty`, records `source` as the new clean state (so undoing back to here
4360    /// clears the flag again), and re-stamps the on-disk hash.
4361    ///
4362    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4363    /// the hook a **filesystem-free host** calls itself once it has persisted
4364    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4365    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4366    /// are already where that host wants them, and this just tells the model they
4367    /// are safe.
4368    pub fn mark_saved(&mut self) {
4369        self.clean_source = self.source.clone();
4370        self.dirty = false;
4371        // The bytes on disk are now ours, so this is the new watermark: without
4372        // re-stamping it, every save would report its own work as an external
4373        // change forever after.
4374        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4375        self.status = Some(format!("saved {}", self.file_name()));
4376    }
4377
4378    /// What the file looks like now against the bytes leaf last read or wrote.
4379    ///
4380    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4381    /// so this is a filesystem round-trip, not a per-frame question — ask it
4382    /// when a window regains focus, on a timer, or before a save.
4383    ///
4384    /// This *only* reports the file. Whether the document also has unsaved edits
4385    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4386    /// [`DiskState::Changed`] means a save overwrites someone's work and a
4387    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4388    /// it has no way to ask — so it hands a frontend both halves and lets it put
4389    /// the question to the person who can answer it.
4390    #[cfg(feature = "fs")]
4391    pub fn disk_state(&self) -> DiskState {
4392        let Some(want) = self.disk_hash else {
4393            return DiskState::Untitled;
4394        };
4395        match std::fs::read(&self.path) {
4396            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4397            Ok(_) => DiskState::Changed,
4398            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4399            Err(_) => DiskState::Unreadable,
4400        }
4401    }
4402
4403    /// Re-read the file and replace the document with what's there — the other
4404    /// answer to a [`DiskState::Changed`].
4405    ///
4406    /// **Discards unsaved changes and the undo history, unconditionally.** It
4407    /// doesn't check `dirty` first: a frontend that wants to protect unsaved
4408    /// work asks (`dirty` + [`Doc::disk_state`]) *before* calling this, and one
4409    /// reloading a clean document shouldn't have to argue with a guard. The
4410    /// history goes because twig's undo stack belongs to the buffer, and these
4411    /// are different bytes — replaying a step recorded against the old ones onto
4412    /// them would corrupt the document, and nothing here can honestly rebase it.
4413    ///
4414    /// The caret keeps its byte offset, clamped to the new length; the selection
4415    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4416    /// file changed, so it can't know where the caret "still" is. Clamping keeps
4417    /// it where the user left it in the common case (a change further down the
4418    /// file, or none in the text they're sitting in), and never puts it
4419    /// somewhere invalid. A selection has two such offsets and no such excuse —
4420    /// silently reinterpreting one over changed bytes would arm the *next*
4421    /// keystroke to delete something the user never selected.
4422    ///
4423    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4424    /// document alone with a status.
4425    #[cfg(feature = "fs")]
4426    pub fn reload(&mut self) {
4427        if self.is_untitled() {
4428            self.status = Some("no file to reload".into());
4429            return;
4430        }
4431        let bytes = match std::fs::read(&self.path) {
4432            Ok(b) => b,
4433            Err(e) => {
4434                self.status = Some(format!("reload failed: {e}"));
4435                return;
4436            }
4437        };
4438        let Ok(source) = String::from_utf8(bytes) else {
4439            self.status = Some("reload failed: file is not UTF-8".into());
4440            return;
4441        };
4442        // Reparse rather than splice the difference in: leaf doesn't know what
4443        // changed, and `format` is the format this document is, not what the
4444        // (unchanged) name now says — see `save_as`.
4445        let editor = match new_editor(source.as_bytes(), self.format) {
4446            Ok(ed) => ed,
4447            Err(e) => {
4448                self.status = Some(format!("reload failed: {e}"));
4449                return;
4450            }
4451        };
4452        self.editor = editor;
4453        self.disk_hash = Some(hash_bytes(source.as_bytes()));
4454        self.clean_source = source.clone();
4455        self.source = source;
4456        // Reload replaces the text without going through `refresh`, so it has to
4457        // move the revision itself or every frontend would keep painting the old
4458        // file from cache.
4459        self.revision += 1;
4460        self.caret = self.caret.min(self.source.len());
4461        self.anchor = None;
4462        self.goal_col = None;
4463        self.last_edit_kind = None;
4464        self.dirty = false;
4465        self.status = Some(format!("reloaded {}", self.file_name()));
4466        self.clamp_caret();
4467    }
4468
4469    /// Re-read the source from twig after it has changed the document. The one
4470    /// funnel every edit, undo, and redo comes through — so it's where the
4471    /// revision moves, and anything cached against the text dies here.
4472    fn refresh(&mut self) {
4473        if let Ok(s) = self.editor.source_str() {
4474            self.source = s;
4475        }
4476        self.revision += 1;
4477        self.clamp_caret();
4478    }
4479
4480    // ── caret movement ─────────────────────────────────────────────────────────
4481    // `extend` grows the selection (Shift+motion): it pins the anchor on the
4482    // first extended step and moves only the caret; an un-extended motion drops
4483    // the selection.
4484
4485    /// Place the caret at byte `offset` (clamped to a char boundary), extending
4486    /// the selection when `extend` is set. The public form of `move_to`, for a
4487    /// frontend that hit-tests pixels straight to a source offset.
4488    pub fn place_caret(&mut self, offset: usize, extend: bool) {
4489        self.goal_col = None;
4490        let before = self.caret;
4491        // A pixel hit-test can land between the visible caret stops — in the
4492        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
4493        // Snap to the nearest real stop so the caret can't come to rest where it
4494        // would draw in one place and type in another. The `(row, col)` click
4495        // path (`click`) already snaps this way through `offset_of_pos`; the
4496        // source view reaches every byte, so it snaps to nothing.
4497        let target = match self.view {
4498            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
4499            // The source view reaches every byte, so there is no stop to snap
4500            // to — but "every byte" still means every *character* boundary. A
4501            // caret resting inside a multi-byte character draws nowhere real
4502            // and panics the next time anything slices there.
4503            View::Source => {
4504                let mut o = offset.min(self.source.len());
4505                while o > 0 && !self.source.is_char_boundary(o) {
4506                    o -= 1;
4507                }
4508                o
4509            }
4510        };
4511        self.move_to(target, extend);
4512        self.clamp_caret();
4513        self.debug_assert_on_a_stop(before);
4514    }
4515
4516    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
4517    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
4518    /// grab the metadata) while the source view still selects the literal whole.
4519    pub fn select_all(&mut self) {
4520        self.anchor = Some(self.caret_floor());
4521        self.caret = self.source.len();
4522        self.goal_col = None;
4523        self.last_edit_kind = None;
4524        self.status = None;
4525    }
4526
4527    /// Select the word (or whitespace / punctuation run) at `offset` — the
4528    /// double-click gesture. Anchors on the run's start with the caret at its
4529    /// end so a following Shift-motion extends from the far edge.
4530    pub fn select_word_at(&mut self, offset: usize) {
4531        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
4532        self.anchor = Some(s);
4533        self.caret = e;
4534        self.goal_col = None;
4535        self.last_edit_kind = None;
4536        self.status = None;
4537        self.clamp_caret();
4538    }
4539
4540    /// Select the whole enclosing text block (paragraph, heading, list item's
4541    /// text…) at `offset` — the triple-click gesture. Reads the range straight
4542    /// from the AST (twig's `content_span`), so it selects the entire *logical*
4543    /// paragraph even when that paragraph soft-wraps across several visual rows —
4544    /// where a visual-row-based select breaks down, because one source offset at
4545    /// a wrap boundary belongs to two rows at once.
4546    pub fn select_block_at(&mut self, offset: usize) {
4547        let off = offset.min(self.source.len());
4548        let range = self
4549            .editor
4550            .ancestors_at(off)
4551            .ok()
4552            .and_then(|chain| {
4553                // Ancestors run root → deepest; the deepest node that is neither
4554                // an inline span nor a multi-block container is the text block
4555                // the caret sits in (a paragraph, a heading, a code block…).
4556                chain
4557                    .into_iter()
4558                    .rev()
4559                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4560                    .map(|m| m.content_span.unwrap_or(m.span))
4561            })
4562            .unwrap_or_else(|| source_line_range(&self.source, off));
4563        self.anchor = Some(range.start.min(self.source.len()));
4564        self.caret = range.end.min(self.source.len());
4565        self.goal_col = None;
4566        self.last_edit_kind = None;
4567        self.status = None;
4568        self.clamp_caret();
4569    }
4570
4571    /// The lowest source offset the caret may occupy in the active view. In
4572    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
4573    /// the first rendered offset; the source view reaches everything, so it's 0.
4574    fn caret_floor(&self) -> usize {
4575        match self.view {
4576            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
4577            View::Source => 0,
4578        }
4579    }
4580
4581    /// Land in a table cell with its whole content selected — the anchor at the
4582    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
4583    /// like tabbing into a form field: the text comes up selected, so typing
4584    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
4585    /// end`) collapses to a plain caret home (an empty selection is no selection).
4586    fn select_cell(&mut self, start: usize, end: usize) {
4587        let floor = self.caret_floor();
4588        self.anchor = Some(start.min(self.source.len()).max(floor));
4589        self.caret = end.min(self.source.len()).max(floor);
4590        self.goal_col = None;
4591        self.status = None;
4592        self.last_edit_kind = None;
4593        self.clear_pending();
4594    }
4595
4596    fn move_to(&mut self, offset: usize, extend: bool) {
4597        if extend {
4598            if self.anchor.is_none() {
4599                self.anchor = Some(self.caret);
4600            }
4601        } else {
4602            self.anchor = None;
4603        }
4604        self.caret = offset.min(self.source.len()).max(self.caret_floor());
4605        self.status = None;
4606        // A caret move ends the current typing/deletion run, so the next edit
4607        // starts a fresh undo group rather than coalescing across the gap.
4608        self.last_edit_kind = None;
4609        // Moving away disarms any sticky mark — "start bold" applies only where
4610        // it was asked for, not wherever the caret next lands.
4611        self.clear_pending();
4612    }
4613
4614    // In the source view, motion walks source bytes / source lines. In the
4615    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
4616    // what steps the caret cleanly over hidden delimiters.
4617
4618    pub fn move_left(&mut self, extend: bool) {
4619        self.goal_col = None;
4620        if !extend {
4621            if let Some((s, _e)) = self.selection() {
4622                self.move_to(s, false);
4623                return;
4624            }
4625        }
4626        let target = match self.view {
4627            View::Source => {
4628                if self.caret > 0 {
4629                    prev_boundary(&self.source, self.caret)
4630                } else {
4631                    0
4632                }
4633            }
4634            // Walks caret *stops*, not columns: decoration (a table border, a
4635            // cell's padding) is stepped over in one press, and a hidden
4636            // delimiter never holds the caret up.
4637            View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
4638        };
4639        let before = self.caret;
4640        self.move_to(target, extend);
4641        self.debug_assert_on_a_stop(before);
4642    }
4643
4644    pub fn move_right(&mut self, extend: bool) {
4645        self.goal_col = None;
4646        if !extend {
4647            if let Some((_s, e)) = self.selection() {
4648                self.move_to(e, false);
4649                return;
4650            }
4651        }
4652        let target = match self.view {
4653            View::Source => {
4654                if self.caret < self.source.len() {
4655                    next_boundary(&self.source, self.caret)
4656                } else {
4657                    self.caret
4658                }
4659            }
4660            View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
4661        };
4662        let before = self.caret;
4663        self.move_to(target, extend);
4664        self.debug_assert_on_a_stop(before);
4665    }
4666
4667    /// Move to the start of the previous word (⌥← / Ctrl+←).
4668    pub fn move_word_left(&mut self, extend: bool) {
4669        self.goal_col = None;
4670        let before = self.caret;
4671        let target = self.word_left_from(self.caret);
4672        self.move_to(target, extend);
4673        self.debug_assert_on_a_stop(before);
4674    }
4675
4676    /// Move to the end of the next word (⌥→ / Ctrl+→).
4677    pub fn move_word_right(&mut self, extend: bool) {
4678        self.goal_col = None;
4679        let before = self.caret;
4680        let target = self.word_right_from(self.caret);
4681        self.move_to(target, extend);
4682        self.debug_assert_on_a_stop(before);
4683    }
4684
4685    // Word boundaries are found in the space the *view* is in. The source view
4686    // walks the source, because there the source is what's rendered. WYSIWYG
4687    // walks the rendered text instead: `**` is invisible to the user, so it has
4688    // to be invisible to word motion too — a caret parked inside one draws in
4689    // the column after `bold` and types two bytes earlier, and a word-delete
4690    // that stops there shreds the markup into `a ** c`.
4691
4692    /// The word boundary to the left of `off` in the active view's space.
4693    fn word_left_from(&self, off: usize) -> usize {
4694        match self.view {
4695            View::Source => prev_word(&self.source, off),
4696            View::Wysiwyg => self.glyph_word_left(off),
4697        }
4698    }
4699
4700    /// The word boundary to the right of `off` in the active view's space.
4701    fn word_right_from(&self, off: usize) -> usize {
4702        match self.view {
4703            View::Source => next_word(&self.source, off),
4704            View::Wysiwyg => self.glyph_word_right(off),
4705        }
4706    }
4707
4708    /// The character class of the glyph drawn at stop `off`.
4709    ///
4710    /// Read from the source, because a stop points at the source byte its glyph
4711    /// came from — the source *is* where the rendered character is written. What
4712    /// makes the walk glyph space rather than source space is that it only ever
4713    /// visits stops, and the hidden bytes between them have none.
4714    fn class_at(&self, off: usize) -> Class {
4715        self.source
4716            .get(off..)
4717            .and_then(|s| s.chars().next())
4718            .map_or(Class::Space, classify)
4719    }
4720
4721    /// [`next_word`] in glyph space: skip any leading separators, then consume
4722    /// the following word run, with the stop table standing in for the source's
4723    /// characters.
4724    fn glyph_word_right(&self, from: usize) -> usize {
4725        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
4726            return from;
4727        };
4728        let mut in_word = false;
4729        loop {
4730            match self.class_at(off) {
4731                Class::Word => in_word = true,
4732                _ if in_word => return off,
4733                _ => {}
4734            }
4735            match self.vmap.stop_after(off) {
4736                Some(next) => off = next,
4737                None => return off,
4738            }
4739        }
4740    }
4741
4742    /// [`prev_word`] in glyph space: skip separators walking left, then consume
4743    /// the preceding word run.
4744    fn glyph_word_left(&self, from: usize) -> usize {
4745        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
4746            return from;
4747        };
4748        let mut in_word = false;
4749        while let Some(prev) = self.vmap.stop_before(off) {
4750            match self.class_at(prev) {
4751                Class::Word => in_word = true,
4752                _ if in_word => return off,
4753                _ => {}
4754            }
4755            off = prev;
4756        }
4757        off
4758    }
4759
4760    /// After a motion that walks the visual map, the caret must be *on* the map.
4761    /// A stop is the only offset where the caret draws and edits in the same
4762    /// place, and it's the invariant both a caret parked inside an emoji and one
4763    /// parked inside a `**` were quietly breaking.
4764    ///
4765    /// Only when the caret actually moved: a walk with nowhere to go leaves it
4766    /// where it was, which is wherever the floor or a frontend put it rather
4767    /// than somewhere this motion chose.
4768    fn debug_assert_on_a_stop(&self, before: usize) {
4769        debug_assert!(
4770            self.view != View::Wysiwyg
4771                || self.vmap.num_rows() == 0
4772                || self.caret == before
4773                || self.vmap.is_stop(self.caret),
4774            "motion left the caret at {}, which is not a caret stop: it would draw in \
4775             one place and type in another",
4776            self.caret
4777        );
4778    }
4779
4780    // Up and Down run off the ends of the document rather than stopping dead at
4781    // them: Up from the first row lands at the document's start, Down from the
4782    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
4783    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
4784    // reaching the end of the text is what a reader means by it.
4785    //
4786    // The views used to disagree here by accident rather than by decision: the
4787    // source view fell into the edge behaviour through `row_col_to_offset`
4788    // clamping an out-of-range row to the end of the string, while WYSIWYG had
4789    // no row below to walk to and did nothing at all. They share the rule now,
4790    // each in its own space — the source view reaches every byte, WYSIWYG only
4791    // the offsets it draws.
4792
4793    pub fn move_up(&mut self, extend: bool) {
4794        let (row, col) = self.caret_pos();
4795        let goal = self.goal_col.unwrap_or(col);
4796        let target = match self.view {
4797            View::Source => match row.checked_sub(1) {
4798                Some(r) => row_col_to_offset(&self.source, r, goal),
4799                None => self.reachable_start(),
4800            },
4801            // A table's border rules are drawn but hold no caret, so Up steps
4802            // over them to the row that does.
4803            View::Wysiwyg => match self.vmap.navigable_above(row) {
4804                Some(r) => self.row_target(r, goal),
4805                None => self.reachable_start(),
4806            },
4807        };
4808        self.step_vertical(target, goal, extend);
4809    }
4810
4811    pub fn move_down(&mut self, extend: bool) {
4812        let (row, col) = self.caret_pos();
4813        let goal = self.goal_col.unwrap_or(col);
4814        let target = match self.view {
4815            View::Source => match self.source_row_below(row) {
4816                Some(r) => row_col_to_offset(&self.source, r, goal),
4817                None => self.reachable_end(),
4818            },
4819            View::Wysiwyg => match self.vmap.navigable_below(row) {
4820                Some(r) => self.row_target(r, goal),
4821                None => self.reachable_end(),
4822            },
4823        };
4824        self.step_vertical(target, goal, extend);
4825    }
4826
4827    /// Land a vertical motion at `target`, latching the `goal` column it aimed
4828    /// with so the rest of the run keeps aiming there.
4829    ///
4830    /// A motion with nowhere to go changes *nothing*, the goal column included:
4831    /// the latch used to run before the early return at the top of the document,
4832    /// so an Up that did nothing still armed a column, and the next Down aimed
4833    /// at one the caret had never been in.
4834    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
4835        let before = self.caret;
4836        if target == before {
4837            return;
4838        }
4839        self.goal_col = Some(goal);
4840        self.move_to(target, extend);
4841        self.debug_assert_on_a_stop(before);
4842    }
4843
4844    /// The source line below `row`, or `None` when `row` is the last one. Lines
4845    /// are counted by newline, so a trailing one leaves a real, empty last line
4846    /// for the caret to sit on — the document ends below it, not on it.
4847    fn source_row_below(&self, row: usize) -> Option<usize> {
4848        let last = self.source.bytes().filter(|&b| b == b'\n').count();
4849        (row < last).then_some(row + 1)
4850    }
4851
4852    /// Where a vertical motion aiming at the `goal` column lands on visual row
4853    /// `r`: the column clamped to the row, mapped to its offset, then held
4854    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
4855    /// column belongs to the row below, and a gutter's column 0 points at the
4856    /// block rather than at this row.
4857    fn row_target(&self, r: usize, goal: usize) -> usize {
4858        let (start, end) = self.row_bounds(r);
4859        self.vmap
4860            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
4861            .clamp(start, end)
4862    }
4863
4864    /// The first and last offsets the caret can reach in the active view.
4865    ///
4866    /// Not the same span in both: the source view shows every byte, so it can
4867    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
4868    /// sits below the first stop, and a document's trailing newline is drawn
4869    /// nowhere and so sits past the last.
4870    fn reachable_start(&self) -> usize {
4871        match self.view {
4872            View::Source => 0,
4873            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
4874        }
4875    }
4876
4877    fn reachable_end(&self) -> usize {
4878        match self.view {
4879            View::Source => self.source.len(),
4880            View::Wysiwyg => self.vmap.stop_at_or_before(self.source.len()).unwrap_or(self.caret),
4881        }
4882    }
4883
4884    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
4885    /// including the space a soft wrap ate off its end, which is drawn on this
4886    /// row however much the offset past it belongs to the next one.
4887    fn row_span(&self, r: usize) -> (usize, usize) {
4888        let start = self
4889            .vmap
4890            .row_start(r)
4891            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
4892        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
4893        (start.min(end), end)
4894    }
4895
4896    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
4897    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
4898    /// this row's last position is the one before it — the offset before the
4899    /// space the wrap ate, where the caret draws just past the row's last word
4900    /// and types there too.
4901    ///
4902    /// Aiming at the shared offset instead is what stalled End: it is the row's
4903    /// last *column*, so End pressed on the row reached it and then read back as
4904    /// the row below's start, where a second press ran on to that row's end and
4905    /// the next to the one after — End walking down the paragraph a row a press.
4906    fn row_bounds(&self, r: usize) -> (usize, usize) {
4907        let (start, end) = self.row_span(r);
4908        let wraps = self
4909            .vmap
4910            .navigable_below(r)
4911            .and_then(|b| self.vmap.row_start(b))
4912            .is_some_and(|off| off == end);
4913        match wraps {
4914            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
4915            false => (start, end),
4916        }
4917    }
4918
4919    /// The `[start, end]` of the line Home and End aim at: the visual row in
4920    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
4921    ///
4922    /// A soft-wrapped row is a line here, because it is one to the eye and the
4923    /// eye is what these keys are aimed by — a reader pressing End means the end
4924    /// of the line they can see. (`select_block_at` wants the opposite and reads
4925    /// the AST for it: a triple-click grabs the whole paragraph, however many
4926    /// rows it folds into.)
4927    fn line_bounds(&self) -> (usize, usize) {
4928        let (row, _) = self.caret_pos();
4929        match self.view {
4930            View::Source => {
4931                let start = line_start(&self.source, row);
4932                (start, line_end_from(&self.source, start))
4933            }
4934            View::Wysiwyg => self.row_bounds(row),
4935        }
4936    }
4937
4938    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
4939    /// *drawn* — what a kill takes.
4940    ///
4941    /// The two part only at a soft wrap, over the space the wrap ate: the caret
4942    /// can't stand after it (that offset opens the row below, and End stopping
4943    /// there would walk), but it is on this row, and a kill that spared it would
4944    /// leave a double space behind where the row's text had been. Deleting it
4945    /// joins nothing — a wrap is drawn, not written.
4946    fn line_span(&self) -> (usize, usize) {
4947        let (row, _) = self.caret_pos();
4948        match self.view {
4949            View::Source => self.line_bounds(),
4950            View::Wysiwyg => self.row_span(row),
4951        }
4952    }
4953
4954    /// The first offset in `[start, end]` holding something other than
4955    /// whitespace, or `end` when the line holds nothing else — where Home aims.
4956    ///
4957    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
4958    /// so a hidden delimiter is never taken for the line's first character (nor
4959    /// landed on), and the source view steps the source it is showing.
4960    fn first_non_space(&self, start: usize, end: usize) -> usize {
4961        let mut off = start;
4962        while off < end {
4963            if self.class_at(off) != Class::Space {
4964                return off;
4965            }
4966            off = match self.view {
4967                View::Source => next_boundary(&self.source, off),
4968                View::Wysiwyg => match self.vmap.stop_after(off) {
4969                    Some(next) => next,
4970                    None => return end,
4971                },
4972            };
4973        }
4974        end
4975    }
4976
4977    /// Home: to the first character on the line, or to column 0 when the caret
4978    /// is already on it — the two-press toggle every editor spells this way.
4979    /// The indentation is somewhere the caret has to be able to reach and almost
4980    /// never where a reader is headed, so it costs the second press.
4981    pub fn move_home(&mut self, extend: bool) {
4982        self.goal_col = None;
4983        let (start, end) = self.line_bounds();
4984        let text = self.first_non_space(start, end);
4985        let target = if self.caret == text { start } else { text };
4986        let before = self.caret;
4987        self.move_to(target, extend);
4988        self.debug_assert_on_a_stop(before);
4989    }
4990
4991    /// End: to the end of the line.
4992    pub fn move_end(&mut self, extend: bool) {
4993        self.goal_col = None;
4994        let (_, end) = self.line_bounds();
4995        let before = self.caret;
4996        self.move_to(end, extend);
4997        self.debug_assert_on_a_stop(before);
4998    }
4999
5000    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5001    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5002    /// when the caret isn't in a table, or is already in the last/first cell — the
5003    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5004    /// meaning everywhere else.
5005    pub fn cell_hop(&mut self, forward: bool) -> bool {
5006        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5007            return false;
5008        };
5009        // Flatten to document (row-major) order and step one cell either way.
5010        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5011        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5012        let next = if forward { i.checked_add(1) } else { i.checked_sub(1) };
5013        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5014            return false; // at the table's edge; leave Tab to the frontend
5015        };
5016        self.select_cell(start, end);
5017        true
5018    }
5019
5020    /// Move the caret to the cell directly above (`down == false`) or below in
5021    /// the same column, landing with the cell's whole content selected (see
5022    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5023    /// when the caret isn't in a table), so the frontend can fall through — the
5024    /// vertical counterpart of [`cell_hop`].
5025    ///
5026    /// A ragged row that is short a column clamps to its last cell, so Down never
5027    /// falls out of the table over a gap the row above happened to have.
5028    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5029        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5030            return false;
5031        };
5032        let target = match down {
5033            true => r + 1,
5034            false if r == 0 => return false,
5035            false => r - 1,
5036        };
5037        let Some(row) = grid.get(target) else {
5038            return false;
5039        };
5040        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5041            return false;
5042        };
5043        self.select_cell(start, end);
5044        true
5045    }
5046
5047    /// The table containing `off` as a row-major grid of `(start, end)` cell
5048    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5049    /// isn't in a table. Read straight off the visual map's laid-out grid, so
5050    /// every cell (an empty one included, whose derived home twig gives no
5051    /// `content_span` for) is present and in the order Tab walks them.
5052    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5053        for t in &self.vmap.tables {
5054            let mut pos = None;
5055            let grid: Vec<Vec<(usize, usize)>> = t
5056                .grid
5057                .iter()
5058                .enumerate()
5059                .map(|(r, row)| {
5060                    row.cells
5061                        .iter()
5062                        .enumerate()
5063                        .map(|(c, cell)| {
5064                            if pos.is_none() && off >= cell.start && off <= cell.end {
5065                                pos = Some((r, c));
5066                            }
5067                            (cell.start, cell.end)
5068                        })
5069                        .collect()
5070                })
5071                .collect();
5072            if let Some((r, c)) = pos {
5073                return Some((grid, r, c));
5074            }
5075        }
5076        None
5077    }
5078
5079    // ── table key policy ──────────────────────────────────────────────────────
5080    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5081    // as one policy every frontend shares, rather than each re-deriving it. Each
5082    // reports whether it acted *as a table key*; a `false` hands the key back to
5083    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5084    // everywhere else.
5085
5086    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5087    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5088    /// back and simply stays put at the very first cell. `false` when the caret
5089    /// isn't in a table.
5090    pub fn cell_tab(&mut self, forward: bool) -> bool {
5091        if !self.caret_in_table() {
5092            return false;
5093        }
5094        if self.cell_hop(forward) {
5095            return true;
5096        }
5097        // Off the last cell: grow the table by a row and step into its first
5098        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5099        if forward {
5100            self.append_row_and_enter(0);
5101        }
5102        true
5103    }
5104
5105    /// Return inside a table: drop to the cell below in the same column,
5106    /// appending a new row when the caret is already in the last one. `false`
5107    /// when the caret isn't in a table, so the frontend inserts a newline.
5108    pub fn cell_return(&mut self) -> bool {
5109        if !self.caret_in_table() {
5110            return false;
5111        }
5112        if self.cell_move_vertical(true) {
5113            return true;
5114        }
5115        // Already on the last row: grow one below and drop into the same column.
5116        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5117        self.append_row_and_enter(col);
5118        true
5119    }
5120
5121    /// Append a row below the caret's (last) row and land in `col` of it. The
5122    /// caret is in the last row, so twig's "insert below" makes the fresh row the
5123    /// table's new last — but twig re-spells the whole table, moving every byte,
5124    /// so the destination is read back from the rebuilt grid by the table's
5125    /// position (stable across a row insert), not from the pre-edit caret.
5126    fn append_row_and_enter(&mut self, col: usize) {
5127        let table = self.caret_table_index();
5128        self.table_insert_row(true);
5129        self.rebuild_map();
5130        let Some((start, end)) = table
5131            .and_then(|ti| self.vmap.tables.get(ti))
5132            .and_then(|t| t.grid.last())
5133            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5134            .map(|cell| (cell.start, cell.end))
5135        else {
5136            return;
5137        };
5138        self.select_cell(start, end);
5139    }
5140
5141    /// The index, among the document's tables, of the one the caret sits in —
5142    /// `None` when it's in none. Used to re-find a table after an edit re-spells
5143    /// it (a row insert leaves the table order unchanged).
5144    fn caret_table_index(&self) -> Option<usize> {
5145        let off = self.caret;
5146        self.vmap.tables.iter().position(|t| {
5147            t.grid
5148                .iter()
5149                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5150        })
5151    }
5152
5153    /// Shift+Return inside a table: insert a hard line break *within* the current
5154    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5155    /// table, so the frontend inserts an ordinary line break.
5156    ///
5157    /// A table row is a single source line, so the newline-spelled hard break
5158    /// can't live in a cell. twig spells the in-cell break the format's way
5159    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5160    /// break round-trips as structure the renderer reads back as a line — not the
5161    /// opaque raw HTML the old raw-splice left behind.
5162    ///
5163    /// Djot has no idiomatic in-cell break, so twig refuses it
5164    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5165    /// would render as the literal text `<br>`. The gesture is still *consumed*
5166    /// there — returning `false` would let the frontend insert a real newline,
5167    /// which splits the one-line row — it just leaves the cell unchanged and says
5168    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5169    ///
5170    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5171    /// have to be read together: djot is not the only `false`, and naming it in
5172    /// the message was already a guess that HTML — which spells the break as its
5173    /// own `<br>` — would have made wrong.
5174    pub fn cell_line_break(&mut self) -> bool {
5175        if !self.caret_in_table() {
5176            return false;
5177        }
5178        self.record_caret();
5179        match self.editor.insert_line_break(self.caret) {
5180            Ok(change) => {
5181                self.last_edit_kind = None;
5182                self.refresh();
5183                self.caret = change.new.end;
5184                self.anchor = None;
5185                self.goal_col = None;
5186                self.clamp_caret();
5187                self.dirty = self.source != self.clean_source;
5188                self.status = None;
5189                self.record_caret();
5190            }
5191            Err(twig::Error::UnsupportedFormat) => {
5192                self.status = Some(format!(
5193                    "in-cell line breaks aren't supported in {}",
5194                    self.format_name()
5195                ));
5196            }
5197            Err(_) => {}
5198        }
5199        true
5200    }
5201
5202    /// Rebuild the visual map at the width the last build used. A structural edit
5203    /// bumps the revision and swaps the source in, but leaves the *map* stale;
5204    /// when a single gesture edits and then moves over the result (Tab appending
5205    /// a row, then stepping into it), the move needs the map to already show the
5206    /// edit rather than waiting for the frontend's next frame.
5207    fn rebuild_map(&mut self) {
5208        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5209        self.build_map(wrap);
5210    }
5211
5212    /// Move the caret to the very start of the document (⌘↑ on macOS,
5213    /// Ctrl+Home on Windows/Linux).
5214    pub fn move_doc_start(&mut self, extend: bool) {
5215        self.goal_col = None;
5216        self.move_to(0, extend);
5217    }
5218
5219    /// Move the caret to the very end of the document (⌘↓ on macOS,
5220    /// Ctrl+End on Windows/Linux).
5221    pub fn move_doc_end(&mut self, extend: bool) {
5222        self.goal_col = None;
5223        let end = self.source.len();
5224        self.move_to(end, extend);
5225    }
5226
5227    /// Point the caret at the body cell `(row, col)` the mouse landed on —
5228    /// `col` being a cell of the terminal grid, which is what a display column
5229    /// is. A click on the far cell of a wide character lands at that
5230    /// character's start; the mapping's own doc-comments carry the rule.
5231    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5232        self.goal_col = None;
5233        let target = match self.view {
5234            View::Source => row_col_to_offset(&self.source, row, col),
5235            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5236        };
5237        let before = self.caret;
5238        self.move_to(target, extend);
5239        self.debug_assert_on_a_stop(before);
5240    }
5241
5242    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5243    /// screen if it has moved since the last frame, and never scroll past the
5244    /// last of `rows`.
5245    ///
5246    /// Only if it has *moved* — that's the whole point. Revealing the caret on
5247    /// every frame ties the viewport to it, and a scroll wheel that fights the
5248    /// caret for the viewport loses: the view snaps back the instant it tries to
5249    /// pass the caret's row, so the document can't be scrolled beyond what's
5250    /// already on screen. A caret move is the frontend's cue to follow; a scroll
5251    /// with the caret sitting still is the reader's cue to leave it alone.
5252    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5253        if self.drawn_caret != Some(self.caret) {
5254            if caret_row < self.scroll {
5255                self.scroll = caret_row;
5256            } else if height > 0 && caret_row >= self.scroll + height {
5257                self.scroll = caret_row + 1 - height;
5258            }
5259            self.drawn_caret = Some(self.caret);
5260        }
5261        self.scroll = self.scroll.min(rows.saturating_sub(1));
5262    }
5263
5264    /// The caret's screen position `(row, col)` in the active view's grid, with
5265    /// `col` a display column: the cell to draw the caret in, which on a line of
5266    /// `你好` or emoji is not the count of characters before it.
5267    pub fn caret_pos(&self) -> (usize, usize) {
5268        match self.view {
5269            View::Source => offset_to_row_col(&self.source, self.caret),
5270            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5271        }
5272    }
5273
5274    fn clamp_caret(&mut self) {
5275        if self.caret > self.source.len() {
5276            self.caret = self.source.len();
5277        }
5278        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5279        // any selection anchor) to the first rendered offset.
5280        let floor = self.caret_floor();
5281        if self.caret < floor {
5282            self.caret = floor;
5283        }
5284        if let Some(a) = self.anchor {
5285            if a < floor {
5286                self.anchor = Some(floor);
5287            }
5288        }
5289        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5290            self.caret -= 1;
5291        }
5292    }
5293}
5294
5295// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5296
5297// Left/right motion and backspace/delete step by *grapheme cluster*, not
5298// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5299// marks moves and deletes as the single character a user sees. Grapheme
5300// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5301
5302/// How an insert of `text` groups for undo: a single typed character folds into
5303/// the run of typing around it, while a newline or a multi-character insert is a
5304/// step of its own.
5305fn typed_edit_kind(text: &str) -> EditKind {
5306    if text.chars().take(2).count() == 1 && text != "\n" {
5307        EditKind::Insert
5308    } else {
5309        EditKind::Other
5310    }
5311}
5312
5313fn prev_boundary(s: &str, i: usize) -> usize {
5314    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5315    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5316}
5317
5318fn next_boundary(s: &str, i: usize) -> usize {
5319    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5320    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5321}
5322
5323// ── word boundaries ──────────────────────────────────────────────────────────
5324// The shared primitive behind word-wise motion, word deletion, and
5325// double-click-to-select-a-word. A "word" is a maximal run of one character
5326// class; whitespace and punctuation are their own classes, so motion skips
5327// cleanly between them the way native text fields do.
5328
5329#[derive(PartialEq, Eq, Clone, Copy)]
5330enum Class {
5331    Word,
5332    Space,
5333    Other,
5334}
5335
5336/// The source range of an inline node's own visible text — the part of it a
5337/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5338/// `None` for a node with no interior to empty (a `str`, a break).
5339///
5340/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5341/// one delimiter in from the span — the same place the renderer maps it to. A
5342/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5343/// against the source rather than trusted: a range guessed wrong here is text
5344/// deleted wrong.
5345fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5346    if let Some(span) = n.content_span.clone() {
5347        return Some(span);
5348    }
5349    match n.kind.as_str() {
5350        "verbatim" | "inline_math" => {
5351            let text = n.text.as_ref()?;
5352            let start = n.span.start + 1;
5353            let range = start..start + text.len();
5354            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
5355        }
5356        _ => None,
5357    }
5358}
5359
5360/// The `id` a node declares, or `None` for one that declares none — the
5361/// attribute djot writes for a `{#v1}` and mints for a heading.
5362///
5363/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
5364/// names nothing, so it reads as absent rather than as the empty string.
5365fn declared_id(n: &FlatNode) -> Option<&str> {
5366    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
5367}
5368
5369/// A heading's words reduced to the form a link fragment spells them in:
5370/// lowercase, runs of anything else collapsed to a single `-`, with none left
5371/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
5372///
5373/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
5374/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
5375/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
5376/// any other language is still a heading someone will link to. Underscores
5377/// survive for the same reason they do on the web: they are word characters
5378/// wherever identifiers are written.
5379fn slug(text: &str) -> String {
5380    let mut out = String::new();
5381    let mut pending = false;
5382    for c in text.chars() {
5383        if c.is_alphanumeric() || c == '_' {
5384            if pending && !out.is_empty() {
5385                out.push('-');
5386            }
5387            pending = false;
5388            out.extend(c.to_lowercase());
5389        } else {
5390            pending = true;
5391        }
5392    }
5393    out
5394}
5395
5396fn is_block_container(kind: &Kind) -> bool {
5397    matches!(
5398        kind,
5399        Kind::Doc
5400            | Kind::Section
5401            | Kind::BlockQuote
5402            | Kind::BulletList
5403            | Kind::OrderedList
5404            | Kind::TaskList
5405            | Kind::ListItem
5406            | Kind::TaskListItem
5407            // Every `container` — a directive in any of its three forms, or a
5408            // promoted HTML element. A *text* directive is really inline, so
5409            // claiming it here is a small overreach, and the deliberate one this
5410            // function's kind-only peer `is_inline_kind` documents: the pair is
5411            // consulted together, and answering "block container" for something
5412            // inline is what keeps an ancestor walk from stopping short of the
5413            // paragraph that actually holds it.
5414            | Kind::Container
5415    )
5416}
5417
5418/// The `[start, end)` byte range of the source line containing `off` (newline
5419/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
5420/// line between paragraphs).
5421fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
5422    let off = off.min(s.len());
5423    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
5424    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
5425    start..end
5426}
5427
5428/// How many leading bytes an outdent takes off `line`: a whole indent level
5429/// where the line has one, and whatever it has where it has less.
5430///
5431/// A leading tab counts as a level on its own. It's indentation some other
5432/// editor wrote, and one tab is one level everywhere it came from — measuring it
5433/// in spaces it doesn't contain would leave it untouchable.
5434fn outdent_width(line: &str, unit: usize) -> usize {
5435    if line.starts_with('\t') {
5436        return 1;
5437    }
5438    line.bytes()
5439        .take(unit)
5440        .take_while(|b| *b == b' ')
5441        .count()
5442}
5443
5444
5445/// A list marker found at the head of a line, together with everything before it
5446/// that a sibling line has to repeat.
5447///
5448/// The three offsets differ only inside a block quote, where `>   - b` opens with
5449/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
5450/// `line_start == marker_start`, and `text` is the plain `"  - "`.
5451#[derive(Clone, Debug)]
5452struct ListMarker {
5453    /// The line's first byte.
5454    line_start: usize,
5455    /// Where the marker proper begins, past any quote prefix. The offset to hand
5456    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
5457    marker_start: usize,
5458    /// `line_start` through the marker's trailing space — quote prefix, indent
5459    /// and bullet together, which is what the next item's line opens with.
5460    text: String,
5461}
5462
5463impl ListMarker {
5464    /// Where the item's content starts — one past the marker's trailing space.
5465    fn content_start(&self) -> usize {
5466        self.line_start + self.text.len()
5467    }
5468}
5469
5470
5471
5472fn classify(c: char) -> Class {
5473    if c == '_' || c.is_alphanumeric() {
5474        Class::Word
5475    } else if c.is_whitespace() {
5476        Class::Space
5477    } else {
5478        Class::Other
5479    }
5480}
5481
5482/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
5483/// skip any leading separators, then consume the following word run.
5484fn next_word(s: &str, i: usize) -> usize {
5485    let mut off = i;
5486    let mut in_word = false;
5487    for c in s[i..].chars() {
5488        if classify(c) == Class::Word {
5489            in_word = true;
5490        } else if in_word {
5491            break;
5492        }
5493        off += c.len_utf8();
5494    }
5495    off
5496}
5497
5498/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
5499/// skip separators walking left, then consume the preceding word run.
5500fn prev_word(s: &str, i: usize) -> usize {
5501    let mut off = i;
5502    let mut in_word = false;
5503    for c in s[..i].chars().rev() {
5504        if classify(c) == Class::Word {
5505            in_word = true;
5506        } else if in_word {
5507            break;
5508        }
5509        off -= c.len_utf8();
5510    }
5511    off
5512}
5513
5514/// The `[start, end)` run of same-class characters surrounding `off` — the
5515/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
5516/// the run ending there is used.
5517fn word_range_at(s: &str, off: usize) -> (usize, usize) {
5518    if s.is_empty() {
5519        return (0, 0);
5520    }
5521    let off = off.min(s.len());
5522    let reference = if off < s.len() {
5523        s[off..].chars().next()
5524    } else {
5525        s[..off].chars().next_back()
5526    };
5527    let Some(rc) = reference else {
5528        return (off, off);
5529    };
5530    let class = classify(rc);
5531
5532    let mut start = off;
5533    for c in s[..start].chars().rev() {
5534        if classify(c) == class {
5535            start -= c.len_utf8();
5536        } else {
5537            break;
5538        }
5539    }
5540    let mut end = off;
5541    for c in s[end..].chars() {
5542        if classify(c) == class {
5543            end += c.len_utf8();
5544        } else {
5545            break;
5546        }
5547    }
5548    (start, end)
5549}
5550
5551/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
5552/// the line's start — terminal cells, not characters, so the column names the
5553/// cell the caret is drawn in even on a line of `你好` or emoji.
5554fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
5555    let off = off.min(s.len());
5556    let mut row = 0;
5557    let mut line_start = 0;
5558    for (i, &b) in s.as_bytes().iter().enumerate() {
5559        if i >= off {
5560            break;
5561        }
5562        if b == b'\n' {
5563            row += 1;
5564            line_start = i + 1;
5565        }
5566    }
5567    (row, wysiwyg::text_width(&s[line_start..off]))
5568}
5569
5570/// The byte offset at display column `col` of `row` (clamped to that line's
5571/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
5572///
5573/// A column landing *inside* a character — the second cell of `你`, or any cell
5574/// but the first of an emoji — resolves to that character's start, which is the
5575/// column the caret would have been drawn at to begin with. So both cells of a
5576/// wide character mean the character, and every offset survives the round trip
5577/// out to a column and back. The walk steps by grapheme cluster for the same
5578/// reason the caret does: a cluster is the character, and the cells belong to it
5579/// rather than to the codepoints spelling it.
5580fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
5581    let start = line_start(s, row);
5582    let end = line_end_from(s, start);
5583    let mut off = start;
5584    let mut at = 0; // the display column `off` sits at
5585    while off < end {
5586        let next = next_boundary(s, off).min(end);
5587        let cells = wysiwyg::text_width(&s[off..next]);
5588        if at + cells > col {
5589            break; // `col` is one of this cluster's own cells
5590        }
5591        at += cells;
5592        off = next;
5593    }
5594    off
5595}
5596
5597fn line_start(s: &str, row: usize) -> usize {
5598    if row == 0 {
5599        return 0;
5600    }
5601    let mut r = 0;
5602    for (i, &b) in s.as_bytes().iter().enumerate() {
5603        if b == b'\n' {
5604            r += 1;
5605            if r == row {
5606                return i + 1;
5607            }
5608        }
5609    }
5610    s.len()
5611}
5612
5613fn line_end_from(s: &str, start: usize) -> usize {
5614    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
5615}
5616
5617#[cfg(test)]
5618mod tests {
5619    use super::*;
5620
5621    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
5622    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
5623    /// without one is a view no user is ever in.
5624    fn doc_in(view: View, name: &str, body: &str) -> Doc {
5625        // The fixture name doubles as the temp file's, so two tests picking the
5626        // same one raced under the parallel runner and read each other's body —
5627        // a green suite proving the wrong thing. The counter makes that
5628        // unreachable rather than asking every future caller to notice.
5629        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5630        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5631        let mut p = std::env::temp_dir();
5632        p.push(format!("leaf_test_{name}_{seq}.md"));
5633        std::fs::write(&p, body).unwrap();
5634        let mut d = Doc::open(p).unwrap();
5635        d.view = view;
5636        if view == View::Wysiwyg {
5637            d.build_visual(80);
5638        }
5639        d
5640    }
5641
5642    // Source-view document for the source-behaviour tests. `Doc::open` now
5643    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
5644    // `wysiwyg_doc` builds the rich-text variant on top of this.
5645    fn doc_with(name: &str, body: &str) -> Doc {
5646        doc_in(View::Source, name, body)
5647    }
5648
5649    /// Every visual row's drawn text — what the reader actually sees, which is
5650    /// the only thing the reveal preference is supposed to change.
5651    fn drawn_rows(d: &Doc) -> Vec<String> {
5652        d.vmap
5653            .rows
5654            .iter()
5655            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5656            .collect()
5657    }
5658
5659    /// Put the caret at the first byte of `needle` and rebuild, so the row under
5660    /// it becomes the revealed line.
5661    fn caret_at(d: &mut Doc, needle: &str) {
5662        d.caret = d.source.find(needle).expect("needle in source");
5663        d.build_visual(80);
5664    }
5665
5666    #[test]
5667    fn blockquote_after_a_list_is_not_bulleted() {
5668        // twig nests a following top-level block quote under the `bullet_list`
5669        // (a direct child, not a `list_item`). The map must render it de-nested —
5670        // `│ quote`, never `• │ quote` — with a blank separator, like any block
5671        // that follows a list. Regression for the "combined list + blockquote" bug.
5672        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
5673        d.build_visual(80);
5674        let rows: Vec<String> = d
5675            .vmap
5676            .rows
5677            .iter()
5678            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5679            .collect();
5680        assert!(
5681            rows.iter().any(|r| r == "│ quote"),
5682            "block quote should render on its own gutter, got rows: {rows:?}"
5683        );
5684        assert!(
5685            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
5686            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
5687        );
5688    }
5689
5690    // ── the map is built at most once per (revision, wrap) ───────────────────
5691    //
5692    // A frontend repaints for reasons that have nothing to do with the text — a
5693    // blinking caret, a scroll — and rebuilding the map is O(document). These
5694    // pin *that the cache fires*, which a passing suite can't tell you: a cache
5695    // that never hits is invisible to every other test in this file.
5696    //
5697    // The probe is to wreck the built map and ask for it again. A rebuild
5698    // repairs it; a cache hit hands the wreckage straight back. Nothing else
5699    // can distinguish the two from outside.
5700
5701    #[test]
5702    fn a_rebuild_with_nothing_changed_reuses_the_map() {
5703        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
5704        d.build_visual(80);
5705        assert!(!d.vmap.rows.is_empty());
5706        d.vmap.rows.clear(); // wreck it
5707        d.build_visual(80);
5708        assert!(
5709            d.vmap.rows.is_empty(),
5710            "the map was rebuilt though nothing changed — the cache never fired"
5711        );
5712    }
5713
5714    #[test]
5715    fn an_edit_rebuilds_the_map() {
5716        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
5717        d.build_visual(80);
5718        let before = d.revision();
5719        d.vmap.rows.clear();
5720        d.insert("x");
5721        d.build_visual(80);
5722        assert!(d.revision() > before, "an edit must move the revision");
5723        assert!(
5724            !d.vmap.rows.is_empty(),
5725            "an edited document must not paint from a stale map"
5726        );
5727    }
5728
5729    #[test]
5730    fn a_width_change_rebuilds_the_map() {
5731        // The map is a function of the wrap width too, so a resize is a miss
5732        // even though the text is untouched.
5733        let mut d = doc_in(View::Wysiwyg, "cache_width", "one two three four five six\n");
5734        d.build_visual(80);
5735        d.vmap.rows.clear();
5736        d.build_visual(12);
5737        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
5738        // And the unwrapped map is its own key, not the same as any width.
5739        d.vmap.rows.clear();
5740        d.build_visual_unwrapped();
5741        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
5742    }
5743
5744    #[test]
5745    fn a_motion_does_not_rebuild_the_map() {
5746        // The whole point: moving the caret changes nothing the map is built
5747        // from. If a motion bumped the revision, every arrow key would cost a
5748        // full rebuild and the cache would be worthless.
5749        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
5750        d.build_visual(80);
5751        let rev = d.revision();
5752        d.move_right(false);
5753        d.move_right(true);
5754        d.move_down(false);
5755        assert_eq!(d.revision(), rev, "a motion must not move the revision");
5756        d.vmap.rows.clear();
5757        d.build_visual(80);
5758        assert!(d.vmap.rows.is_empty(), "a motion should not rebuild the map");
5759    }
5760
5761    #[test]
5762    fn saving_does_not_rebuild_the_map() {
5763        // Saving changes `dirty`, not the text.
5764        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
5765        d.insert("x");
5766        d.build_visual(80);
5767        let rev = d.revision();
5768        d.save();
5769        assert_eq!(d.revision(), rev, "a save must not move the revision");
5770        assert!(!d.dirty, "the save should have cleaned the document");
5771    }
5772
5773    #[test]
5774    fn a_reload_rebuilds_the_map() {
5775        // Reload replaces the text without going through `refresh`, so it has to
5776        // move the revision itself — else the editor paints the old file.
5777        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
5778        d.build_visual(80);
5779        let rev = d.revision();
5780        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
5781        d.reload();
5782        assert!(d.revision() > rev, "a reload must move the revision");
5783        d.build_visual(80);
5784        let text: String = d
5785            .vmap
5786            .rows
5787            .iter()
5788            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5789            .collect();
5790        assert!(
5791            text.contains("wholly new"),
5792            "the reloaded text should be on screen, got {text:?}"
5793        );
5794    }
5795
5796    // ── golden-case harness ──────────────────────────────────────────────────
5797    // The pattern the whole parity suite can reuse: write a fixture with the
5798    // caret marked by `|`, run one action, and compare the rendered result —
5799    // also caret-marked — against the expected string. One readable line per
5800    // behavior, and it exercises the exact `Doc` ops both frontends call.
5801
5802    /// Split a `|`-marked fixture into `(source, caret_offset)`.
5803    fn parse_caret(marked: &str) -> (String, usize) {
5804        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
5805        (marked.replacen('|', "", 1), caret)
5806    }
5807
5808    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
5809    /// selection) so a result reads like the fixtures.
5810    fn render_caret(d: &Doc) -> String {
5811        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
5812        // so the caret always renders inside its own selection.
5813        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
5814        if let Some((s, e)) = d.selection() {
5815            marks.push((s, 0, '['));
5816            marks.push((e, 2, ']'));
5817        }
5818        // Insert right-to-left: descending offset, then descending rank.
5819        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
5820        let mut out = d.source.clone();
5821        for (at, _, ch) in marks {
5822            out.insert(at, ch);
5823        }
5824        out
5825    }
5826
5827    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
5828    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
5829        golden_in(View::Source, name, marked, action)
5830    }
5831
5832    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
5833    /// the same fixture has to read the same way in both.
5834    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
5835        let (src, caret) = parse_caret(marked);
5836        let mut d = doc_in(view, name, &src);
5837        d.caret = caret;
5838        action(&mut d);
5839        render_caret(&d)
5840    }
5841
5842    #[test]
5843    fn word_motion_walks_word_by_word() {
5844        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
5845        assert_eq!(g("hello wor|ld", |d| d.move_word_left(false)), "hello |world");
5846        assert_eq!(g("hello| world", |d| d.move_word_left(false)), "|hello world");
5847        assert_eq!(g("hel|lo world", |d| d.move_word_right(false)), "hello| world");
5848        assert_eq!(g("hello| world", |d| d.move_word_right(false)), "hello world|");
5849        // Punctuation is its own class, so motion stops at the boundary.
5850        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
5851    }
5852
5853    #[test]
5854    fn word_motion_extends_the_selection_when_asked() {
5855        assert_eq!(
5856            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
5857            "hello [world|]"
5858        );
5859    }
5860
5861    #[test]
5862    fn delete_word_removes_a_whole_word() {
5863        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
5864        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
5865        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
5866        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
5867    }
5868
5869    // ── Home / End ───────────────────────────────────────────────────────────
5870
5871    #[test]
5872    fn home_toggles_between_the_line_s_text_and_its_margin() {
5873        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
5874        // indent to the markup it spells everywhere it means one, so the fixture
5875        // with whitespace left to walk is a code block, which is verbatim.
5876        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
5877        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
5878        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
5879        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
5880        // A line with no indentation has one place to go, so the toggle is a
5881        // no-op rather than a trip to nowhere.
5882        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
5883        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
5884
5885        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
5886        let indent = d.source.find("    indented").unwrap();
5887        d.caret = indent + 6; // inside "indented"
5888        d.move_home(false);
5889        assert_eq!(d.caret, indent + 4, "wysiwyg: Home aims at the code line's text");
5890        d.move_home(false);
5891        assert_eq!(d.caret, indent, "wysiwyg: the second press takes the indent");
5892        d.move_home(false);
5893        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
5894    }
5895
5896    #[test]
5897    fn end_takes_the_line_the_view_is_showing() {
5898        // The line differs by view for the same document, and that is the point:
5899        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
5900        // as a space on one row and the source view as two lines.
5901        let mut d = doc_with("end_src", "one two\nthree\n");
5902        d.caret = 1;
5903        d.move_end(false);
5904        assert_eq!(d.caret, 7, "source: the end of the source line");
5905
5906        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
5907        d.caret = 1;
5908        d.move_end(false);
5909        assert_eq!(d.caret, 13, "wysiwyg: the end of the row, soft break and all");
5910    }
5911
5912    #[test]
5913    fn home_and_end_extend_the_selection_when_asked() {
5914        for (view, tag) in VIEWS {
5915            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
5916            d.caret = 6;
5917            d.move_end(true);
5918            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
5919            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
5920            d.caret = 6;
5921            d.move_home(true);
5922            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
5923        }
5924    }
5925
5926    // ── kill to the line's start / end ───────────────────────────────────────
5927
5928    #[test]
5929    fn kill_to_the_line_start_and_end_in_both_views() {
5930        for (view, tag) in VIEWS {
5931            // The gap that reads as a paragraph break in each view: the source
5932            // view's lines are the renderer's rows only where the source says so.
5933            let gap = if view == View::Source { "\n" } else { "\n\n" };
5934            let mut d = doc_in(view, &format!("kill_end_{tag}"), &format!("one two{gap}three\n"));
5935            d.caret = 3;
5936            d.delete_to_line_end();
5937            assert_eq!(d.source, format!("one{gap}three\n"), "{tag}: ^K to the line's end");
5938            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
5939
5940            let mut d = doc_in(view, &format!("kill_start_{tag}"), &format!("one two{gap}three\n"));
5941            d.caret = 7; // the end of the first line
5942            d.delete_to_line_start();
5943            assert_eq!(d.source, format!("{gap}three\n"), "{tag}: ⌘⌫ to the line's start");
5944            assert_eq!(d.caret, 0, "{tag}");
5945        }
5946    }
5947
5948    #[test]
5949    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
5950        // The decision: at the boundary both kills do nothing, rather than
5951        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
5952        // at a soft wrap as often as at a newline, where there is nothing
5953        // written to delete — and a source newline is only half of the blank
5954        // line between two paragraphs, so taking it leaves a soft break rather
5955        // than the join it looks like. Backspace and Delete are the keys for it.
5956        for (view, tag) in VIEWS {
5957            let gap = if view == View::Source { "\n" } else { "\n\n" };
5958            let src = format!("one{gap}three\n");
5959            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
5960            d.caret = 3; // the end of "one"
5961            d.delete_to_line_end();
5962            assert_eq!(d.source, src, "{tag}: ^K at the line's end joined it to the next");
5963
5964            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
5965            d.caret = 3 + gap.len(); // the start of "three"
5966            d.delete_to_line_start();
5967            assert_eq!(d.source, src, "{tag}: ⌘⌫ at the line's start joined it to the last");
5968        }
5969    }
5970
5971    #[test]
5972    fn a_kill_takes_the_selection_when_there_is_one() {
5973        // What every other delete here does with one, so these two as well.
5974        for (view, tag) in VIEWS {
5975            for (name, kill) in [
5976                ("end", (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc)),
5977                ("start", |d: &mut Doc| d.delete_to_line_start()),
5978            ] {
5979                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
5980                d.anchor = Some(4);
5981                d.caret = 7; // "two"
5982                kill(&mut d);
5983                assert_eq!(d.source, "one  three\n", "{tag}: {name} ignored the selection");
5984                assert_eq!(d.selection(), None, "{tag}: {name}");
5985            }
5986        }
5987    }
5988
5989    #[test]
5990    fn a_kill_takes_the_markup_it_empties_with_it() {
5991        // The same hazard a word-delete has: a WYSIWYG range covers what the
5992        // user can see, which for `**bold**` is the word and never the
5993        // delimiters, so a kill that stopped at the text would leave `a ****` —
5994        // markup wrapped around nothing.
5995        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
5996        d.caret = d.source.find("bold").unwrap();
5997        d.delete_to_line_end();
5998        assert_eq!(d.source, "a \n");
5999    }
6000
6001    #[test]
6002    fn a_kill_is_undone_in_one_step() {
6003        for (view, tag) in VIEWS {
6004            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6005            d.caret = 3;
6006            d.delete_to_line_end();
6007            assert_eq!(d.source, "one\n", "{tag}");
6008            d.undo();
6009            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6010        }
6011    }
6012
6013    #[test]
6014    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6015        // Regression: triple-click used move_home/move_end over visual rows, so
6016        // it only worked on a paragraph's first row (a wrap-boundary offset maps
6017        // to the earlier row). select_block_at reads the AST, so every offset in
6018        // the paragraph selects the whole thing.
6019        let body = "one two three four five six seven eight\n";
6020        let mut d = doc_with("sel_block", body);
6021        d.view = View::Wysiwyg;
6022        d.build_visual(12); // force the paragraph to wrap into several rows
6023        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6024        let para = (0, "one two three four five six seven eight".len());
6025        for off in [0usize, 8, 19, 28, 38] {
6026            d.caret = 0;
6027            d.anchor = None;
6028            d.select_block_at(off);
6029            assert_eq!(d.selection(), Some(para), "offset {off} should select the paragraph");
6030        }
6031    }
6032
6033    #[test]
6034    fn select_block_uses_content_span_for_a_heading() {
6035        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6036        d.select_block_at(4); // inside "Title"
6037        // content_span excludes the "# " marker.
6038        assert_eq!(d.selected_text(), Some("Title"));
6039        d.select_block_at(10); // inside "body"
6040        assert_eq!(d.selected_text(), Some("body"));
6041    }
6042
6043    #[test]
6044    fn select_all_spans_the_document() {
6045        let mut d = doc_with("sel_all", "abc\n\ndef\n");
6046        d.select_all();
6047        assert_eq!(d.selection(), Some((0, d.source.len())));
6048    }
6049
6050    #[test]
6051    fn select_word_at_picks_the_surrounding_word() {
6052        let mut d = doc_with("sel_word", "hello world\n");
6053        d.select_word_at(8); // inside "world"
6054        assert_eq!(d.selection(), Some((6, 11)));
6055        // Double-clicking at end-of-word still grabs the word to its left.
6056        d.select_word_at(5); // the space between the words
6057        assert_eq!(d.selection(), Some((5, 6)));
6058    }
6059
6060    #[test]
6061    fn word_helpers_respect_utf8_boundaries() {
6062        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6063        assert_eq!(golden("utf8", "|café ok", |d| d.move_word_right(false)), "café| ok");
6064        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6065    }
6066
6067    #[test]
6068    fn typing_inserts_at_the_caret_and_advances_it() {
6069        let mut d = doc_with("type", "hello\n");
6070        d.insert("Hi ");
6071        assert_eq!(d.source, "Hi hello\n");
6072        assert_eq!(d.caret, 3);
6073        assert!(d.dirty);
6074    }
6075
6076    #[test]
6077    fn backspace_deletes_the_char_before_the_caret() {
6078        let mut d = doc_with("bs", "hello\n");
6079        d.caret = 3; // after "hel"
6080        d.backspace();
6081        assert_eq!(d.source, "helo\n");
6082        assert_eq!(d.caret, 2);
6083    }
6084
6085    #[test]
6086    fn typing_replaces_the_selection() {
6087        let mut d = doc_with("replace", "a word b\n");
6088        d.anchor = Some(2);
6089        d.caret = 6; // "word" selected
6090        d.insert("X");
6091        assert_eq!(d.source, "a X b\n");
6092        assert_eq!(d.caret, 3);
6093        assert_eq!(d.anchor, None);
6094    }
6095
6096    #[test]
6097    fn toggle_bold_wraps_then_unwraps_the_selection() {
6098        let mut d = doc_with("bold", "a word b\n");
6099        d.anchor = Some(2);
6100        d.caret = 6;
6101        d.toggle(InlineKind::Strong);
6102        assert_eq!(d.source, "a **word** b\n");
6103        // The toggled region stays selected, so a second toggle reverses it.
6104        d.toggle(InlineKind::Strong);
6105        assert_eq!(d.source, "a word b\n");
6106    }
6107
6108    #[test]
6109    fn toggle_code_wraps_then_unwraps_the_selection() {
6110        let mut d = doc_with("code_rt", "a word b\n");
6111        d.anchor = Some(2);
6112        d.caret = 6;
6113        d.toggle(InlineKind::Verbatim);
6114        assert_eq!(d.source, "a `word` b\n");
6115        d.toggle(InlineKind::Verbatim);
6116        assert_eq!(d.source, "a word b\n");
6117    }
6118
6119    #[test]
6120    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6121        // ⌘b at a bare caret, then type: the text comes out bold with no
6122        // selection ever made — the word-processor "start bold here" gesture.
6123        let mut d = doc_with("sticky_wrap", "xy\n");
6124        d.caret = 1; // between x and y
6125        d.toggle(InlineKind::Strong);
6126        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6127        d.insert("A");
6128        assert_eq!(d.source, "x**A**y\n");
6129    }
6130
6131    #[test]
6132    fn sticky_bold_lights_the_toolbar_before_any_typing() {
6133        // The button must light the instant ⌘b is pressed, or the mode is
6134        // invisible until the first character lands.
6135        let mut d = doc_with("sticky_light", "xy\n");
6136        d.caret = 1;
6137        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6138        d.toggle(InlineKind::Strong);
6139        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6140    }
6141
6142    #[test]
6143    fn sticky_bold_toggled_off_types_normally_again() {
6144        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6145        // in the flow of typing, the exact sequence the user described.
6146        let mut d = doc_with("sticky_off", "\n");
6147        d.caret = 0;
6148        d.toggle(InlineKind::Strong);
6149        d.insert("a");
6150        d.insert("b"); // continues inside the run, no re-arming
6151        assert_eq!(d.source, "**ab**\n");
6152        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6153        d.insert("c");
6154        assert_eq!(d.source, "**ab**c\n");
6155    }
6156
6157    #[test]
6158    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6159        // Once a mark is realised the caret sits inside the run, so plain typing
6160        // extends it rather than starting a second, adjacent bold span.
6161        let mut d = doc_with("sticky_cont", "\n");
6162        d.caret = 0;
6163        d.toggle(InlineKind::Emph);
6164        d.insert("h");
6165        d.insert("i");
6166        assert_eq!(d.source, "*hi*\n");
6167    }
6168
6169    #[test]
6170    fn moving_the_caret_disarms_a_sticky_mark() {
6171        // Arming a mark and then moving away must not style text elsewhere.
6172        let mut d = doc_with("sticky_disarm", "xy\n");
6173        d.caret = 0;
6174        d.toggle(InlineKind::Strong);
6175        d.move_right(false); // caret 0 → 1, disarms
6176        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6177        d.insert("A");
6178        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6179    }
6180
6181    #[test]
6182    fn stacked_sticky_marks_apply_together() {
6183        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6184        let mut d = doc_with("sticky_stack", "\n");
6185        d.caret = 0;
6186        d.toggle(InlineKind::Strong);
6187        d.toggle(InlineKind::Emph);
6188        d.insert("x");
6189        // Land the caret on the styled character and confirm both marks are live.
6190        d.anchor = Some(d.source.find('x').unwrap());
6191        d.caret = d.anchor.unwrap() + 1;
6192        let marks = d.active_inline_marks();
6193        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6194        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6195    }
6196
6197    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6198
6199    #[test]
6200    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6201        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6202        // The space inside the run made `**bold **`, which is *not* bold — four
6203        // literal asterisks — so the rich view drew them, correctly and
6204        // uselessly, until the next character happened to close the run again.
6205        let mut d = wysiwyg_doc("edge_typing", "a \n");
6206        d.caret = 2;
6207        d.toggle(InlineKind::Strong);
6208        for c in "bold".chars() {
6209            d.insert(&c.to_string());
6210        }
6211        assert_eq!(d.source, "a **bold**\n");
6212        d.insert(" ");
6213        assert_eq!(d.source, "a **bold** \n", "the space belongs outside the run");
6214        assert!(
6215            d.active_inline_marks().contains(InlineKind::Strong),
6216            "bold is still what's being typed, so the button stays lit"
6217        );
6218        // What the writer is looking at while all this happens: their words.
6219        d.build_visual(80);
6220        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6221        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6222        for c in "hey".chars() {
6223            d.insert(&c.to_string());
6224        }
6225        assert_eq!(d.source, "a **bold hey**\n", "one bold phrase, not two runs");
6226    }
6227
6228    #[test]
6229    fn typing_past_a_space_can_still_leave_the_bold_behind() {
6230        // The other half: the marks stay armed across the space, so ⌘b turns
6231        // them off again there and the next word is plain — the run isn't
6232        // rejoined by a caret that was told not to.
6233        let mut d = wysiwyg_doc("edge_shed", "\n");
6234        d.caret = 0;
6235        d.toggle(InlineKind::Strong);
6236        for c in "bold ".chars() {
6237            d.insert(&c.to_string());
6238        }
6239        assert_eq!(d.source, "**bold** \n");
6240        d.toggle(InlineKind::Strong);
6241        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6242        d.insert("x");
6243        assert_eq!(d.source, "**bold** x\n");
6244    }
6245
6246    #[test]
6247    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
6248        // ⌘b and then a space before any word: the space is not marked (nothing
6249        // is), and the word after it is.
6250        let mut d = wysiwyg_doc("edge_space_first", "a\n");
6251        d.caret = 1;
6252        d.toggle(InlineKind::Strong);
6253        d.insert(" ");
6254        assert_eq!(d.source, "a \n");
6255        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6256        d.insert("b");
6257        assert_eq!(d.source, "a **b**\n");
6258    }
6259
6260    #[test]
6261    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
6262        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
6263        d.caret = 8; // the caret's home at the end of the run's text
6264        d.insert(" ");
6265        assert_eq!(d.source, "x **bold** \n", "the space lands past the delimiters");
6266        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
6267
6268        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
6269        d.caret = 4; // in front of the "b"
6270        d.insert(" ");
6271        assert_eq!(d.source, "x  **bold** y\n");
6272        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
6273    }
6274
6275    #[test]
6276    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
6277        // Backspace over the last letter of a bold phrase.
6278        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
6279        d.caret = 10; // past the "h"
6280        d.backspace();
6281        assert_eq!(d.source, "a **bold** \n");
6282        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
6283        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6284        d.insert("x");
6285        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
6286    }
6287
6288    #[test]
6289    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
6290        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
6291        // mark, which is only text. The marks live on in the caret instead.
6292        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
6293        d.caret = 5;
6294        d.backspace();
6295        assert_eq!(d.source, "a  c\n");
6296        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6297        d.insert("x");
6298        assert_eq!(d.source, "a **x** c\n");
6299    }
6300
6301    #[test]
6302    fn typing_over_a_whole_bold_word_keeps_it_bold() {
6303        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
6304        d.anchor = Some(4);
6305        d.caret = 8; // the word, not its delimiters
6306        d.insert("x");
6307        assert_eq!(d.source, "a **x** c\n");
6308    }
6309
6310    #[test]
6311    fn a_code_span_keeps_the_space_it_is_given() {
6312        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
6313        // is still verbatim, so nothing is re-spelt. The repair asks the parser
6314        // rather than a table of kinds, and this is the answer it gets.
6315        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
6316        d.caret = 7;
6317        d.insert(" ");
6318        assert_eq!(d.source, "a `code ` c\n");
6319    }
6320
6321    #[test]
6322    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
6323        // A run's closing delimiter has a caret home on each side of it, one
6324        // column apart on screen — and a plain ← off the space after a bold word
6325        // lands on the outer one. The character drawn behind the caret there is
6326        // still the last letter of the phrase, so that is what Backspace takes;
6327        // the byte behind it is a `*` nobody can see.
6328        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
6329        d.caret = 9;
6330        d.move_left(false);
6331        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
6332        d.backspace();
6333        assert_eq!(d.source, "**bol** x\n", "a letter of the phrase, not its `*`");
6334        assert_eq!(d.caret, 5);
6335
6336        // And the mirror in front of the opening delimiter, where Delete's
6337        // character is the first letter of the run.
6338        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
6339        d.caret = 1;
6340        d.delete_forward();
6341        assert_eq!(d.source, "x**old**\n");
6342        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
6343    }
6344
6345    #[test]
6346    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
6347        // The byte beside the caret at either edge of a bold word is a `*` the
6348        // rich view draws nothing for. Taking it is not the character delete the
6349        // key was pressed for — it unspells the run and puts a literal asterisk
6350        // on screen (`a *bold** c`). The visible character is the one that goes.
6351        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
6352        d.caret = 4; // in front of the "b"
6353        d.backspace();
6354        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
6355
6356        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
6357        d.caret = 8; // past the "d"
6358        d.delete_forward();
6359        assert_eq!(d.source, "a **bold**c\n");
6360        assert_eq!(d.caret, 8, "and the caret stays inside the run");
6361        d.insert("x");
6362        assert_eq!(d.source, "a **boldx**c\n");
6363
6364        // A code span's backticks are hidden the same way, so they are covered
6365        // by the same rule and not by a list of kinds.
6366        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
6367        d.caret = 3;
6368        d.backspace();
6369        assert_eq!(d.source, "a`code` c\n");
6370    }
6371
6372    #[test]
6373    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
6374        // The asterisks are on the screen there and the caret can stand between
6375        // them, so a delete takes exactly the byte it is aimed at.
6376        let mut d = doc_with("edge_open_src", "a **bold** c\n");
6377        d.caret = 4;
6378        d.backspace();
6379        assert_eq!(d.source, "a *bold** c\n");
6380
6381        let mut d = doc_with("edge_close_src", "a **bold** c\n");
6382        d.caret = 8;
6383        d.delete_forward();
6384        assert_eq!(d.source, "a **bold* c\n");
6385    }
6386
6387    #[test]
6388    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
6389        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
6390        // The space had stepped outside the run (the mark-edge rule), taking the
6391        // caret with it, so the delete put it back down on the far side of the
6392        // closing `**` — one place on screen, and the wrong side of it. Typing
6393        // came out plain and the toolbar went dark, with nothing to see.
6394        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
6395        d.caret = 0;
6396        d.toggle(InlineKind::Strong);
6397        for c in "bold".chars() {
6398            d.insert(&c.to_string());
6399        }
6400        d.insert(" ");
6401        assert_eq!(d.source, "**bold** \n");
6402        d.backspace();
6403        assert_eq!(d.source, "**bold**\n", "the space goes, the delimiters stay");
6404        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
6405        assert!(
6406            d.active_inline_marks().contains(InlineKind::Strong),
6407            "so the button is still lit"
6408        );
6409        d.insert("x");
6410        assert_eq!(d.source, "**boldx**\n", "and the next character is still bold");
6411    }
6412
6413    #[test]
6414    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
6415        // What the stranded caret did next: the byte behind it was the closing
6416        // `*`, so a second press took that instead of a letter — `**bold*`, the
6417        // styling gone and an asterisk on the screen where the word had been.
6418        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
6419        d.caret = 0;
6420        d.toggle(InlineKind::Strong);
6421        for c in "bold ".chars() {
6422            d.insert(&c.to_string());
6423        }
6424        assert_eq!(d.source, "**bold** \n");
6425        d.backspace();
6426        d.backspace();
6427        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
6428        assert_eq!(d.caret, 5);
6429    }
6430
6431    #[test]
6432    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
6433        // `***both***` closes two runs with one stack of asterisks: the caret has
6434        // to walk in through all of them, or it lands between the emph and the
6435        // strong and types half-marked.
6436        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
6437        d.caret = 11;
6438        d.backspace();
6439        assert_eq!(d.source, "***both***\n");
6440        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
6441        d.insert("x");
6442        assert_eq!(d.source, "***bothx***\n");
6443    }
6444
6445    #[test]
6446    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
6447        // The settle only moves a caret a run actually closed over. Ordinary
6448        // deletes — inside a run, or in plain prose — are untouched.
6449        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
6450        d.caret = 8;
6451        d.backspace();
6452        assert_eq!(d.source, "a **bol** c\n");
6453        assert_eq!(d.caret, 7);
6454
6455        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
6456        d.caret = 5;
6457        d.backspace();
6458        assert_eq!(d.source, "plai\n");
6459        assert_eq!(d.caret, 4);
6460    }
6461
6462    #[test]
6463    fn the_source_view_leaves_a_delete_where_it_landed() {
6464        // The delimiters are on the screen there, so the offset past them is a
6465        // place the caret can be seen to be — nothing to settle.
6466        let mut d = doc_with("edge_bksp_src", "**bold** \n");
6467        d.caret = 9;
6468        d.backspace();
6469        assert_eq!(d.source, "**bold**\n");
6470        assert_eq!(d.caret, 8);
6471    }
6472
6473    #[test]
6474    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
6475        // `***both***` closes two runs with one stack of asterisks; a space that
6476        // clears only the inner one lands against the outer's and breaks that
6477        // instead.
6478        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
6479        d.caret = 9;
6480        d.insert(" ");
6481        assert_eq!(d.source, "a ***both*** \n");
6482        assert_eq!(d.caret, 13);
6483        d.insert("x");
6484        assert_eq!(d.source, "a ***both x***\n");
6485    }
6486
6487    #[test]
6488    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
6489        // The delimiter shuffle is not an edit the writer made, so it is not a
6490        // step they have to undo past.
6491        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
6492        d.caret = 8;
6493        d.insert(" ");
6494        assert_eq!(d.source, "a **bold** \n");
6495        d.undo();
6496        assert_eq!(d.source, "a **bold**\n");
6497    }
6498
6499    #[test]
6500    fn the_source_view_types_the_space_where_it_was_asked_to() {
6501        // The rule is a rich-view courtesy. In the source view the delimiters are
6502        // on the screen and the user is editing the bytes they can see.
6503        let mut d = doc_with("edge_src", "a **bold** c\n");
6504        d.caret = 8;
6505        d.insert(" ");
6506        assert_eq!(d.source, "a **bold ** c\n");
6507    }
6508
6509    #[test]
6510    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
6511        // Double-clicking a word takes the space after it; bolding that must not
6512        // spell `**word **`, which is not bold at all.
6513        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
6514        d.anchor = Some(2);
6515        d.caret = 7; // "word "
6516        d.toggle(InlineKind::Strong);
6517        assert_eq!(d.source, "a **word** b\n");
6518        // And a selection of nothing but whitespace has no word to mark.
6519        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
6520        d.anchor = Some(6);
6521        d.caret = 7;
6522        d.toggle(InlineKind::Strong);
6523        assert_eq!(d.source, "a word b\n");
6524        assert!(d.status.is_some());
6525    }
6526
6527    #[test]
6528    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
6529        let mut d = doc_with("head_set", "hello\n");
6530        d.caret = 2; // caret inside the paragraph, no selection
6531        d.set_block(BlockKind::Heading(1));
6532        assert_eq!(d.source, "# hello\n");
6533    }
6534
6535    #[test]
6536    fn set_block_heading_works_in_wysiwyg_view() {
6537        // The app defaults to WYSIWYG; the caret is a source offset either way.
6538        let mut d = wysiwyg_doc("head_wys", "hello\n");
6539        d.caret = 2;
6540        d.set_block(BlockKind::Heading(1));
6541        assert_eq!(d.source, "# hello\n");
6542    }
6543
6544    #[test]
6545    fn toggle_heading_applies_switches_and_reverts() {
6546        let mut d = doc_with("head_toggle", "hello\n");
6547        d.caret = 2;
6548        d.toggle_heading(1);
6549        assert_eq!(d.source, "# hello\n"); // paragraph → H1
6550        d.toggle_heading(2);
6551        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
6552        d.toggle_heading(2);
6553        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
6554    }
6555
6556    #[test]
6557    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
6558        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
6559        // the blank line but the caret rendered on the *next* line, because the
6560        // separator was a non-navigable decoration row. In Preserve flow that
6561        // blank line is a real caret home — the caret must resolve onto it, and
6562        // typing there makes the soft break that continues the paragraph.
6563        let src = "line one:\nsecond line\n";
6564        let mut d = wysiwyg_doc("pre_enter_lineend", src);
6565        d.set_line_flow(LineFlow::Preserve);
6566        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
6567        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
6568        d.newline();
6569        d.build_visual_unwrapped();
6570        assert_eq!(d.source, "line one:\n\nsecond line\n");
6571        assert_eq!(d.caret, 10, "caret sits on the new blank line, not the next line");
6572        // The blank line is row 1, and the caret resolves onto it — not row 2.
6573        assert_eq!(d.vmap.pos_of_offset(10), (1, 0), "caret renders on the blank row");
6574        assert!(!d.vmap.rows[1].decoration, "the blank line is navigable in Preserve");
6575        // Typing there makes a soft break: one paragraph, three lines.
6576        d.insert("new clause,");
6577        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
6578    }
6579
6580    #[test]
6581    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
6582        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
6583        // that keeps it one paragraph — where Fold would open a second paragraph.
6584        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
6585        d.set_line_flow(LineFlow::Preserve);
6586        d.caret = 3;
6587        d.newline();
6588        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
6589
6590        // End-of-paragraph: Enter then typing continues the same paragraph on a
6591        // new line (a soft break), not a fresh paragraph.
6592        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
6593        d.set_line_flow(LineFlow::Preserve);
6594        d.caret = 3;
6595        d.newline();
6596        d.insert("def");
6597        assert_eq!(d.source, "abc\ndef\n", "end-of-line Enter + typing is a soft break");
6598    }
6599
6600    #[test]
6601    fn preserve_double_enter_still_makes_a_paragraph() {
6602        // Two Enters in a row promote to a real paragraph break: the second lands
6603        // on the blank line the first opened and takes the empty-line branch.
6604        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
6605        d.set_line_flow(LineFlow::Preserve);
6606        d.caret = 3;
6607        d.newline();
6608        d.newline();
6609        d.insert("def");
6610        assert_eq!(d.source, "abc\n\ndef\n", "double Enter is a paragraph break");
6611    }
6612
6613    #[test]
6614    fn preserve_backspace_joins_across_a_soft_break() {
6615        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
6616        // soft break it deletes the single newline and joins the two lines.
6617        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
6618        d.set_line_flow(LineFlow::Preserve);
6619        d.build_visual(80);
6620        d.caret = 4; // start of "def", just past the soft break
6621        d.backspace();
6622        assert_eq!(d.source, "abcdef\n", "Backspace joins across the soft break");
6623        assert_eq!(d.caret, 3, "caret lands where the lines meet");
6624    }
6625
6626    #[test]
6627    fn fold_enter_still_starts_a_new_paragraph() {
6628        // The default flow is unchanged: a lone `\n` would render as an invisible
6629        // space, so Enter keeps opening the paragraph break that actually shows.
6630        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
6631        d.caret = 3;
6632        d.newline();
6633        assert_eq!(d.source, "abc\n\ndef\n", "Fold mid-line Enter is a paragraph break");
6634    }
6635
6636    #[test]
6637    fn wysiwyg_one_enter_starts_a_new_paragraph() {
6638        // Regression: one Enter left the caret between the two newlines, so typing
6639        // made a soft break (one paragraph) and you needed a second Enter.
6640        let mut d = wysiwyg_doc("wys_enter", "abc\n");
6641        d.caret = 3;
6642        d.newline();
6643        d.insert("def");
6644        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
6645    }
6646
6647    #[test]
6648    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
6649        // Regression: Enter at the caret's natural End-of-line resting place
6650        // after a bold run with nothing following it (on screen: right after
6651        // "bold", before the hidden closing "**") spliced the paragraph break
6652        // at that very byte offset — which sits *before* the closing "**" in
6653        // the source, since the delimiter is hidden and emits no glyph of its
6654        // own for `push_row`'s "end of row" fallback to count. That severed the
6655        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
6656        // "**" alone on the new line instead of leaving "**bold**" intact with
6657        // a fresh empty paragraph after it.
6658        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
6659        d.move_end(false); // the WYSIWYG End key, from caret 0
6660        assert_eq!(d.caret, 6, "caret rests right after \"bold\", before the hidden \"**\"");
6661        d.newline();
6662        assert!(
6663            d.source.starts_with("**bold**"),
6664            "the closing ** must stay attached to \"bold\": got {:?}",
6665            d.source
6666        );
6667        assert_eq!(
6668            d.source, "**bold**\n\n\n",
6669            "a fresh empty paragraph follows the still-intact bold run"
6670        );
6671    }
6672
6673    #[test]
6674    fn source_view_enter_is_a_single_newline() {
6675        let mut d = doc_with("src_enter", "abc\n");
6676        d.caret = 3;
6677        d.newline();
6678        assert_eq!(d.source, "abc\n\n");
6679    }
6680
6681    #[test]
6682    fn heading_applies_at_the_end_of_a_paragraph() {
6683        // The caret at a line end sits at the doc level; set_block must still find
6684        // the block on that line.
6685        let mut d = doc_with("head_end", "abc\n");
6686        d.caret = 3; // end of "abc"
6687        d.toggle_heading(1);
6688        assert_eq!(d.source, "# abc\n");
6689    }
6690
6691    #[test]
6692    fn heading_on_an_empty_new_paragraph_creates_one() {
6693        let mut d = wysiwyg_doc("head_empty", "abc\n");
6694        d.caret = 3;
6695        d.newline(); // caret now on a fresh, empty paragraph
6696        d.toggle_heading(1);
6697        d.insert("Title");
6698        assert!(d.source.contains("# Title"), "got {:?}", d.source);
6699    }
6700
6701    #[test]
6702    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
6703        // The reported bug, end to end: click a blank line with another one under
6704        // it, press H1, type. The text landed in the heading and the caret's
6705        // offset was right (the source view drew it there), but the rich view
6706        // drew it two rows lower, on the trailing blank line — the empty `# `
6707        // heading had left every row below it short by the marker's two bytes,
6708        // and the blank line ended up claiming the heading's own end offset.
6709        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
6710        d.build_visual_unwrapped();
6711        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
6712        d.toggle_heading(1);
6713        for c in "title".chars() {
6714            d.insert(&c.to_string());
6715            d.build_visual_unwrapped(); // as a frontend does, one frame per key
6716        }
6717        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
6718        assert_eq!(d.caret_pos(), (4, 5), "the caret draws at the end of the heading");
6719    }
6720
6721    #[test]
6722    fn clicking_an_empty_heading_types_after_its_marker() {
6723        // The same anchor from the other side: the empty heading's row is its own
6724        // caret home, so a click on it must land past the hidden `# `. Landing in
6725        // front of the hashes made the first keystroke un-heading the line.
6726        let mut d = wysiwyg_doc("head_click", "# \n");
6727        d.build_visual_unwrapped();
6728        d.caret = d.vmap.offset_of_pos(0, 0);
6729        d.insert("x");
6730        assert_eq!(d.source, "# x\n");
6731    }
6732
6733    #[test]
6734    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
6735        let mut d = wysiwyg_doc("head_enter", "# Title\n");
6736        d.caret = 7; // end of the heading
6737        d.newline();
6738        d.insert("body");
6739        assert_eq!(d.source, "# Title\n\nbody\n");
6740    }
6741
6742    #[test]
6743    fn wysiwyg_enter_continues_a_bullet_list() {
6744        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
6745        d.caret = 6; // end of "item"
6746        d.newline();
6747        d.insert("two");
6748        assert_eq!(d.source, "- item\n- two\n");
6749    }
6750
6751    #[test]
6752    fn wysiwyg_enter_increments_an_ordered_list() {
6753        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
6754        d.caret = 6; // end of "one"
6755        d.newline();
6756        d.insert("two");
6757        assert_eq!(d.source, "1. one\n2. two\n");
6758    }
6759
6760    #[test]
6761    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
6762        // Regression for the "extra newline" left between a list and the paragraph
6763        // below it. Enter, Enter leaves the list on a fresh empty paragraph
6764        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
6765        // Backspace should then take the caret cleanly back to the end of the list
6766        // item, `- item\n\nnext`, not delete a single newline and strand it on the
6767        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
6768        // no caret can land on. The map is rebuilt between keystrokes exactly as a
6769        // frontend does, since Backspace reads the stop table to place the delete.
6770        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
6771        d.caret = 6; // end of "item"
6772        d.newline();
6773        d.build_visual(80);
6774        d.newline(); // leave the list onto a fresh empty paragraph
6775        d.build_visual(80);
6776        assert_eq!(d.source, "- item\n\n\n\nnext\n", "double-Enter opens the empty paragraph");
6777        d.backspace();
6778        assert_eq!(d.source, "- item\n\nnext\n", "one Backspace collapses the whole gap");
6779        assert_eq!(d.caret, 6, "and lands the caret back at the end of the list item");
6780    }
6781
6782    #[test]
6783    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
6784        // The stop-wise delete must not over-reach when there is no block boundary
6785        // to cross: two blank lines in a row are one caret stop apart, so pressing
6786        // Enter on an empty line and then Backspace removes exactly the one newline
6787        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
6788        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
6789        d.caret = 5; // the empty paragraph the first Enter already opened
6790        d.build_visual(80);
6791        d.newline();
6792        d.build_visual(80);
6793        assert_eq!(d.source, "abc\n\n\n\n", "Enter on the blank line adds one newline");
6794        d.backspace();
6795        assert_eq!(d.source, "abc\n\n\n", "Backspace takes back exactly that one newline");
6796    }
6797
6798    #[test]
6799    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
6800        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
6801        d.caret = 6; // end of the empty "- " item
6802        d.newline();
6803        d.insert("p");
6804        assert_eq!(d.source, "- a\n\np\n");
6805    }
6806
6807    #[test]
6808    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
6809        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
6810        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
6811        // not take the list-exit path (which would splice the `- ` away as if
6812        // leaving an empty item); the AST guard sends it to a normal break and
6813        // leaves the underline intact.
6814        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
6815        assert!(
6816            d.nodes().iter().any(|n| n.kind == Kind::Heading),
6817            "precondition: twig parses this as a heading, not a list",
6818        );
6819        d.caret = 7; // on the `- ` underline line
6820        d.newline();
6821        assert!(
6822            d.source.contains("- "),
6823            "the setext underline survives, not spliced away as a list item: {:?}",
6824            d.source,
6825        );
6826    }
6827
6828    #[test]
6829    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
6830        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
6831        d.caret = 7; // end of "abc" inside the fence
6832        d.newline();
6833        d.insert("def");
6834        assert_eq!(d.source, "```\nabc\ndef\n```\n");
6835    }
6836
6837    #[test]
6838    fn wysiwyg_enter_continues_a_block_quote() {
6839        // Enter opens a new *paragraph* inside the quote, not a second line of
6840        // the same one. `> quote\n> more` is a soft break, which under
6841        // `LineFlow::Fold` renders as a space — the keystroke would look like it
6842        // did nothing. The quoted blank line is what makes the break visible, and
6843        // it's the same thing Enter does in running prose.
6844        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
6845        d.caret = 7; // end of "quote"
6846        d.newline();
6847        d.insert("more");
6848        assert_eq!(d.source, "> quote\n>\n> more\n");
6849        // Still one quote, now holding two paragraphs — not a quote and a stray
6850        // line that fell out of it.
6851        let quotes = d
6852            .nodes()
6853            .iter()
6854            .filter(|n| n.kind == Kind::BlockQuote)
6855            .count();
6856        assert_eq!(quotes, 1);
6857    }
6858
6859    #[test]
6860    fn set_block_makes_a_heading_at_the_caret() {
6861        let mut d = doc_with("head", "Title\n\nbody\n");
6862        d.caret = 0;
6863        d.set_block(BlockKind::Heading(2));
6864        assert_eq!(d.source, "## Title\n\nbody\n");
6865        d.set_block(BlockKind::Paragraph);
6866        assert_eq!(d.source, "Title\n\nbody\n");
6867    }
6868
6869    // ── block containers (quote / list) ──────────────────────────────────────
6870
6871    #[test]
6872    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
6873        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
6874        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
6875        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
6876        // A caret at a line end sits at the doc level; the block is still found.
6877        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
6878    }
6879
6880    #[test]
6881    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
6882        // Every source line of the paragraph gets its own `> `, so a caret left
6883        // on its old byte offset falls one prefix per line above it too far
6884        // back — inside the markup it just asked for rather than in its word.
6885        assert_eq!(
6886            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
6887            "> aaa\n> b|bb\n> ccc\n"
6888        );
6889    }
6890
6891    #[test]
6892    fn toggle_blockquote_works_in_wysiwyg_view() {
6893        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
6894        assert_eq!(g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
6895        assert_eq!(g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
6896    }
6897
6898    #[test]
6899    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
6900        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
6901        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
6902        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
6903        // The *other* kind converts in place instead of nesting, which is what
6904        // makes the two buttons one three-state control.
6905        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
6906        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
6907        // Its own kind, over the only item the list holds, takes it off.
6908        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
6909    }
6910
6911    #[test]
6912    fn toggle_list_works_in_wysiwyg_view() {
6913        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
6914        assert_eq!(g("l_wys", "hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
6915        assert_eq!(g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
6916        assert_eq!(g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
6917    }
6918
6919    #[test]
6920    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
6921        // The selection has to grow with the markup: twig takes a container off
6922        // only a range covering every block it holds, so the second press can
6923        // reverse the first only if the result is what's selected.
6924        let mut d = doc_with("list_sel", "abc\n\ndef\n");
6925        d.select_all();
6926        d.toggle_list(true);
6927        assert_eq!(d.source, "1. abc\n\n2. def\n");
6928        assert_eq!(d.selection(), Some((0, d.source.len())));
6929        d.toggle_list(true);
6930        assert_eq!(d.source, "abc\n\ndef\n");
6931    }
6932
6933    #[test]
6934    fn toggle_blockquote_nests_a_partly_covered_quote() {
6935        // twig's rule: covering only some of a container's blocks nests, because
6936        // taking the quote off would drag its uncovered siblings out with it.
6937        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
6938        d.caret = 2; // in the first quoted paragraph only
6939        d.toggle_blockquote();
6940        assert_eq!(d.source, "> > a\n>\n> b\n");
6941    }
6942
6943    #[test]
6944    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
6945        // A blank line used to be no block for twig to wrap —
6946        // `toggle_block_container` answered `NotFound` — so Quote and the list
6947        // buttons did nothing on the very line the H1 button works on, and leaf
6948        // lent twig a scratch paragraph to wrap and took it back out again.
6949        // twig 3.2.0 opens an empty container there itself, so what is left here
6950        // is where the caret lands: inside the marker that was just written.
6951        let mut d = doc_with("quote_blank", "\nabc\n");
6952        d.caret = 0;
6953        d.toggle_blockquote();
6954        assert_eq!(d.source, "> \nabc\n");
6955        assert_eq!(d.caret, 2, "the caret belongs inside the quote it just opened");
6956        assert!(d.status.is_none(), "{:?}", d.status);
6957        assert!(d.dirty);
6958
6959        // And the paragraph below is still its own block: an empty container one
6960        // soft break from `abc` would take that paragraph into the quote with it.
6961        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
6962        d.caret = 0;
6963        d.toggle_blockquote();
6964        d.build_visual(80);
6965        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
6966
6967        // The same from the other side: a blank line directly under a paragraph
6968        // earns the blank line an empty block needs, rather than being read as a
6969        // soft break inside that paragraph.
6970        let mut d = doc_with("list_blank_below", "abc\n");
6971        d.caret = 4;
6972        d.toggle_list(false);
6973        assert_eq!(d.source, "abc\n\n- ");
6974        assert_eq!(d.caret, 7);
6975    }
6976
6977    #[test]
6978    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
6979        // The gesture the rendering fix is for. `newline` inside a quote already
6980        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
6981        // spelling — but the two marker lines it adds belonged to no node until
6982        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
6983        // just made drew as plain prose under the quote.
6984        let mut d = wysiwyg_doc("quote_enter", "> a\n");
6985        d.caret = 3; // past `a`, at the end of the quoted line
6986        d.newline();
6987        assert_eq!(d.source, "> a\n>\n> \n");
6988        d.build_visual(80);
6989        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
6990        // And the caret is on the new line, not stranded on the old one.
6991        assert_eq!(d.caret, 8);
6992    }
6993
6994    #[test]
6995    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
6996        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
6997        // now it is twig's single edit. Either way one ⌘z has to put the blank
6998        // line back rather than undoing into a half-built document.
6999        for open in [
7000            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7001            &|d: &mut Doc| d.toggle_list(false),
7002            &|d: &mut Doc| d.toggle_list(true),
7003        ] {
7004            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7005            d.caret = 3;
7006            open(&mut d);
7007            assert_ne!(d.source, "a\n\n\n\nb\n");
7008            d.undo();
7009            assert_eq!(d.source, "a\n\n\n\nb\n");
7010        }
7011    }
7012
7013    #[test]
7014    fn a_container_toggle_is_one_undo_step() {
7015        let mut d = doc_with("quote_undo", "hello\n");
7016        d.caret = 3;
7017        d.insert("X"); // a typing run the structural edit must not fold into
7018        d.toggle_blockquote();
7019        assert_eq!(d.source, "> helXlo\n");
7020        d.undo();
7021        assert_eq!(d.source, "helXlo\n");
7022    }
7023
7024    // ── links ────────────────────────────────────────────────────────────────
7025
7026    #[test]
7027    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7028        let mut d = doc_with("link_sel", "word here\n");
7029        d.anchor = Some(0);
7030        d.caret = 4;
7031        d.insert_link("http://x.dev");
7032        assert_eq!(d.source, "[word](http://x.dev) here\n");
7033        // The text, not the destination — so a second press re-points the link
7034        // the first one made rather than nesting one inside it.
7035        assert_eq!(d.selected_text(), Some("word"));
7036        d.insert_link("http://y.dev");
7037        assert_eq!(d.source, "[word](http://y.dev) here\n");
7038        assert_eq!(d.selected_text(), Some("word"));
7039    }
7040
7041    #[test]
7042    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7043        let mut d = doc_with("img_caret", "before after\n");
7044        d.caret = 7; // between "before " and "after"
7045        d.insert_image("cat.png", "a cat");
7046        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
7047        // The caret sits just past the inserted image, nothing selected.
7048        assert_eq!(d.selection(), None);
7049        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
7050    }
7051
7052    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7053    /// destination at the first space, so the `format!` this used to be wrote
7054    /// something that was not an image at all — and the reader saw the markup as
7055    /// text. twig owns the spelling now, and moves it into the angle form.
7056    #[test]
7057    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7058        let mut d = doc_with("img_space", "x\n");
7059        d.caret = 0;
7060        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7061        assert_eq!(
7062            d.source,
7063            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
7064        );
7065        // And it reads back as an image pointing at the unescaped path — the angle
7066        // brackets are spelling, not part of the destination.
7067        d.caret = 2;
7068        assert_eq!(
7069            d.image_destination_at_caret(),
7070            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7071        );
7072    }
7073
7074    /// A `)` in a caption or a filename must not close the image early.
7075    #[test]
7076    fn insert_image_escapes_a_paren_in_either_half() {
7077        let mut d = doc_with("img_paren", "x\n");
7078        d.caret = 0;
7079        d.insert_image("a)b.png", "");
7080        assert_eq!(d.source, "![](a\\)b.png)x\n");
7081        d.caret = 2;
7082        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7083    }
7084
7085    #[test]
7086    fn insert_image_uses_the_selection_as_alt_text() {
7087        let mut d = doc_with("img_sel", "caption here\n");
7088        d.anchor = Some(0);
7089        d.caret = 7; // "caption"
7090        d.insert_image("p.png", "ignored fallback");
7091        assert_eq!(d.source, "![caption](p.png) here\n");
7092    }
7093
7094    #[test]
7095    fn insert_image_with_no_alt_leaves_empty_brackets() {
7096        let mut d = doc_with("img_noalt", "\n");
7097        d.caret = 0;
7098        d.insert_image("logo.svg", "");
7099        assert_eq!(d.source, "![](logo.svg)\n");
7100    }
7101
7102    #[test]
7103    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7104        // The round trip is the point: it's no use writing markup the reader
7105        // can't pick up again. This is the pair that only holds from twig 2.5.1
7106        // on — before it, the one-line form went in fine and came back as a
7107        // paragraph of raw tags, publishing no media at all.
7108        let mut d = doc_with("vid_rt", "\n");
7109        d.caret = 0;
7110        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7111        assert_eq!(d.source, "<video src=\"clip.mp4\" controls>a clip</video>\n");
7112
7113        d.build_visual(80);
7114        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7115        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7116        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7117        assert_eq!(d.vmap.media[0].alt, "a clip");
7118    }
7119
7120    #[test]
7121    fn insert_media_spells_audio_with_its_own_tag() {
7122        let mut d = doc_with("aud_rt", "\n");
7123        d.caret = 0;
7124        d.insert_media(MediaKind::Audio, "take.mp3", "");
7125        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7126        d.build_visual(80);
7127        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7128    }
7129
7130    #[test]
7131    fn insert_media_uses_the_selection_as_fallback_text() {
7132        // The same courtesy `insert_image` does with alt: select a caption,
7133        // insert, and the caption labels the thing rather than being replaced.
7134        let mut d = doc_with("vid_sel", "the talk here\n");
7135        d.anchor = Some(0);
7136        d.caret = 8; // "the talk"
7137        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7138        assert_eq!(d.source, "<video src=\"talk.mp4\" controls>the talk</video> here\n");
7139    }
7140
7141    #[test]
7142    fn insert_media_with_an_image_kind_is_just_insert_image() {
7143        let mut d = doc_with("img_via_media", "\n");
7144        d.caret = 0;
7145        d.insert_media(MediaKind::Image, "logo.svg", "x");
7146        assert_eq!(d.source, "![x](logo.svg)\n");
7147    }
7148
7149    // ── thematic breaks ─────────────────────────────────────────────────────
7150
7151    /// The node the source parses as at `caret` — what confirms an inserted
7152    /// `---` actually reads back as a rule, not stray text or a setext heading.
7153    ///
7154    /// The *narrowest* node covering the offset. Every ancestor covers it too,
7155    /// and since twig 2.8 that includes the `doc` root, which now carries a real
7156    /// span (it reported none before, so taking the first match used to land on
7157    /// the block by luck and now always answers `"doc"`).
7158    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
7159        d.nodes()
7160            .into_iter()
7161            .filter(|n| n.span.start <= caret && caret < n.span.end)
7162            .min_by_key(|n| n.span.end - n.span.start)
7163            .map(|n| n.kind)
7164    }
7165
7166    #[test]
7167    fn a_task_box_toggles_at_the_caret_and_reads_back() {
7168        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
7169        d.caret = 8; // inside "todo"
7170        assert_eq!(d.task_checked_at_caret(), Some(false));
7171        d.toggle_task_checked();
7172        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
7173        assert_eq!(d.task_checked_at_caret(), Some(true));
7174        d.toggle_task_checked();
7175        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
7176    }
7177
7178    #[test]
7179    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
7180        // The whole reason `toggle_task_at` exists apart from the caret form:
7181        // ticking a box elsewhere must not move the cursor out of what's being
7182        // typed.
7183        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
7184        d.caret = 8; // inside "first"
7185        let second = d.source.find("second").unwrap();
7186        d.toggle_task_at(second);
7187        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
7188        assert_eq!(d.caret, 8, "the caret stayed in the first item");
7189    }
7190
7191    #[test]
7192    fn a_plain_item_gains_and_loses_a_box() {
7193        let mut d = doc_with("task_mint", "- plain\n");
7194        d.caret = 4;
7195        assert_eq!(d.task_checked_at_caret(), None);
7196        d.toggle_task_item();
7197        assert_eq!(d.source, "- [ ] plain\n");
7198        assert_eq!(d.task_checked_at_caret(), Some(false), "a new box arrives unticked");
7199        d.toggle_task_item();
7200        assert_eq!(d.source, "- plain\n");
7201    }
7202
7203    #[test]
7204    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
7205        // `set checked` must not silently convert a bullet into a task — that is
7206        // `toggle_task_item`'s job, and twig refuses it here.
7207        let mut d = doc_with("task_none", "- plain\n");
7208        d.caret = 4;
7209        d.toggle_task_checked();
7210        assert_eq!(d.source, "- plain\n", "nothing written");
7211        assert!(d.status.is_some(), "the refusal should reach the status line");
7212    }
7213
7214    #[test]
7215    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
7216        let mut d = doc_with("task_quote", "> - [ ] nested\n");
7217        d.caret = d.source.find("nested").unwrap();
7218        assert_eq!(d.task_checked_at_caret(), Some(false));
7219        d.toggle_task_checked();
7220        assert_eq!(d.source, "> - [x] nested\n");
7221    }
7222
7223    #[test]
7224    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
7225        // A rule is a block, so twig's `insert_thematic_break` alone lands it
7226        // after the whole paragraph. `split_block` parts the paragraph first and
7227        // the rule is aimed at the *first* half, which is what a rule button is
7228        // understood to do — and what leaf spelled by hand until twig grew both
7229        // halves of the gesture.
7230        let mut d = doc_with("hr_mid", "before after\n");
7231        d.caret = 7; // between "before " and "after"
7232        d.insert_thematic_break();
7233        assert_eq!(d.source, "before \n\n---\n\nafter\n");
7234        assert_eq!(d.selection(), None);
7235        assert_eq!(kind_at(&mut d, "before \n\n".len()), Some(Kind::ThematicBreak));
7236    }
7237
7238    #[test]
7239    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
7240        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
7241        // and leaf wrote the first into both until twig started spelling it.
7242        let mut md = doc_with("hr_md", "para\n");
7243        md.caret = 2;
7244        md.insert_thematic_break();
7245        assert_eq!(md.source, "pa\n\n---\n\nra\n");
7246
7247        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
7248        dj.caret = 2;
7249        dj.insert_thematic_break();
7250        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
7251    }
7252
7253    #[test]
7254    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
7255        // The same bytes are two documents. In Markdown `  - b` is a nested item
7256        // and the next one belongs beside it, at its indent. In Djot a list
7257        // marker can't interrupt a paragraph, so those bytes are literal text in
7258        // item `a` and there is only one item — writing `  - ` under it would add
7259        // no item at all, just more text, and the new sibling has to go to
7260        // column zero. Both spellings come out of the *enclosing item's* line.
7261        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
7262        md.caret = "- a\n  - b".len();
7263        md.newline();
7264        assert_eq!(md.source, "- a\n  - b\n  - \n");
7265        assert_eq!(list_items(&mut md), 3);
7266
7267        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
7268        dj.view = View::Wysiwyg;
7269        dj.build_visual(80);
7270        dj.caret = "- a\n  - b".len();
7271        dj.newline();
7272        assert_eq!(dj.source, "- a\n  - b\n- \n");
7273        assert_eq!(list_items(&mut dj), 2);
7274
7275        // Where Djot's nesting is real — opened by a blank line — the indent is
7276        // reproduced there too, and the two formats agree again.
7277        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
7278        dj.view = View::Wysiwyg;
7279        dj.build_visual(80);
7280        dj.caret = "- a\n\n  - b".len();
7281        dj.newline();
7282        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
7283        assert_eq!(list_items(&mut dj), 3);
7284    }
7285
7286    #[test]
7287    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
7288        // Tab replaces the line's whole prefix with the one twig spells, so the
7289        // quote markers, the parent's indent and an ordered marker's extra
7290        // column are all its answer rather than leaf's arithmetic.
7291        for (name, body, caret, want) in [
7292            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
7293            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
7294            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
7295            // A checkbox is markup the item's own text wraps past, but a nested
7296            // list may only open at the *list* marker's column — four in from
7297            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
7298            // parses as one item, not two.
7299            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
7300            (
7301                "quoted task",
7302                "> - [ ] a\n> - [ ] b\n",
7303                18,
7304                "> - [ ] a\n>   - [ ] b\n",
7305            ),
7306        ] {
7307            let mut doc = wysiwyg_doc(name, body);
7308            doc.caret = caret;
7309            doc.indent();
7310            assert_eq!(doc.source, want, "{name}");
7311            // The nesting is real, not just indented text.
7312            assert_eq!(list_items(&mut doc), 2, "{name}");
7313        }
7314    }
7315
7316    #[test]
7317    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
7318        // The same bytes, the two formats disagreeing, and a gesture that used
7319        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
7320        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
7321        // so those bytes are literal text inside item `a` — there is nothing to
7322        // outdent, and treating them as a marker turned one item into two, a
7323        // structural edit from a keystroke that should delete one character.
7324        //
7325        // twig's `line_prefix` is what tells them apart: it reports the marker
7326        // on the Markdown line and nothing on the Djot one, which is a
7327        // continuation. No byte scan can reach that answer.
7328        let src = "- a\n  - b\n";
7329        let at = "- a\n  - ".len();
7330
7331        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
7332        md.view = View::Wysiwyg;
7333        md.build_visual(80);
7334        md.caret = at;
7335        md.backspace();
7336        assert_eq!(md.source, "- a\n- b\n");
7337        assert_eq!(list_items(&mut md), 2);
7338
7339        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
7340        dj.view = View::Wysiwyg;
7341        dj.build_visual(80);
7342        dj.caret = at;
7343        dj.backspace();
7344        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
7345        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
7346    }
7347
7348    #[test]
7349    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
7350        // Leaf used to spell the next item from the marker bytes it scanned, and
7351        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
7352        // and dropped out of the checklist. twig reproduces the whole
7353        // continuation, and a fresh item is always unticked however the one above
7354        // it stands.
7355        for (name, body, want) in [
7356            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
7357            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
7358        ] {
7359            let mut doc = wysiwyg_doc(name, body);
7360            doc.caret = body.trim_end_matches('\n').len();
7361            doc.newline();
7362            assert_eq!(doc.source, want, "{name}");
7363            // Both items are checklist items — the new one is a box, not the
7364            // plain bullet the old marker scan left behind — and it is unticked
7365            // whichever way the one above it faces.
7366            let boxes: Vec<Option<bool>> = doc
7367                .nodes()
7368                .iter()
7369                .filter(|n| n.kind == Kind::TaskListItem)
7370                .map(|n| n.checked)
7371                .collect();
7372            assert_eq!(boxes.len(), 2, "{name}");
7373            assert_eq!(boxes[1], Some(false), "{name}");
7374        }
7375    }
7376
7377    #[test]
7378    fn a_split_takes_the_space_the_caret_was_in_front_of() {
7379        // Splicing a break at the caret strands the space the words were parted
7380        // at on the head of the second block, where it reads as an indent nobody
7381        // typed. twig's split consumes it.
7382        for (name, body, caret, want) in [
7383            ("para", "one two\n", 3, "one\n\ntwo\n"),
7384            ("item", "- one two\n", 5, "- one\n- two\n"),
7385            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
7386            // A heading takes leaf's own path, which has to match.
7387            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
7388        ] {
7389            let mut doc = wysiwyg_doc(name, body);
7390            doc.caret = caret;
7391            doc.newline();
7392            assert_eq!(doc.source, want, "{name}");
7393        }
7394    }
7395
7396    #[test]
7397    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
7398        // The one place leaf keeps its own break: `split_block` repeats the `#`,
7399        // and Enter after a title is how the body under it is asked for.
7400        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
7401        doc.caret = "# Title".len();
7402        doc.newline();
7403        doc.insert("body");
7404        assert_eq!(doc.source, "# Title\n\nbody\n");
7405        assert_eq!(
7406            doc.nodes()
7407                .iter()
7408                .filter(|n| n.kind == Kind::Heading)
7409                .count(),
7410            1
7411        );
7412    }
7413
7414    #[test]
7415    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
7416        // A quoted item's marker doesn't open its line, so a scan that starts at
7417        // column zero finds a `>` where it wanted a bullet, calls the line "not a
7418        // list" and hands Enter to the plain-quote branch — which writes `> ` and
7419        // drops the list. The next item has to carry the whole prefix.
7420        for (name, body, want) in [
7421            ("flat", "> - a\n", "> - a\n> - \n"),
7422            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
7423            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
7424            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
7425            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
7426        ] {
7427            let mut doc = wysiwyg_doc(name, body);
7428            doc.caret = body.trim_end_matches('\n').len();
7429            doc.newline();
7430            assert_eq!(doc.source, want, "{name}");
7431            // The marker isn't just spelled right, it parses as an item.
7432            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
7433        }
7434    }
7435
7436    #[test]
7437    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
7438        // Double-Enter exits the list. Unquoted that means a blank line, but a
7439        // *bare* blank line would end the quote too and drop the caret out of it,
7440        // so the separator keeps its `>` and the caret's line keeps its `> `.
7441        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
7442        doc.caret = "> - a\n> - ".len();
7443        doc.newline();
7444        assert_eq!(doc.source, "> - a\n>\n> \n");
7445        assert_eq!(list_items(&mut doc), 1);
7446        // What "still in the quote" means for the next keystroke: the caret sits
7447        // behind the prefix, and what's typed there lands inside the quote as a
7448        // paragraph of its own — not as more of item `a`.
7449        doc.insert("x");
7450        assert_eq!(doc.source, "> - a\n>\n> x\n");
7451        assert!(
7452            doc.editor
7453                .ancestors_at(doc.caret - 1)
7454                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
7455        );
7456    }
7457
7458    #[test]
7459    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
7460        // The marker is hidden block markup, so Backspace over it is structural —
7461        // but only the marker is the list's. Splicing from the line start would
7462        // take the `>` with it and silently unquote the line.
7463        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
7464        doc.caret = "> - ".len();
7465        doc.backspace();
7466        assert_eq!(doc.source, "> a\n");
7467        assert_eq!(list_items(&mut doc), 0);
7468
7469        // A nested one outdents instead, moving the bullet within the quote
7470        // rather than moving the quote.
7471        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
7472        doc.caret = "> - a\n>   - ".len();
7473        doc.backspace();
7474        assert_eq!(doc.source, "> - a\n> - b\n");
7475        assert_eq!(list_items(&mut doc), 2);
7476    }
7477
7478    #[test]
7479    fn only_a_bare_paragraph_is_parted_around_the_caret() {
7480        // The split is deliberately narrow. Parting a fenced block would leave
7481        // two fences with a rule between them, and parting a list item would
7482        // mint an item nobody asked for on the way to a rule that lands after
7483        // the list either way — so both keep the whole block intact and take the
7484        // rule after it. A caret in a quote is likewise left alone.
7485        for (name, body, caret, want) in [
7486            ("code", "```\nfn x() {}\n```\n", 8, "```\nfn x() {}\n```\n\n---\n"),
7487            ("list", "- one two\n", 6, "- one two\n\n---\n"),
7488            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
7489        ] {
7490            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
7491            d.caret = caret;
7492            d.insert_thematic_break();
7493            assert_eq!(d.source, want, "{name}: the block should stay whole");
7494        }
7495    }
7496
7497    #[test]
7498    fn insert_thematic_break_replaces_the_selection() {
7499        // Now that the rule lands *at* the caret again, replacing the selection
7500        // is coherent once more: the text goes, and the rule takes its place.
7501        // The space the deletion left leading the second half is consumed by the
7502        // split rather than opening the new paragraph with it.
7503        let mut d = doc_with("hr_sel", "one two three\n");
7504        d.anchor = Some(4);
7505        d.caret = 7; // "two"
7506        d.insert_thematic_break();
7507        assert_eq!(d.source, "one \n\n---\n\nthree\n");
7508        assert_eq!(d.selection(), None);
7509    }
7510
7511    #[test]
7512    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
7513        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
7514        // because writing `---` into one is code, not a rule — twig now walks out
7515        // to the block that owns the caret's line, so there is nothing to refuse.
7516        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
7517        code.caret = 5; // inside the fenced code
7518        code.insert_thematic_break();
7519        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
7520        assert_eq!(code.status, None, "no refusal to report any more");
7521
7522        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
7523        table.caret = 3; // in the header row
7524        table.insert_thematic_break();
7525        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
7526    }
7527
7528    #[test]
7529    fn insert_thematic_break_in_a_list_item_ends_the_list() {
7530        // The un-indented rule cannot continue the list, so it closes the list
7531        // and lands at the top level rather than nested inside it.
7532        let mut d = doc_with("hr_list", "- one\n- two\n");
7533        d.caret = "- one\n- tw".len(); // mid "two"
7534        d.insert_thematic_break();
7535        d.build_visual(80);
7536        let rule_at = d.source.find("---").unwrap();
7537        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
7538        assert!(
7539            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
7540                && n.span.start <= rule_at
7541                && rule_at < n.span.end),
7542            "the rule must not be nested inside the list"
7543        );
7544    }
7545
7546    #[test]
7547    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
7548        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
7549        // which is the document the gesture was actually asked for.
7550        let mut d = doc_with("hr_quote", "> hello\n");
7551        d.caret = 4; // inside the quoted text
7552        d.insert_thematic_break();
7553        assert_eq!(d.source, "> hello\n>\n> ---\n");
7554        d.build_visual(80);
7555        let rule_at = d.source.find("---").unwrap();
7556        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
7557        assert!(
7558            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
7559                && n.span.start <= rule_at
7560                && rule_at < n.span.end),
7561            "the rule belongs to the quote it was asked for"
7562        );
7563    }
7564
7565    // ── typing against a block picture ────────────────────────────────────────
7566
7567    /// A rendered-view document with the caret parked on one of the picture's two
7568    /// stops, and the map already built — the state a frontend is in between
7569    /// drawing a frame and the next keystroke.
7570    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
7571        let mut d = doc_in(View::Wysiwyg, name, src);
7572        d.build_visual_unwrapped();
7573        let start = src.find("![").unwrap();
7574        d.caret = match side {
7575            MediaStop::Before => start,
7576            MediaStop::After => start + "![](p.png)".len(),
7577        };
7578        d
7579    }
7580
7581    /// The block media the map publishes, after rebuilding it — "is this still a
7582    /// picture, or has it become a line of text with an image in it?"
7583    fn media_count(d: &mut Doc) -> usize {
7584        d.build_visual_unwrapped();
7585        d.vmap.media.len()
7586    }
7587
7588    #[test]
7589    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
7590        // The accident this prevents: tap the blank page under a photo (which
7591        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
7592        // paragraph with an *inline* image — the photo stops being drawn.
7593        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
7594        d.insert("xy");
7595        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
7596        assert_eq!(media_count(&mut d), 1, "still a picture");
7597    }
7598
7599    #[test]
7600    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
7601        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
7602        d.insert("xy");
7603        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
7604        assert_eq!(media_count(&mut d), 1);
7605    }
7606
7607    #[test]
7608    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
7609        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
7610        d.insert("x");
7611        assert_eq!(d.source, "x\n\n![](p.png)\n");
7612        assert_eq!(media_count(&mut d), 1);
7613    }
7614
7615    #[test]
7616    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
7617        // The opened paragraph is part of the keystroke, not an edit the writer
7618        // made — so it undoes with the character, not a step later.
7619        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
7620        d.insert("x");
7621        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
7622        d.undo();
7623        assert_eq!(d.source, "hi\n\n![](p.png)\n");
7624    }
7625
7626    #[test]
7627    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
7628        // ⌘V dissolves the picture exactly as a keystroke does.
7629        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
7630        d.paste("pasted");
7631        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
7632        assert_eq!(media_count(&mut d), 1);
7633    }
7634
7635    #[test]
7636    fn typing_beside_an_inline_image_is_ordinary_editing() {
7637        // An inline image has no placeholder row and no stops of its own. Opening
7638        // a paragraph mid-sentence would be the bug, not the fix.
7639        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
7640        d.build_visual_unwrapped();
7641        d.caret = "see ![](p.png)".len();
7642        d.insert("!");
7643        assert_eq!(d.source, "see ![](p.png)! here\n");
7644    }
7645
7646    #[test]
7647    fn source_view_types_raw_markup_against_an_image_untouched() {
7648        // Source view is for writing the markup itself; a break inserted behind
7649        // the writer's back there would be the editor arguing with them.
7650        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
7651        d.caret = "![](p.png)".len();
7652        d.insert("x");
7653        assert_eq!(d.source, "![](p.png)x\n");
7654    }
7655
7656    #[test]
7657    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
7658        // A selection is replaced, not joined into, so there is nothing to
7659        // protect: the range takes the picture with it.
7660        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
7661        d.anchor = Some(d.caret);
7662        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
7663        d.insert("x");
7664        assert_eq!(d.source, "hi\n\nx\n");
7665    }
7666
7667    #[test]
7668    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
7669        // What this actually cost: a real vault's photo, to one stray Backspace.
7670        // The caret past `![](p.png)` was deleting the closing paren — invisible
7671        // in the rendered view — and the photo became the text `![](p.png`.
7672        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
7673        d.backspace();
7674        assert_eq!(d.source, "hi\n");
7675        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
7676        d.undo();
7677        assert_eq!(d.source, "hi\n\n![](p.png)\n", "and comes back in one piece");
7678    }
7679
7680    #[test]
7681    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
7682        // Deleting the break here would join the picture to the paragraph above,
7683        // where it is an *inline* image and stops being drawn. Step over the
7684        // boundary; the next press deletes in the paragraph the caret reached.
7685        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
7686        d.backspace();
7687        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
7688        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
7689        d.backspace();
7690        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
7691        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
7692    }
7693
7694    #[test]
7695    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
7696        // The mirror. A byte-step here eats the `!` and leaves a link.
7697        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
7698        d.delete_forward();
7699        assert_eq!(d.source, "hi\n\nbye\n");
7700        assert_eq!(media_count(&mut d), 0);
7701    }
7702
7703    #[test]
7704    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
7705        let mut d = doc_at_picture("pic_del_after", "hi\n\n![](p.png)\n\nbye\n", MediaStop::After);
7706        d.delete_forward();
7707        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
7708        assert_eq!(d.caret, d.source.find("bye").unwrap(), "the caret stepped down to `bye`");
7709    }
7710
7711    #[test]
7712    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
7713        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
7714        d.backspace();
7715        assert_eq!(d.source, "\n");
7716        assert_eq!(media_count(&mut d), 0);
7717    }
7718
7719    #[test]
7720    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
7721        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
7722        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
7723        d.delete_word_back();
7724        assert_eq!(d.source, "hi there\n");
7725
7726        // And in front of one it runs *through* the paragraph break into the
7727        // prose above, which merges the picture inline — so it steps out first,
7728        // and the second press deletes the word it was aimed at.
7729        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
7730        d.delete_word_back();
7731        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
7732        d.delete_word_back();
7733        assert_eq!(d.source, "hi \n\n![](p.png)\n", "the word above went, the picture stayed");
7734        assert_eq!(media_count(&mut d), 1);
7735    }
7736
7737    #[test]
7738    fn source_view_deletes_raw_markup_against_an_image_untouched() {
7739        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
7740        d.caret = "![](p.png)".len();
7741        d.backspace();
7742        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
7743    }
7744
7745    #[test]
7746    fn image_destination_at_caret_reads_the_image_under_the_caret() {
7747        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
7748        d.caret = 3; // inside the image markup
7749        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
7750        // Past the image, the caret is in no image.
7751        d.caret = "![a cat](cat.png)".len();
7752        assert_eq!(d.image_destination_at_caret(), None);
7753    }
7754
7755    #[test]
7756    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
7757        // The image is one placeholder row by default, and `set_media_rows` grows
7758        // it to the height the frontend measured: the label row plus blank
7759        // `decoration` fillers that hold the vertical space a raster is drawn into.
7760        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
7761        assert_eq!(d.vmap.media.len(), 1);
7762        let img_row = d.vmap.media[0].rows_span.start;
7763        assert_eq!(d.vmap.media[0].rows_span, img_row..img_row + 1, "default is one row");
7764
7765        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
7766        d.build_visual(80);
7767        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
7768        let span = d.vmap.media[0].rows_span.clone();
7769        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
7770        // The label row carries the mark and its glyphs; the three below are blank
7771        // decoration — drawn, but no caret and no text.
7772        assert!(d.vmap.rows[span.start].media.is_some(), "mark rides the first row");
7773        for r in (span.start + 1)..span.end {
7774            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
7775            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
7776            assert!(d.vmap.rows[r].media.is_none(), "only the first row is marked");
7777        }
7778    }
7779
7780    #[test]
7781    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
7782        // The extra rows are pure spacers: the caret's only homes stay the stop in
7783        // front of the image and the one just past it, so walking the document top
7784        // to bottom visits the same offsets whether the image is 1 row or 5.
7785        let body = "ab\n\n![x](p.png)\n\ncd\n";
7786        let stops_at = |rows: usize| -> Vec<usize> {
7787            let mut d = wysiwyg_doc("img_stops", body);
7788            if rows > 1 {
7789                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
7790                d.build_visual(80);
7791            }
7792            d.caret = 0;
7793            let mut seen = vec![d.caret];
7794            loop {
7795                d.move_right(false);
7796                if *seen.last().unwrap() == d.caret {
7797                    break;
7798                }
7799                seen.push(d.caret);
7800            }
7801            seen
7802        };
7803        assert_eq!(stops_at(1), stops_at(5), "reserving rows must not add stops");
7804    }
7805
7806    #[test]
7807    fn insert_link_repoints_the_link_at_a_bare_caret() {
7808        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
7809        d.caret = 3; // in the link's text, nothing selected
7810        d.insert_link("http://y.dev");
7811        assert_eq!(d.source, "[word](http://y.dev)\n");
7812        assert_eq!(d.selected_text(), Some("word"));
7813    }
7814
7815    #[test]
7816    fn insert_link_on_an_empty_range_autolinks_a_url() {
7817        // A link with no text of its own is an autolink, and twig spells it —
7818        // `<…>` is the canonical form and needs no text typed into it, so the
7819        // caret lands after it rather than selecting a finished link.
7820        let mut d = doc_with("link_empty", "\n");
7821        d.caret = 0;
7822        d.insert_link("http://x.dev");
7823        assert_eq!(d.source, "<http://x.dev>\n");
7824        assert_eq!(d.selection(), None);
7825        assert_eq!(d.caret, 14);
7826    }
7827
7828    #[test]
7829    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
7830        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
7831        // in Markdown, so a destination that can't autolink doubles as the text
7832        // instead — which is then selected, ready to be typed over.
7833        let mut d = doc_with("link_rel", "\n");
7834        d.caret = 0;
7835        d.insert_link("./notes.md");
7836        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
7837        assert_eq!(d.selection(), Some((1, 11)));
7838        d.insert("Notes");
7839        assert_eq!(d.source, "[Notes](./notes.md)\n");
7840    }
7841
7842    #[test]
7843    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
7844        // The autolink's text is its URL, so re-pointing replaces the whole
7845        // node — the caret must not splice a second link inside the first.
7846        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
7847        d.caret = 10;
7848        d.insert_link("https://y.dev");
7849        assert_eq!(d.source, "see <https://y.dev> ok\n");
7850    }
7851
7852    #[test]
7853    fn code_language_reads_and_edits_through_the_fence() {
7854        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
7855        d.caret = 10; // inside the code body
7856        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
7857        assert!(d.caret_in_fenced_code());
7858
7859        d.set_code_language("python");
7860        assert!(d.source.starts_with("```python\n"), "source: {:?}", d.source);
7861        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
7862
7863        // Clearing it leaves a bare fence and no label.
7864        d.set_code_language("");
7865        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
7866        assert_eq!(d.code_language_at_caret(), None);
7867
7868        // A caret outside any code block edits nothing.
7869        let mut p = doc_with("code_lang_none", "just prose\n");
7870        assert!(!p.caret_in_fenced_code());
7871        p.set_code_language("rust");
7872        assert_eq!(p.source, "just prose\n");
7873    }
7874
7875    #[test]
7876    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
7877        // Markdown's info string ends at whitespace, so `two words` would write
7878        // a fence that reads back with a different language than the one asked
7879        // for. twig refuses it; leaf reports that and leaves the source alone.
7880        // The old splice trimmed the ends and wrote whatever was left.
7881        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
7882        d.caret = 10;
7883        d.set_code_language("two words");
7884        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
7885        assert!(d.status.is_some(), "the refusal should be reported");
7886        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
7887    }
7888
7889    #[test]
7890    fn link_destination_at_caret_reads_both_spellings() {
7891        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
7892        d.caret = 5;
7893        assert_eq!(d.link_destination_at_caret().as_deref(), Some("https://x.dev"));
7894        d.caret = 0;
7895        assert_eq!(d.link_destination_at_caret(), None);
7896
7897        // An autolink has no `destination`; its text is the URL.
7898        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
7899        a.caret = 10;
7900        assert_eq!(a.link_destination_at_caret().as_deref(), Some("https://x.dev"));
7901        a.caret = 21;
7902        assert_eq!(a.link_destination_at_caret(), None);
7903    }
7904
7905    #[test]
7906    fn locate_finds_the_block_a_declared_id_names() {
7907        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
7908        // verse. The locator has to land on the *verse*, which is the whole
7909        // reason a link carries one.
7910        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
7911                   {#v2}\nYea, I make a record in the language of my father.\n";
7912        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
7913        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
7914        assert_eq!(
7915            d.source[v2.start..v2.end].trim_end(),
7916            "Yea, I make a record in the language of my father."
7917        );
7918        // The attribute line is not part of it: `start` is a place to put a
7919        // caret, and `{#v2}` is markup the caret has no business landing in.
7920        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
7921        assert_eq!(d.locate("v99"), None);
7922    }
7923
7924    #[test]
7925    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
7926        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
7927        // Markdown heading is literal text. So `#the-second-part` can only be
7928        // the heading's own words, which is the rule every Markdown renderer
7929        // already follows and therefore the one a link was authored against.
7930        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
7931        let mut d = doc_with("locate_md", src);
7932        let hit = d.locate("the-second-part").expect("the heading's slug");
7933        assert!(d.source[hit.start..].starts_with("## The Second Part"));
7934        // Bounded by the next heading that isn't under it, so a peek shows the
7935        // section rather than only its title.
7936        assert_eq!(&d.source[hit.start..hit.end], "## The Second Part\n\nbody\n\n");
7937
7938        // A subsection does not end its parent: `# Title` runs to `## Third`'s
7939        // sibling only because there is no other `#`, so it covers the lot.
7940        let title = d.locate("title").expect("the top heading");
7941        assert_eq!(title.end, d.source.len());
7942    }
7943
7944    #[test]
7945    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
7946        // djot mints `Some-Heading-Here`; a link to it is written
7947        // `#some-heading-here` by nearly everything that writes links. Both
7948        // spellings are one question.
7949        let src = "## Some Heading Here\n\nbody\n";
7950        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
7951        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
7952        let slugged = d.locate("some-heading-here").expect("the link's spelling");
7953        assert_eq!(exact, slugged);
7954        // The section, not the heading line — there is more to show than a title.
7955        assert_eq!(&d.source[exact.start..exact.end], src);
7956    }
7957
7958    #[test]
7959    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
7960        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
7961        assert_eq!(d.locate(""), None);
7962        assert_eq!(d.locate("   "), None);
7963        // All punctuation: it names nothing, and must not be read as "match the
7964        // first heading whose slug is also empty".
7965        assert_eq!(d.locate("!!!"), None);
7966    }
7967
7968    #[test]
7969    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
7970        // The document's mistake, and the answer every other anchor
7971        // implementation gives — the alternative is for a link to mean whichever
7972        // of the two a walk happened to reach first.
7973        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
7974        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
7975        let hit = d.locate("dup").expect("the first `{#dup}`");
7976        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
7977    }
7978
7979    #[test]
7980    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
7981        // The button's whole job: a reference where the caret was, a definition
7982        // to give it meaning, and the caret waiting in the empty note so the
7983        // next keystroke is the note's first word.
7984        let mut d = doc_with("fn_insert", "A claim and more.\n");
7985        d.caret = 7; // just past "A claim"
7986        d.insert_footnote();
7987        assert!(d.source.starts_with("A claim[^1] and more."), "{:?}", d.source);
7988        assert!(d.source.contains("[^1]:"), "the definition too: {:?}", d.source);
7989        assert_eq!(d.status, None);
7990
7991        let reference = d.source.find("[^1]").unwrap();
7992        let note = d.footnote_at(reference + 2).expect("the reference just written");
7993        assert_eq!(note.label, "1");
7994        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
7995        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
7996        // …and typing there is typing into the note, not near it.
7997        d.insert("the note");
7998        assert_eq!(
7999            d.footnote_at(reference + 2).and_then(|f| f.text),
8000            Some("the note".to_string())
8001        );
8002    }
8003
8004    #[test]
8005    fn insert_footnote_numbers_past_the_notes_already_written() {
8006        // A second press must not hand back a label somebody else is using: twig
8007        // reuses a defined label rather than appending a rival definition, so a
8008        // repeat of `1` would quietly point the new reference at the old note.
8009        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8010        d.caret = 7; // past `[^1]`, before " two."
8011        d.insert_footnote();
8012        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8013        assert_eq!(d.source.matches("[^2]:").count(), 1);
8014    }
8015
8016    #[test]
8017    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8018        // `[^2]` with no definition is still a 2 that means something to whoever
8019        // wrote it — stepping over it would mint a note for their reference. A
8020        // word label takes no number, so it blocks none.
8021        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8022        d.caret = d.source.find(" c").unwrap();
8023        d.insert_footnote();
8024        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8025        assert!(d.source.starts_with("a[^2] b[^why][^1] c"), "{:?}", d.source);
8026    }
8027
8028    #[test]
8029    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8030        // A reference annotates the words before it. Consuming the selection —
8031        // which is what an insert normally does — would delete the very claim
8032        // the author selected in order to footnote.
8033        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8034        d.anchor = Some(2);
8035        d.caret = 7; // "claim" selected
8036        d.insert_footnote();
8037        assert!(d.source.starts_with("A claim[^1] and more."), "{:?}", d.source);
8038    }
8039
8040    #[test]
8041    fn a_note_just_written_still_knows_where_its_reference_is() {
8042        // The authoring loop in one test: press the button, type the note, ask to
8043        // go back. The caret ends at the note's last byte — which is the *end* of
8044        // the definition's span, the one offset the query used to exclude — so
8045        // this is where the round trip either works or doesn't.
8046        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8047        d.caret = 7;
8048        d.insert_footnote();
8049        d.insert("the note");
8050        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8051        let back = d.footnote_definition_at_caret().expect("still in the note we just typed");
8052        assert_eq!(back.label, "1");
8053        // …and following it lands on the reference's label, where a reader's
8054        // return leg lands.
8055        assert_eq!(back.offset, Some(9));
8056        assert_eq!(&d.source[9..10], "1");
8057    }
8058
8059    #[test]
8060    fn insert_footnote_takes_one_undo_for_both_halves() {
8061        // twig writes the pair as a single edit; the point of that is here.
8062        let before = "A claim and more.\n";
8063        let mut d = doc_with("fn_insert_undo", before);
8064        d.caret = 7;
8065        d.insert_footnote();
8066        assert_ne!(d.source, before);
8067        d.undo();
8068        assert_eq!(d.source, before, "one undo takes back both halves");
8069    }
8070
8071    #[test]
8072    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
8073        // HTML is authorable — it spells the inline marks — and has no footnote.
8074        // The refusal says so rather than writing brackets that would render as
8075        // brackets.
8076        let src = "<p>A claim.</p>\n";
8077        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
8078        assert!(!Capabilities::of(Format::Html).footnote);
8079        d.caret = 5;
8080        d.insert_footnote();
8081        assert_eq!(d.source, src, "nothing written");
8082        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
8083    }
8084
8085    #[test]
8086    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
8087        // The empty body is the one place this could go wrong: the definition
8088        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
8089        // byte early would draw up in the paragraph above the note it belongs to.
8090        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
8091        d.place_caret(7, false);
8092        d.insert_footnote();
8093        d.build_visual(80); // the frame a frontend draws after the edit
8094        assert_eq!(d.vmap.snap_to_stop(d.caret), d.caret, "the caret sits on a stop");
8095        let (row, _) = d.caret_pos();
8096        assert!(
8097            drawn_rows(&d)[row].contains("[1]"),
8098            "the caret is on the note's row, not above it: {:?}",
8099            drawn_rows(&d)
8100        );
8101    }
8102
8103    #[test]
8104    fn footnote_at_caret_resolves_a_reference_to_its_note() {
8105        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
8106        // blank line, as one has to.
8107        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
8108        d.caret = 9;
8109        let f = d.footnote_at_caret().expect("the caret stands in a reference");
8110        assert_eq!(f.label, "1");
8111        assert_eq!(f.text.as_deref(), Some("the note"));
8112        // The offset points at the note's first word, not at the definition's
8113        // `[` — the marker is decoration with no caret stop on it.
8114        assert_eq!(f.offset, Some(29));
8115        assert_eq!(&d.source[29..37], "the note");
8116        // …and `end` closes the range, so a frontend can ask which rendered rows
8117        // the note occupies rather than re-deriving them from the text.
8118        assert_eq!(f.end, Some(37));
8119        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
8120    }
8121
8122    /// Two definitions in a row: each is its own note, and neither reaches into
8123    /// the other.
8124    ///
8125    /// A djot definition's span used to run past the blank line into the first
8126    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
8127    /// offsets named the *next* note's rows too, showing a reader two footnotes
8128    /// when they had asked about one. twig 3.1 ends the span after the block's
8129    /// own last line; the test outlives the workaround leaf carried for it.
8130    #[test]
8131    fn footnote_at_stops_a_note_at_the_definition_after_it() {
8132        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
8133        for format in [Format::Markdown, Format::Djot] {
8134            let mut d = Doc::from_source(src.to_string(), format).unwrap();
8135            d.caret = 7;
8136            let f = d.footnote_at_caret().expect("a reference");
8137            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
8138            assert_eq!(
8139                &src[f.offset.unwrap()..f.end.unwrap()],
8140                "first note.",
8141                "in {format:?}"
8142            );
8143        }
8144    }
8145
8146    /// The other side of that boundary: a blank line *inside* a definition is
8147    /// interior to it, and the note keeps its second paragraph.
8148    ///
8149    /// This is what the old body scan cost. It stopped at the first line not
8150    /// indented under the note — a blank line is not — so a two-paragraph note
8151    /// came back as its first paragraph, and "go to note" framed half of it.
8152    /// Reading the span twig gives is both simpler and right.
8153    #[test]
8154    fn footnote_at_keeps_a_notes_second_paragraph() {
8155        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
8156        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8157        d.caret = 7;
8158        let f = d.footnote_at_caret().expect("a reference");
8159        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
8160        // And it stops there — `After.` is the next block, not more note.
8161        assert_eq!(&src[f.offset.unwrap()..f.end.unwrap()], f.text.as_deref().unwrap());
8162        assert!(!f.text.as_deref().unwrap().contains("After"));
8163    }
8164
8165    #[test]
8166    fn footnote_at_bounds_a_note_whose_body_is_empty() {
8167        // `[^1]:` with nothing after it. The range is empty rather than
8168        // inverted, and still points inside the definition — which is what keeps
8169        // a frontend's row lookup from walking off into the block above.
8170        let src = "A claim[^1].\n\n[^1]:\n";
8171        let mut d = doc_with("fn_empty_body", src);
8172        d.caret = 9;
8173        let f = d.footnote_at_caret().expect("a reference");
8174        assert_eq!(f.text.as_deref(), Some(""));
8175        assert_eq!(f.offset, f.end, "an empty note is an empty range");
8176        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
8177    }
8178
8179    #[test]
8180    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
8181        let mut d = doc_with("fn_at_caret_none", "A claim[^1] and more.\n\n[^1]: the note\n");
8182        d.caret = 2; // in the prose
8183        assert_eq!(d.footnote_at_caret(), None);
8184    }
8185
8186    #[test]
8187    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
8188        // The two are deliberately separate: a reference names a note in this
8189        // document, a link names somewhere to leave for, and answering one with
8190        // the other is what made a reference click do nothing at all.
8191        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
8192        d.caret = 3; // the `1` of `[^1]`
8193        assert!(d.footnote_at_caret().is_some());
8194        assert_eq!(d.link_destination_at_caret(), None, "a reference is not a link");
8195
8196        d.caret = 10; // inside the link's label
8197        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
8198        assert_eq!(d.link_destination_at_caret().as_deref(), Some("https://x.dev"));
8199    }
8200
8201    #[test]
8202    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
8203        // A `[^99]` the document never defines is a real state — a note deleted
8204        // out from under its reference — and the label is what lets a frontend
8205        // say so. `None` here would be indistinguishable from "not on a
8206        // reference", which is the wrong thing to tell a reader.
8207        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
8208        d.caret = 9;
8209        let f = d.footnote_at_caret().expect("the reference is still a reference");
8210        assert_eq!(f.label, "99");
8211        assert_eq!(f.text, None);
8212        assert_eq!(f.offset, None);
8213    }
8214
8215    #[test]
8216    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
8217        // Labels are not always numbers, and a note's body runs past its first
8218        // line — the indented continuation belongs to the note, so it comes back
8219        // with it (source bytes, verbatim, as documented).
8220        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
8221        let mut d = doc_with("fn_word_label", src);
8222        d.caret = 6;
8223        let f = d.footnote_at_caret().expect("the caret stands in a reference");
8224        assert_eq!(f.label, "note");
8225        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
8226    }
8227
8228    #[test]
8229    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
8230        // The point of the offset form: a pointer hovering a reference asks what
8231        // note it names, and must not drag the caret along to ask.
8232        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
8233        d.caret = 0;
8234        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
8235        assert_eq!(f.label, "1");
8236        assert_eq!(f.text.as_deref(), Some("the note"));
8237        assert_eq!(d.caret, 0, "asking must not move the caret");
8238        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
8239    }
8240
8241    #[test]
8242    fn footnote_definition_at_caret_points_back_at_the_reference() {
8243        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
8244        // the caret can rest on — is at 9.
8245        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
8246        d.caret = 30; // inside the note's body
8247        let f = d
8248            .footnote_definition_at_caret()
8249            .expect("the caret stands in a definition");
8250        assert_eq!(f.label, "1");
8251        assert_eq!(f.offset, Some(9));
8252        assert_eq!(&d.source[7..11], "[^1]");
8253    }
8254
8255    #[test]
8256    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
8257        // The two legs have to meet: wherever `footnote_at` sends the caret, the
8258        // definition query must answer for — otherwise arriving at a note leaves
8259        // the reader somewhere the way back isn't offered.
8260        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
8261        let mut d = doc_with("fn_def_marker", src);
8262        let landed = d.footnote_at(9).unwrap().offset.unwrap();
8263        assert_eq!(
8264            d.footnote_definition_at(landed).and_then(|f| f.offset),
8265            Some(9),
8266            "the note a reference sends you to offers the way back"
8267        );
8268    }
8269
8270    #[test]
8271    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
8272        // The two queries answer for disjoint places, which is what lets one
8273        // gesture mean "down to the note" in one and "back up" in the other
8274        // without either having to remember which way the reader is going.
8275        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
8276        d.caret = 2; // prose
8277        assert_eq!(d.footnote_definition_at_caret(), None);
8278        d.caret = 9; // the reference
8279        assert_eq!(d.footnote_definition_at_caret(), None);
8280        assert!(d.footnote_at_caret().is_some(), "which is the reference's own query");
8281    }
8282
8283    #[test]
8284    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
8285        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
8286        // note", which is false and leaves a frontend unable to explain why the
8287        // way back is missing.
8288        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
8289        let mut d = doc_with("fn_def_orphan", src);
8290        d.caret = src.find("orphan").unwrap();
8291        let f = d
8292            .footnote_definition_at_caret()
8293            .expect("an orphan is still a definition");
8294        assert_eq!(f.label, "2");
8295        assert_eq!(f.offset, None);
8296    }
8297
8298    #[test]
8299    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
8300        // One label, cited twice. The first is where the reader most likely came
8301        // from, and the only answer that doesn't depend on how they got here.
8302        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
8303        let mut d = doc_with("fn_def_repeat", src);
8304        d.caret = src.find("the note").unwrap();
8305        let f = d.footnote_definition_at_caret().expect("a definition");
8306        assert_eq!(f.offset, Some(5), "the first `[^a]`'s label, not the second's");
8307        assert_eq!(&src[3..7], "[^a]");
8308    }
8309
8310    #[test]
8311    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
8312        // Down and back up, each leg found from the document rather than from a
8313        // memory of the other — so it still works for a reader who scrolled to
8314        // the notes instead of jumping there.
8315        //
8316        // `place_caret` rather than assigning `caret`, because that is what a
8317        // frontend calls: it snaps to a real caret stop, and a jump that lands
8318        // on a byte the caret can't rest on would arrive somewhere the return
8319        // leg no longer answers for. `build_map` first, since snapping is a
8320        // no-op until the map exists — which is exactly how this went unnoticed
8321        // when the offsets pointed at the `[^` markers.
8322        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
8323        d.build_map(None);
8324        d.place_caret(9, false);
8325        let down = d.footnote_at_caret().expect("a reference").offset.expect("a note");
8326        d.place_caret(down, false);
8327        let up = d
8328            .footnote_definition_at_caret()
8329            .expect("a definition")
8330            .offset
8331            .expect("a reference");
8332        d.place_caret(up, false);
8333        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
8334        assert_eq!(
8335            d.footnote_at_caret().expect("back on the reference").label,
8336            "1"
8337        );
8338    }
8339
8340    #[test]
8341    fn insert_link_hands_the_destination_to_twig_raw() {
8342        // Escaping is twig's, and format-specific: Markdown ends a destination
8343        // at the first space and needs the `<…>` form, where djot would read
8344        // those angle brackets as part of the URL.
8345        let mut d = doc_with("link_space", "word\n");
8346        d.anchor = Some(0);
8347        d.caret = 4;
8348        d.insert_link("a b");
8349        assert_eq!(d.source, "[word](<a b>)\n");
8350    }
8351
8352    #[test]
8353    fn insert_link_reports_a_destination_no_format_can_carry() {
8354        let mut d = doc_with("link_bad", "word\n");
8355        d.anchor = Some(0);
8356        d.caret = 4;
8357        d.insert_link("a\nb");
8358        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
8359        assert!(d.status.is_some(), "InvalidArgument should reach the status line");
8360        assert!(!d.dirty);
8361    }
8362
8363    #[test]
8364    fn insert_link_works_in_wysiwyg_view() {
8365        let mut d = wysiwyg_doc("link_wys", "word here\n");
8366        d.anchor = Some(0);
8367        d.caret = 4;
8368        d.insert_link("http://x.dev");
8369        assert_eq!(d.source, "[word](http://x.dev) here\n");
8370        assert_eq!(d.selected_text(), Some("word"));
8371        // The map the caret has to keep riding is rebuilt each frame; motion
8372        // over the fresh one must still land on a real stop (the debug_assert).
8373        d.build_visual(80);
8374        d.move_right(false);
8375        d.move_left(false);
8376    }
8377
8378    #[test]
8379    fn click_maps_a_row_col_to_a_byte_offset() {
8380        let mut d = doc_with("click", "ab\ncd\n");
8381        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
8382        assert_eq!(d.caret, 4);
8383    }
8384
8385    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
8386    // stop just as the `(row, col)` click path does, so the caret can never come
8387    // to rest in the blank gap between two paragraphs — where it would draw in one
8388    // place and type in another.
8389    #[test]
8390    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
8391        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
8392        // caret stop (stops are 0,1,3,4).
8393        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
8394        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
8395        d.place_caret(2, false);
8396        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8397        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
8398    }
8399
8400    #[test]
8401    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
8402        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
8403        d.place_caret(0, false); // anchor at the start of "A"
8404        d.place_caret(2, true); // drag into the gap
8405        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8406        let (s, e) = d.selection().expect("a selection");
8407        assert!(d.vmap.is_stop(s) && d.vmap.is_stop(e), "selection {s}..{e} off a stop");
8408    }
8409
8410    #[test]
8411    fn place_caret_on_a_real_stop_is_left_untouched() {
8412        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
8413        d.place_caret(3, false); // the start of "B" — a genuine stop
8414        assert_eq!(d.caret, 3);
8415    }
8416
8417    // An *empty paragraph* (two blank lines, an intentional blank line the user
8418    // opened) is a real caret stop, unlike the gap — a click into it must stay.
8419    #[test]
8420    fn place_caret_rests_in_an_empty_paragraph() {
8421        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
8422        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
8423        assert!(d.vmap.is_stop(empty));
8424        d.place_caret(empty, false);
8425        assert_eq!(d.caret, empty);
8426    }
8427
8428    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
8429        doc_in(View::Wysiwyg, name, body)
8430    }
8431
8432    /// How many list items the source actually parses into — the check that a
8433    /// marker Leaf wrote is a marker the format agrees is one.
8434    fn list_items(doc: &mut Doc) -> usize {
8435        doc.editor
8436            .nodes()
8437            .unwrap()
8438            .iter()
8439            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
8440            .count()
8441    }
8442
8443    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
8444    /// incremental (`build_spliced` / `build_cached`) path must always match.
8445    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
8446        reference_map_revealing(source, None)
8447    }
8448
8449    /// [`reference_map`] with a reveal line — the ground truth for the
8450    /// `MarkupMode::Full` builds, where the map is a function of the caret's
8451    /// line as well as the text.
8452    fn reference_map_revealing(
8453        source: &str,
8454        reveal: Option<Range<usize>>,
8455    ) -> crate::wysiwyg::VisualMap {
8456        // The same parse `Doc` uses. With twig's plain defaults instead, the two
8457        // sides disagree on what the *document* is before the renderer is even
8458        // reached — a bare `:word` is a text directive to one and prose to the
8459        // other — and the mismatch reads as a splice bug that isn't one.
8460        let mut ed =
8461            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
8462        let nodes = ed.nodes().unwrap();
8463        crate::wysiwyg::build(&nodes, source, None, false, &std::collections::HashMap::new(), reveal)
8464    }
8465
8466    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
8467        if a.rows.len() != b.rows.len() {
8468            return true;
8469        }
8470        for (ra, rb) in a.rows.iter().zip(&b.rows) {
8471            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
8472                return true;
8473            }
8474            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
8475                if ga.ch != gb.ch || ga.src != gb.src {
8476                    return true;
8477                }
8478            }
8479        }
8480        false
8481    }
8482
8483    #[test]
8484    fn incremental_build_matches_a_fresh_build_across_edits() {
8485        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
8486        // fast path, gated on twig's `dirty_range`) or falls back to
8487        // `build_cached`. After each edit the map must be byte-identical to a
8488        // from-scratch build — this is the correctness net under the splice.
8489        let docs = [
8490            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
8491            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
8492            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
8493            // A footnote definition is a root beside `doc`, merged back into the
8494            // top-level list by `wysiwyg::top_blocks`. The random edits below
8495            // make and unmake definitions as they go (a deleted `:` turns one
8496            // back into a paragraph, and vice versa), which is exactly the
8497            // structural churn the splice path has to notice and bail out of.
8498            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
8499        ];
8500        // A deterministic mix: mostly single characters (which stay inside one
8501        // block → splice), plus edits that reshape structure (a paragraph break,
8502        // a heading marker, a code fence → fallback), so both paths are exercised.
8503        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
8504        for src in docs {
8505            let mut d = wysiwyg_doc("diff", src);
8506            d.build_visual_unwrapped();
8507            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
8508
8509            for step in 0..60usize {
8510                let len = d.source.len();
8511                let raw = (step * 13 + 5) % (len + 1);
8512                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8513                let pre = d.source.clone();
8514                let action;
8515                if step % 3 == 0 && pos < len {
8516                    let end = (pos + 1..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8517                    action = format!("delete [{pos},{end})");
8518                    d.edit(pos, end, "");
8519                } else {
8520                    let ins = inserts[step % inserts.len()];
8521                    action = format!("insert {ins:?} @ {pos}");
8522                    d.edit(pos, pos, ins);
8523                }
8524                d.build_visual_unwrapped();
8525                if maps_differ(&d.vmap, &reference_map(&d.source)) {
8526                    panic!(
8527                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
8528                        d.source
8529                    );
8530                }
8531            }
8532        }
8533    }
8534
8535    #[test]
8536    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
8537        // The same correctness net as `incremental_build_matches_a_fresh_build_
8538        // across_edits`, under `MarkupMode::Full` — where the map depends on
8539        // the caret's *line* as well as the text, so the two caches have a new
8540        // way to be wrong. Both are exercised: the block cache can hand back
8541        // rows built for a line that is no longer the revealed one, and the
8542        // splice path can reuse a suffix that still has yesterday's line raw.
8543        //
8544        // Caret motion is interleaved with the edits deliberately, because a
8545        // caret that only ever moved with the edit would never cross a line
8546        // without also dirtying it — the case where a stale reveal survives.
8547        let docs = [
8548            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
8549            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
8550        ];
8551        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
8552        for src in docs {
8553            let mut d = wysiwyg_doc("reveal_diff", src);
8554            d.set_markup_mode(MarkupMode::Full);
8555
8556            for step in 0..60usize {
8557                let len = d.source.len();
8558                let raw = (step * 13 + 5) % (len + 1);
8559                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8560                let pre = d.source.clone();
8561                let action;
8562                if step % 3 == 0 && pos < len {
8563                    let end = (pos + 1..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8564                    action = format!("delete [{pos},{end})");
8565                    d.edit(pos, end, "");
8566                } else {
8567                    let ins = inserts[step % inserts.len()];
8568                    action = format!("insert {ins:?} @ {pos}");
8569                    d.edit(pos, pos, ins);
8570                }
8571                // Walk the caret somewhere else in the document, independently
8572                // of where the edit landed.
8573                let want = (step * 29 + 11) % (d.source.len() + 1);
8574                d.caret = (want..=d.source.len())
8575                    .find(|&i| d.source.is_char_boundary(i))
8576                    .unwrap();
8577                d.build_visual_unwrapped();
8578
8579                let want = reference_map_revealing(&d.source, d.reveal_line());
8580                if maps_differ(&d.vmap, &want) {
8581                    panic!(
8582                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
8583                        d.caret, d.source
8584                    );
8585                }
8586            }
8587        }
8588    }
8589
8590    #[test]
8591    fn caret_motion_across_lines_rebuilds_only_under_full() {
8592        // The cache-key change has to earn its keep in both directions: `Full`
8593        // must rebuild when the caret changes line (or the reveal would never
8594        // move), and the hidden modes must *not* (or every arrow key would pay
8595        // for a feature they don't use). The existing `cache_motion` test pins
8596        // the second for the default mode; this pins the pair against a mode
8597        // change alone.
8598        let body = "*one* here\n\n*two* there\n";
8599
8600        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
8601        full.set_markup_mode(MarkupMode::Full);
8602        caret_at(&mut full, "one");
8603        let before = full.revision();
8604        caret_at(&mut full, "two");
8605        assert_eq!(full.revision(), before, "motion is not an edit");
8606        assert!(
8607            drawn_rows(&full).iter().any(|r| r == "*two* there"),
8608            "the map followed the caret: {:?}",
8609            drawn_rows(&full)
8610        );
8611
8612        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
8613        caret_at(&mut hidden, "one");
8614        let key = hidden.vmap_key.clone();
8615        caret_at(&mut hidden, "two");
8616        assert_eq!(hidden.vmap_key, key, "a hidden mode rebuilds nothing on motion");
8617    }
8618
8619    #[test]
8620    fn wysiwyg_down_crosses_a_paragraph_boundary() {
8621        // Regression: the blank separator row used to share the previous
8622        // paragraph's end offset, so Down got pinned at the boundary (while Up
8623        // still crossed). Both directions must step through it symmetrically.
8624        //
8625        // It's now stepped *over* rather than onto: the blank line between two
8626        // paragraphs is the boundary being drawn, not a line of the document, so
8627        // one press of Down crosses it. The goal column survives the crossing —
8628        // col 3 at the end of "abc" is col 3 at the end of "def".
8629        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
8630        d.caret = 3; // end of "abc" (row 0)
8631        d.move_down(false);
8632        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
8633        assert_eq!(d.caret, 8); // end of "def", col 3 kept
8634        d.move_up(false);
8635        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
8636        assert_eq!(d.caret, 3);
8637    }
8638
8639    #[test]
8640    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
8641        // The second Up and the second Down here run off the ends of the
8642        // document, which is no longer a place a press is swallowed: they carry
8643        // the caret to the start and the end of the text. The claim in the
8644        // middle — that a Down retraces the Up that crossed the paragraph gap —
8645        // is the one this test is for, and it is asserted where it is made.
8646        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
8647        d.caret = 5; // start of "def"
8648        let start = d.caret_pos();
8649        d.move_up(false);
8650        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
8651        d.move_up(false);
8652        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
8653        d.move_down(false);
8654        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
8655        d.move_down(false);
8656        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
8657    }
8658
8659    #[test]
8660    fn wysiwyg_new_paragraph_shows_before_typing() {
8661        // Regression: two Enters at the end of a paragraph produced trailing
8662        // newlines with no AST node, so the caret appeared stuck on the old line
8663        // until a character was typed. It must ride down onto the new line now.
8664        let mut d = doc_with("wys_newpara", "abc\n");
8665        d.view = View::Wysiwyg;
8666        d.caret = 3;
8667        d.insert("\n");
8668        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
8669        assert_eq!(d.source, "abc\n\n\n");
8670        d.build_visual(80);
8671        let (row, _) = d.caret_pos();
8672        assert!(row >= 2, "caret should have moved down to the new line, got row {row}");
8673        assert!(d.vmap.num_rows() >= 3, "the blank lines should render as rows");
8674    }
8675
8676    #[test]
8677    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
8678        // The reported bug: Enter at the end of a paragraph that has another
8679        // paragraph below put the caret at the *start of the next paragraph* —
8680        // the empty paragraph it opened had no row, so the caret snapped onto
8681        // "World". It must now sit on its own empty line, with a blank spacer
8682        // above it (the paragraph gap).
8683        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
8684        d.caret = 5; // end of "Hello"
8685        d.newline();
8686        d.build_visual(80);
8687        let (row, col) = d.caret_pos();
8688        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
8689        assert_eq!(d.vmap.row_width(row), 0, "caret's row must be empty, not 'World'");
8690        assert!(row >= 2, "a blank spacer row should sit above the caret, got row {row}");
8691        // The row above the caret is a real (empty) gap, and "Hello" stays put.
8692        assert_eq!(d.vmap.row_width(row - 1), 0, "the row above the caret is a gap");
8693        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8694        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
8695    }
8696
8697    #[test]
8698    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
8699        // At the document end a single Enter must also show the paragraph gap —
8700        // a blank spacer row above the caret — so the layout already matches how
8701        // it will look once the new paragraph has text.
8702        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
8703        d.caret = 5; // end of "Hello", no trailing newline
8704        d.newline(); // source becomes "Hello\n\n"
8705        d.build_visual(80);
8706        let (row, col) = d.caret_pos();
8707        assert_eq!(col, 0);
8708        assert!(row >= 2, "caret should sit below a blank spacer, got row {row}");
8709        assert_eq!(d.vmap.row_width(row - 1), 0, "the row above the caret is a gap");
8710    }
8711
8712    #[test]
8713    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
8714        // The spacer is view-only: typing the new paragraph must not reflow the
8715        // caret onto a different row — the transient view already matched the
8716        // settled one.
8717        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
8718        d.caret = 5;
8719        d.newline();
8720        d.build_visual(80);
8721        let before = d.caret_pos();
8722        d.insert("New");
8723        d.build_visual(80);
8724        let after = d.caret_pos();
8725        assert_eq!(
8726            after.0, before.0,
8727            "typing must not move the caret to another row ({before:?} -> {after:?})"
8728        );
8729    }
8730
8731    #[test]
8732    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
8733        let fm = "---\ntitle: hi\n---\n";
8734        let body = format!("{fm}# leaf\n\nbody\n");
8735        let mut d = wysiwyg_doc("wys_fm", &body);
8736        // Opening lifts the caret out of the now-hidden frontmatter.
8737        assert_eq!(d.caret, fm.len(), "caret should start at the first real block");
8738        // Left at the content start can't step back into frontmatter.
8739        d.move_left(false);
8740        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
8741        // Doc-start lands on the content floor, not offset 0.
8742        d.move_doc_start(false);
8743        assert_eq!(d.caret, fm.len());
8744        // Select-all + copy never include the frontmatter bytes.
8745        d.select_all();
8746        let sel = d.selected_text().unwrap().to_string();
8747        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
8748        assert!(sel.starts_with("# leaf"), "selection should begin at content: {sel:?}");
8749    }
8750
8751    #[test]
8752    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
8753        // Backspace deletes `prev_boundary..caret` directly; at the first real
8754        // block that boundary is inside the hidden frontmatter, so it must be a
8755        // no-op rather than eating the closing `---`.
8756        let fm = "---\ntitle: hi\n---\n";
8757        let body = format!("{fm}leaf\n");
8758        let mut d = wysiwyg_doc("wys_fm_bs", &body);
8759        assert_eq!(d.caret, fm.len());
8760        d.backspace();
8761        assert_eq!(d.source, body, "backspace must not touch frontmatter");
8762        d.delete_word_back();
8763        assert_eq!(d.source, body, "word-delete must not touch frontmatter either");
8764    }
8765
8766    #[test]
8767    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
8768        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
8769        // fenced div, really, since core parses these on for every document
8770        // now (`parse_extensions`). The container is a `directive` node, an
8771        // `is_block_container` kind like `block_quote`, so the caret works
8772        // inside its child paragraph exactly as it would inside a quote: typing
8773        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
8774        // untouched.
8775        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
8776        let mut d = wysiwyg_doc("wys_vis", body);
8777        d.caret = body.find("hello").unwrap() + "hello".len();
8778        d.insert("!");
8779        assert_eq!(
8780            d.source,
8781            ":::vis{.public .family}\nhello!\n:::\nafter\n",
8782            "typing inside the block edits its content in place"
8783        );
8784        assert!(d.source.contains(":::vis{.public .family}"), "opening fence survives");
8785        assert!(d.source.contains(":::\nafter"), "closing fence survives");
8786    }
8787
8788    #[test]
8789    fn source_view_still_reaches_frontmatter() {
8790        // The metadata is only *hidden*, never lost: the source view edits and
8791        // selects it in full, and it's always preserved on save.
8792        let fm = "---\ntitle: hi\n---\n";
8793        let body = format!("{fm}# leaf\n");
8794        let mut d = doc_with("src_fm", &body);
8795        d.select_all();
8796        let sel = d.selected_text().unwrap();
8797        assert!(sel.contains("title"), "source view should select everything");
8798        d.move_doc_start(false);
8799        assert_eq!(d.caret, 0, "source view can reach offset 0");
8800    }
8801
8802    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
8803
8804    #[test]
8805    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
8806        // The border and padding between two cells all share one source offset,
8807        // so a column-stepping caret would sit on `│` and then stall there
8808        // forever. Right must step: end of "Name" -> start of "Qty".
8809        let mut d = wysiwyg_doc("tbl_right", TABLE);
8810        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
8811        d.move_right(false);
8812        assert_eq!(d.caret, TABLE.find("Qty").unwrap(), "should land in the next cell");
8813        let (r, c) = d.caret_pos();
8814        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
8815    }
8816
8817    #[test]
8818    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
8819        let mut d = wysiwyg_doc("tbl_left", TABLE);
8820        d.caret = TABLE.find("Qty").unwrap();
8821        d.move_left(false);
8822        assert_eq!(d.caret, TABLE.find("Name").unwrap() + 4, "end of the previous cell");
8823    }
8824
8825    #[test]
8826    fn wysiwyg_down_steps_over_a_table_rule() {
8827        // Between the header and the first body row sits a `├───┼───┤` rule.
8828        // It's drawn but holds no caret, so one Down must reach "Pear".
8829        let mut d = wysiwyg_doc("tbl_down", TABLE);
8830        d.caret = TABLE.find("Name").unwrap();
8831        d.move_down(false);
8832        assert_eq!(d.caret, TABLE.find("Pear").unwrap(), "one Down reaches the body row");
8833        d.move_down(false);
8834        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
8835    }
8836
8837    #[test]
8838    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
8839        let mut d = wysiwyg_doc("tbl_tab", TABLE);
8840        d.caret = TABLE.find("Name").unwrap();
8841        // A hop lands with the destination cell's whole content selected, the
8842        // caret at its end — so typing replaces the cell like a form field.
8843        assert!(d.cell_hop(true));
8844        assert_eq!(d.selected_text(), Some("Qty"), "the target cell comes up selected");
8845        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
8846        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
8847        assert_eq!(d.selected_text(), Some("Pear"));
8848        assert!(d.cell_hop(false));
8849        assert_eq!(d.selected_text(), Some("Qty"));
8850    }
8851
8852    #[test]
8853    fn tab_outside_a_table_is_not_a_cell_hop() {
8854        // `cell_hop` reports false so the frontend can indent as usual.
8855        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
8856        d.caret = 4;
8857        assert!(!d.cell_hop(true));
8858        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
8859    }
8860
8861    #[test]
8862    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
8863        let mut d = wysiwyg_doc("tbl_edge", TABLE);
8864        d.caret = TABLE.rfind("12").unwrap(); // the final cell
8865        assert!(!d.cell_hop(true), "no cell after the last one");
8866        d.caret = TABLE.find("Name").unwrap();
8867        assert!(!d.cell_hop(false), "no cell before the first one");
8868    }
8869
8870    #[test]
8871    fn wysiwyg_vertical_cell_motion_holds_the_column() {
8872        // Down/Up step to the cell above/below in the *same column*, not back to
8873        // the top-left the way a naive row/col motion over the picture would.
8874        let mut d = wysiwyg_doc("tbl_vert", TABLE);
8875        d.caret = TABLE.find("Qty").unwrap();
8876        // Each vertical hop selects the destination cell, holding the column.
8877        assert!(d.cell_move_vertical(true));
8878        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
8879        assert!(d.cell_move_vertical(true));
8880        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
8881        assert!(!d.cell_move_vertical(true), "no row below the last");
8882        assert!(d.cell_move_vertical(false));
8883        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
8884        assert!(d.cell_move_vertical(false));
8885        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
8886        assert!(!d.cell_move_vertical(false), "no row above the header");
8887    }
8888
8889    #[test]
8890    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
8891        let mut d = wysiwyg_doc("tbl_grow", TABLE);
8892        d.caret = TABLE.rfind("12").unwrap();
8893        let rows_before = d.source.matches('\n').count();
8894        assert!(d.cell_tab(true), "acts as a table key");
8895        assert_eq!(
8896            d.source.matches('\n').count(),
8897            rows_before + 1,
8898            "a fresh row was appended"
8899        );
8900        assert!(d.caret_in_table(), "the caret entered the new row");
8901        // The caret sits in the new row's first cell — past the old last cell.
8902        assert!(d.caret > TABLE.rfind("12").unwrap());
8903    }
8904
8905    #[test]
8906    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
8907        let mut d = wysiwyg_doc("tbl_ret", TABLE);
8908        d.caret = TABLE.find("Name").unwrap();
8909        assert!(d.cell_return(), "acts as a table key");
8910        assert_eq!(d.selected_text(), Some("Pear"), "Return drops one cell, selecting it");
8911        // From the last row, Return appends a row and enters it.
8912        d.caret = TABLE.rfind("Fig").unwrap();
8913        let rows_before = d.source.matches('\n').count();
8914        assert!(d.cell_return());
8915        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
8916        assert!(d.caret_in_table());
8917    }
8918
8919    #[test]
8920    fn return_and_tab_outside_a_table_decline() {
8921        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
8922        d.caret = 4;
8923        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
8924        assert!(!d.cell_tab(true), "no table: the frontend indents");
8925        assert!(!d.cell_line_break(), "no table: the frontend breaks the line");
8926    }
8927
8928    #[test]
8929    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
8930        let mut d = wysiwyg_doc("tbl_break", TABLE);
8931        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
8932        assert!(d.cell_line_break(), "acts as a table key");
8933        assert!(d.source.contains("Pear<br>"), "spelled as an inline <br>: {}", d.source);
8934        assert!(d.caret_in_table(), "still in the cell, past the break");
8935        // The break renders as a real line: the "Pear" cell now draws two lines,
8936        // so the table's picture is one row taller than a single-line table.
8937        d.build_visual(80);
8938        let table = &d.vmap.tables[0];
8939        let cell = &table.grid[1].cells[0]; // first body row, first column
8940        assert!(
8941            cell.glyphs.iter().any(|g| g.ch == '\n'),
8942            "the cell carries the break as a newline glyph for the frontend to split"
8943        );
8944    }
8945
8946    #[test]
8947    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
8948        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
8949        // back as structure — the whole point of routing through insert_line_break
8950        // instead of splicing raw `<br>` bytes.
8951        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
8952        d.caret = TABLE.find("Pear").unwrap() + 4;
8953        assert!(d.cell_line_break());
8954        let kinds: Vec<Kind> = d.editor.nodes().unwrap().iter().map(|n| n.kind.clone()).collect();
8955        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
8956        assert!(!kinds.contains(&Kind::RawInline), "still raw HTML: {kinds:?}");
8957    }
8958
8959    #[test]
8960    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
8961        // The `<br>` draws as one newline glyph, so Backspace over it must take
8962        // all four bytes — a one-byte delete would strand a visible `<br` in the
8963        // cell (the reported bug).
8964        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
8965        d.caret = TABLE.find("Pear").unwrap() + 4;
8966        assert!(d.cell_line_break());
8967        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
8968        d.backspace(); // caret sits just past the break
8969        assert!(!d.source.contains("<br"), "no half-deleted <br left: {}", d.source);
8970        assert!(d.source.contains("| Pear |"), "the cell is back to one line: {}", d.source);
8971    }
8972
8973    #[test]
8974    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
8975        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
8976        d.caret = TABLE.find("Pear").unwrap() + 4;
8977        assert!(d.cell_line_break());
8978        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
8979        d.delete_forward();
8980        assert!(!d.source.contains("<br"), "no half-deleted <br: {}", d.source);
8981        assert!(d.source.contains("| Pear |"), "cell back to one line: {}", d.source);
8982    }
8983
8984    #[test]
8985    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
8986        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
8987        // still consumed (a real newline would split the one-line row), but the
8988        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
8989        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
8990        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8991        d.caret = src.find("Pear").unwrap() + 4;
8992        assert!(d.caret_in_table(), "caret should be inside the djot table");
8993        assert!(d.cell_line_break(), "the key is consumed, not passed to the frontend");
8994        assert_eq!(d.source, src, "the djot cell is left untouched");
8995        assert!(!d.source.contains("<br>"), "no non-idiomatic <br> spliced into djot");
8996        assert!(d.status.is_some(), "the refusal is surfaced on the status line");
8997    }
8998
8999    #[test]
9000    fn typing_in_a_cell_edits_that_cell() {
9001        // Editing comes free once offsets map correctly: the caret is a source
9002        // offset, so a normal splice lands inside the pipe table.
9003        let mut d = wysiwyg_doc("tbl_type", TABLE);
9004        d.caret = TABLE.find("Pear").unwrap() + 4;
9005        d.insert("s");
9006        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
9007    }
9008
9009    #[test]
9010    fn motion_and_delete_treat_an_emoji_as_one_character() {
9011        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
9012        // bytes, several codepoints. Right-arrow must clear it in one step, and
9013        // backspace must remove the whole cluster, not a stray joiner.
9014        let family = "👨‍👩‍👧";
9015        let mut d = doc_with("emoji", &format!("a{family}b\n"));
9016        d.caret = 1; // just after 'a', before the emoji
9017        d.move_right(false);
9018        assert_eq!(d.caret, 1 + family.len(), "one step clears the whole cluster");
9019        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
9020
9021        d.backspace(); // delete the emoji as a unit
9022        assert_eq!(d.source, "ab\n");
9023        assert_eq!(d.caret, 1);
9024    }
9025
9026    #[test]
9027    fn motion_handles_a_combining_accent_as_one_character() {
9028        // "e" + U+0301 (combining acute) renders as one é.
9029        let mut d = doc_with("combining", "e\u{0301}x\n");
9030        d.caret = 0;
9031        d.move_right(false);
9032        assert_eq!(d.caret, "e\u{0301}".len(), "steps past base + combining mark");
9033    }
9034
9035    #[test]
9036    fn undo_then_redo_round_trips_an_edit() {
9037        let mut d = doc_with("undo", "hello\n");
9038        d.caret = 5;
9039        d.insert("!");
9040        assert_eq!(d.source, "hello!\n");
9041        d.undo();
9042        assert_eq!(d.source, "hello\n");
9043        assert_eq!(d.caret, 5, "undo restores the caret");
9044        d.redo();
9045        assert_eq!(d.source, "hello!\n");
9046    }
9047
9048    #[test]
9049    fn a_run_of_typing_undoes_as_one_step() {
9050        let mut d = doc_with("coalesce", "\n");
9051        d.caret = 0;
9052        d.insert("a");
9053        d.insert("b");
9054        d.insert("c");
9055        assert_eq!(d.source, "abc\n");
9056        d.undo(); // the whole typed run, not just "c"
9057        assert_eq!(d.source, "\n");
9058        d.undo(); // nothing left — the run was one step
9059        assert_eq!(d.source, "\n");
9060        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9061    }
9062
9063    // ── IME composition ──────────────────────────────────────────────────────
9064
9065    #[test]
9066    fn a_composition_run_undoes_as_one_step() {
9067        let mut d = doc_with("compose", "\n");
9068        d.caret = 0;
9069        // What an IME does: each step replaces the last one's provisional bytes.
9070        d.edit_composing(0, 0, "k");
9071        d.edit_composing(0, 1, "か");
9072        d.edit_composing(0, 3, "かん");
9073        d.edit_composing(0, 6, "感"); // the commit
9074        d.end_composition();
9075        assert_eq!(d.source, "感\n");
9076        d.undo(); // the whole composition, not its last keystroke
9077        assert_eq!(d.source, "\n");
9078        assert_eq!(d.status.as_deref(), None, "the run was a single step");
9079    }
9080
9081    #[test]
9082    fn two_compositions_are_two_undo_steps() {
9083        let mut d = doc_with("compose_two", "\n");
9084        d.caret = 0;
9085        d.edit_composing(0, 0, "か");
9086        d.edit_composing(0, 3, "蚊");
9087        d.end_composition();
9088        d.edit_composing(3, 3, "き");
9089        d.edit_composing(3, 6, "木");
9090        d.end_composition();
9091        assert_eq!(d.source, "蚊木\n");
9092        d.undo();
9093        assert_eq!(d.source, "蚊\n", "only the second composition");
9094        d.undo();
9095        assert_eq!(d.source, "\n");
9096    }
9097
9098    #[test]
9099    fn a_composition_does_not_fold_into_the_typing_around_it() {
9100        let mut d = doc_with("compose_typing", "\n");
9101        d.caret = 0;
9102        d.insert("a");
9103        d.insert("b");
9104        d.edit_composing(2, 2, "か");
9105        d.edit_composing(2, 5, "蚊");
9106        d.end_composition();
9107        d.insert("c");
9108        assert_eq!(d.source, "ab蚊c\n");
9109        d.undo();
9110        assert_eq!(d.source, "ab蚊\n");
9111        d.undo();
9112        assert_eq!(d.source, "ab\n");
9113        d.undo();
9114        assert_eq!(d.source, "\n");
9115    }
9116
9117    #[test]
9118    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
9119        let mut d = doc_with("compose_spurious", "\n");
9120        d.caret = 0;
9121        d.insert("a");
9122        d.end_composition(); // an IME unmarking unprompted
9123        d.insert("b");
9124        assert_eq!(d.source, "ab\n");
9125        d.undo();
9126        assert_eq!(d.source, "\n", "still one typed run");
9127    }
9128
9129    // ── the clipboard's rich flavor ──────────────────────────────────────────
9130
9131    #[test]
9132    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
9133        let mut d = doc_with("sel_inline", "a **bold** c\n");
9134        d.anchor = Some(2);
9135        d.caret = 10; // `**bold**`, inside the paragraph
9136        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
9137    }
9138
9139    #[test]
9140    fn a_whole_block_selection_keeps_its_paragraph() {
9141        let mut d = doc_with("sel_block", "a **bold** c\n");
9142        d.anchor = Some(0);
9143        d.caret = 12; // the entire paragraph
9144        assert_eq!(
9145            d.selection_html().as_deref(),
9146            Some("<p>a <strong>bold</strong> c</p>")
9147        );
9148    }
9149
9150    #[test]
9151    fn a_multi_block_selection_keeps_its_structure() {
9152        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
9153        d.select_all();
9154        let html = d.selection_html().expect("renders");
9155        assert!(html.contains("<p>para</p>"), "{html:?}");
9156        assert!(html.contains("<li>one</li>"), "{html:?}");
9157    }
9158
9159    #[test]
9160    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
9161        // The fragment `Head` is a paragraph standalone; the *document* says it
9162        // sits inside one block, so the wrapper is an artifact either way.
9163        let mut d = doc_with("sel_heading", "# Head line\n");
9164        d.anchor = Some(2);
9165        d.caret = 6;
9166        assert_eq!(d.selection_html().as_deref(), Some("Head"));
9167    }
9168
9169    #[test]
9170    fn no_selection_publishes_no_html() {
9171        let mut d = doc_with("sel_none", "a b\n");
9172        d.caret = 1;
9173        assert_eq!(d.selection_html(), None);
9174    }
9175
9176    #[test]
9177    fn pasting_html_converts_it_and_is_one_undo_step() {
9178        let mut d = doc_with("paste_html", "x\n");
9179        d.caret = 1;
9180        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
9181        assert_eq!(d.source, "xa **b** c\n");
9182        d.undo();
9183        assert_eq!(d.source, "x\n", "the whole paste, in one step");
9184    }
9185
9186    #[test]
9187    fn pasting_html_replaces_the_selection() {
9188        let mut d = doc_with("paste_html_sel", "keep drop\n");
9189        d.anchor = Some(5);
9190        d.caret = 9;
9191        assert!(d.paste_html("<em>new</em>"));
9192        assert_eq!(d.source, "keep *new*\n");
9193    }
9194
9195    #[test]
9196    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
9197        let mut d = doc_with("paste_html_bad", "x\n");
9198        d.caret = 1;
9199        // twig builds no table from HTML; raw `<table>` in prose is worse than
9200        // the plain flavor the caller still holds.
9201        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
9202        assert_eq!(d.source, "x\n", "declined edits nothing");
9203    }
9204
9205    #[test]
9206    fn copy_then_paste_round_trips_through_the_html_flavor() {
9207        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
9208        d.select_all();
9209        let html = d.selection_html().expect("renders");
9210        let mut into = doc_with("clip_round_dst", "\n");
9211        into.caret = 0;
9212        assert!(into.paste_html(&html));
9213        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
9214    }
9215
9216    #[test]
9217    fn moving_the_caret_starts_a_new_undo_group() {
9218        let mut d = doc_with("break", "\n");
9219        d.caret = 0;
9220        d.insert("a");
9221        d.insert("b"); // "ab\n", caret at 2
9222        d.move_left(false); // breaks the run
9223        d.insert("X"); // "aXb\n"
9224        assert_eq!(d.source, "aXb\n");
9225        d.undo();
9226        assert_eq!(d.source, "ab\n", "first undo removes only the post-move insert");
9227        d.undo();
9228        assert_eq!(d.source, "\n", "second undo removes the earlier run");
9229    }
9230
9231    #[test]
9232    fn undo_reverses_a_format_toggle() {
9233        let mut d = doc_with("fmt_undo", "a word b\n");
9234        d.anchor = Some(2);
9235        d.caret = 6;
9236        d.toggle(InlineKind::Strong);
9237        assert_eq!(d.source, "a **word** b\n");
9238        d.undo();
9239        assert_eq!(d.source, "a word b\n");
9240    }
9241
9242    #[test]
9243    fn undo_back_to_the_saved_state_clears_dirty() {
9244        let mut d = doc_with("dirty_undo", "hello\n");
9245        assert!(!d.dirty);
9246        d.caret = 5;
9247        d.insert("!");
9248        assert!(d.dirty);
9249        d.undo();
9250        assert!(!d.dirty, "undoing to the saved source is not a modification");
9251    }
9252
9253    #[test]
9254    fn a_new_edit_invalidates_redo() {
9255        let mut d = doc_with("redo_inv", "\n");
9256        d.caret = 0;
9257        d.insert("a");
9258        d.undo();
9259        d.insert("b"); // diverges — the redo of "a" is now gone
9260        d.redo();
9261        assert_eq!(d.source, "b\n");
9262    }
9263
9264    #[test]
9265    fn undo_on_empty_history_is_a_no_op() {
9266        let mut d = doc_with("undo_empty", "hi\n");
9267        d.undo();
9268        assert_eq!(d.source, "hi\n");
9269        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9270    }
9271
9272    #[test]
9273    fn a_one_character_paste_is_its_own_undo_step() {
9274        for view in [View::Source, View::Wysiwyg] {
9275            let mut d = doc_in(view, "paste_step", "ab\n");
9276            d.caret = 0;
9277            d.insert("x");
9278            d.insert("y"); // a run of typing
9279            d.paste("z"); // one character, but pasted — not part of that run
9280            assert_eq!(d.source, "xyzab\n");
9281            d.undo();
9282            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
9283            assert_eq!(d.caret, 2, "and hands back the caret it found");
9284            d.undo();
9285            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
9286        }
9287    }
9288
9289    #[test]
9290    fn the_same_character_typed_still_joins_the_run() {
9291        // The other half of the pair: `z` is a keystroke here and a paste above,
9292        // and the two undo differently. Nothing about the *string* says which —
9293        // which is why provenance has to come from the door the caller uses.
9294        for view in [View::Source, View::Wysiwyg] {
9295            let mut d = doc_in(view, "typed_run", "ab\n");
9296            d.caret = 0;
9297            d.insert("x");
9298            d.insert("y");
9299            d.insert("z");
9300            d.undo();
9301            assert_eq!(d.source, "ab\n", "one run, one step");
9302        }
9303    }
9304
9305    #[test]
9306    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
9307        for view in [View::Source, View::Wysiwyg] {
9308            let mut d = doc_in(view, "undo_caret", "hello world\n");
9309            d.caret = 11; // standing at the end of "world", away from the edit
9310            d.edit(0, 5, "goodbye");
9311            assert_eq!(d.source, "goodbye world\n");
9312            d.undo();
9313            assert_eq!(d.source, "hello world\n");
9314            // The undone edit ends at offset 5; the user was at 11.
9315            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
9316        }
9317    }
9318
9319    #[test]
9320    fn undo_restores_the_selection_the_edit_replaced() {
9321        for view in [View::Source, View::Wysiwyg] {
9322            let mut d = doc_in(view, "undo_sel", "a word b\n");
9323            d.anchor = Some(2);
9324            d.caret = 6; // "word" selected
9325            d.insert("X");
9326            assert_eq!(d.source, "a X b\n");
9327            d.undo();
9328            assert_eq!(d.source, "a word b\n");
9329            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
9330        }
9331    }
9332
9333    #[test]
9334    fn redo_restores_the_caret_the_edit_left_behind() {
9335        for view in [View::Source, View::Wysiwyg] {
9336            let mut d = doc_in(view, "redo_caret", "hello world\n");
9337            d.caret = 11;
9338            d.edit(0, 5, "goodbye");
9339            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
9340            d.undo();
9341            d.redo();
9342            assert_eq!(d.source, "goodbye world\n");
9343            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
9344        }
9345    }
9346
9347    #[test]
9348    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
9349        for view in [View::Source, View::Wysiwyg] {
9350            let mut d = doc_in(view, "run_caret", "hi\n");
9351            d.caret = 2;
9352            d.insert("a");
9353            d.insert("b");
9354            d.insert("c");
9355            assert_eq!(d.source, "hiabc\n");
9356            d.undo();
9357            assert_eq!(d.source, "hi\n");
9358            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
9359            d.redo();
9360            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
9361        }
9362    }
9363
9364    #[test]
9365    fn undo_restores_the_caret_across_a_format_toggle() {
9366        // A toggle reaches twig without going through `splice`, so it has to
9367        // record its own step — miss it and every stack depth below it is off by
9368        // one, and undo starts handing back another edit's caret.
9369        for view in [View::Source, View::Wysiwyg] {
9370            let mut d = doc_in(view, "fmt_caret", "a word b\n");
9371            d.caret = 8;
9372            d.anchor = Some(2);
9373            d.caret = 6;
9374            d.toggle(InlineKind::Strong);
9375            assert_eq!(d.source, "a **word** b\n");
9376            d.undo();
9377            assert_eq!(d.source, "a word b\n");
9378            assert_eq!(d.selection(), Some((2, 6)), "the toggled selection comes back");
9379        }
9380    }
9381
9382    #[test]
9383    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
9384        // The drift that would never announce itself: twig drops its redo stack
9385        // on any fresh edit, so a leaf redo entry that outlives it would restore
9386        // a caret from the timeline that edit abandoned.
9387        for view in [View::Source, View::Wysiwyg] {
9388            let mut d = doc_in(view, "redo_trunc", "hello world\n");
9389            d.caret = 11;
9390            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
9391            d.undo();
9392            assert_eq!(d.caret, 11);
9393            d.caret = 0;
9394            d.insert("X"); // diverges: A's redo is gone from twig
9395            assert_eq!(d.source, "Xhello world\n");
9396
9397            d.redo();
9398            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
9399            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
9400            d.undo();
9401            assert_eq!(d.source, "hello world\n");
9402            assert_eq!(d.caret, 0, "the surviving step's caret, not the dropped one");
9403        }
9404    }
9405
9406    #[test]
9407    fn indent_and_outdent_move_the_caret_line_with_its_text() {
9408        for view in [View::Source, View::Wysiwyg] {
9409            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
9410            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
9411            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
9412            // Indentation the caret is standing *in* collapses to the line start
9413            // rather than dragging the caret into the text.
9414            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
9415            // A line with none to give back is left exactly as it was.
9416            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
9417            // Less than a full level gives back what it has.
9418            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
9419            // A tab is one level however many spaces it isn't.
9420            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
9421        }
9422    }
9423
9424    #[test]
9425    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
9426        // Why the level is two spaces and not the four both frontends type
9427        // today. Four is markdown's indented-code-block marker, so a Tab on a
9428        // paragraph would silently restyle it as code — a width that changes
9429        // what the document *means* isn't an indent. Pinned because the number
9430        // is the kind of thing a later list-aware pass would reach for.
9431        let mut d = doc_with("indent_kind", "hello\n");
9432        d.caret = 2;
9433        d.indent();
9434        assert_eq!(d.source, "  hello\n");
9435        assert!(
9436            d.nodes().iter().any(|n| n.kind == Kind::Para),
9437            "still prose after a Tab"
9438        );
9439        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
9440
9441        // The four-space level this replaces, for contrast: same text, and twig
9442        // reparses the paragraph into a code block.
9443        let mut wide = doc_with("indent_kind_4", "    hello\n");
9444        wide.build_visual(80);
9445        assert!(
9446            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
9447            "four spaces is a code block, not an indented paragraph"
9448        );
9449    }
9450
9451    #[test]
9452    fn indent_nests_a_list_item_under_its_parent() {
9453        // Tab indents a list item by its own marker width, landing its marker at
9454        // the parent's content column so twig reparses it as a nested list.
9455        for view in [View::Source, View::Wysiwyg] {
9456            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
9457            d.caret = 6; // on the second item
9458            d.indent();
9459            assert_eq!(d.source, "- a\n  - b\n");
9460            let lists = d.nodes().iter().filter(|n| n.kind == Kind::BulletList).count();
9461            assert_eq!(lists, 2, "the indented item is a nested list");
9462        }
9463    }
9464
9465    #[test]
9466    fn indent_nests_an_ordered_item_at_its_marker_width() {
9467        // An ordered marker `1. ` is three columns wide, so a two-space step
9468        // (which nests a bullet) leaves it flat. Regression: Tab must use the
9469        // marker width, three, so the item actually nests — and the source
9470        // renumbers so the sub-list restarts at 1 and the outer list resumes.
9471        for view in [View::Source, View::Wysiwyg] {
9472            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
9473            d.caret = d.source.find('b').unwrap();
9474            d.indent();
9475            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
9476            let lists = d.nodes().iter().filter(|n| n.kind == Kind::OrderedList).count();
9477            assert_eq!(lists, 2, "the indented item is a nested ordered list");
9478        }
9479    }
9480
9481    #[test]
9482    fn indent_leaves_a_lists_first_item_put() {
9483        // The first item of a list has no sibling above it to nest under, so Tab
9484        // is a no-op there — the marker stays at column zero rather than being
9485        // shoved into indentation twig can't read as a sub-list.
9486        for view in [View::Source, View::Wysiwyg] {
9487            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
9488            d.caret = 1; // on the FIRST item
9489            d.indent();
9490            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
9491            // The sibling below still nests, proving the guard is per-item.
9492            d.caret = d.source.find('b').unwrap();
9493            d.indent();
9494            assert_eq!(d.source, "- a\n  - b\n");
9495        }
9496    }
9497
9498    #[test]
9499    fn hidden_mode_keeps_typed_markup_literal() {
9500        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
9501        // twig escapes what would open markup, so the source is `\*hi\*` and the
9502        // AST is a plain string. Formatting is the commands' job in this mode.
9503        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
9504        d.insert("*hi*");
9505        assert_eq!(d.source, "\\*hi\\*");
9506        assert!(d.nodes().iter().all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong));
9507    }
9508
9509    #[test]
9510    fn hidden_mode_escapes_a_line_start_block_marker() {
9511        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
9512        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
9513        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
9514        d.insert("# hi");
9515        assert_eq!(d.source, "\\# hi");
9516        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
9517    }
9518
9519    #[test]
9520    fn authoring_modes_keep_typed_markup_live() {
9521        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
9522        // (no escape), the same as source view — escaping is `None`'s alone, and
9523        // it's the axis, not the reveal, that decides.
9524        for (view, mode) in [
9525            (View::Wysiwyg, MarkupMode::Shortcuts),
9526            (View::Wysiwyg, MarkupMode::Full),
9527            (View::Source, MarkupMode::None),
9528        ] {
9529            let mut d = doc_in(view, "live_markup", "");
9530            d.set_markup_mode(mode);
9531            d.insert("*hi*");
9532            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
9533        }
9534    }
9535
9536    #[test]
9537    fn hidden_mode_overwrite_undoes_in_one_step() {
9538        // Typing over a selection escapes the replacement *and* stays a single
9539        // undo — the selection-delete and the literal insert fold together, so
9540        // one undo brings the whole selection back, like a plain overwrite.
9541        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
9542        d.anchor = Some(2);
9543        d.caret = 6; // "word"
9544        d.insert("*");
9545        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
9546        d.undo();
9547        assert_eq!(d.source, "a word b\n");
9548        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
9549    }
9550
9551    #[test]
9552    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
9553        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
9554        // the whole visual character, never stranding the hidden `\`.
9555        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
9556        d.insert("*");
9557        assert_eq!(d.source, "\\*");
9558        d.backspace();
9559        assert_eq!(d.source, "", "the escape backslash went with the *");
9560        // A *literal* backslash (source view, no escape) is an ordinary char.
9561        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
9562        s.caret = 3; // after `b`
9563        s.backspace();
9564        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
9565    }
9566
9567    #[test]
9568    fn hidden_mode_leaves_structural_markup_alone() {
9569        // Enter continues a bullet list by writing a real `- ` marker (an
9570        // `insert_raw`, not the typing path), so Hidden mode's escaping never
9571        // touches it — the list keeps working.
9572        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
9573        d.caret = 6;
9574        d.newline();
9575        d.insert("two");
9576        assert_eq!(d.source, "- item\n- two\n");
9577    }
9578
9579    #[test]
9580    fn markup_mode_defaults_to_none_and_round_trips() {
9581        // Diaryx's default is the clean `None` surface; a markup-fluent
9582        // frontend can climb the ladder, and the choice sticks.
9583        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
9584        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
9585        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
9586            d.set_markup_mode(mode);
9587            assert_eq!(d.markup_mode(), mode);
9588        }
9589    }
9590
9591    #[test]
9592    fn full_mode_reveals_only_the_caret_line() {
9593        // The mode's whole claim: the caret's line shows its raw delimiters and
9594        // every other line stays resolved. Two paragraphs with identical markup
9595        // so the only difference between the rows is where the caret is.
9596        let mut d = doc_in(View::Wysiwyg, "reveal_caret_line", "*one* here\n\n*two* there\n");
9597        d.set_markup_mode(MarkupMode::Full);
9598
9599        caret_at(&mut d, "one");
9600        let rows = drawn_rows(&d);
9601        assert!(rows.iter().any(|r| r == "*one* here"), "caret's line raw: {rows:?}");
9602        assert!(rows.iter().any(|r| r == "two there"), "other line resolved: {rows:?}");
9603
9604        // Move to the other paragraph: the reveal follows, and the line just
9605        // left goes back to being resolved.
9606        caret_at(&mut d, "two");
9607        let rows = drawn_rows(&d);
9608        assert!(rows.iter().any(|r| r == "*two* there"), "caret's line raw: {rows:?}");
9609        assert!(rows.iter().any(|r| r == "one here"), "left line resolved: {rows:?}");
9610    }
9611
9612    #[test]
9613    fn hidden_modes_never_reveal_wherever_the_caret_is() {
9614        // The two rungs below `Full` share a rendering: delimiters stay hidden
9615        // even under the caret. `Shortcuts` differing from `None` only in what
9616        // typing does is exactly the point of splitting the axes.
9617        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
9618            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
9619            d.set_markup_mode(mode);
9620            caret_at(&mut d, "one");
9621            let rows = drawn_rows(&d);
9622            assert!(rows.iter().any(|r| r == "one here"), "{mode:?} hides: {rows:?}");
9623            assert!(!rows.iter().any(|r| r.contains('*')), "{mode:?} shows no `*`: {rows:?}");
9624        }
9625    }
9626
9627    #[test]
9628    fn revealed_delimiters_are_the_authors_own_spelling() {
9629        // Delimiters are re-read from the source rather than synthesized per
9630        // kind, so a line comes back spelled the way it was written: `_em_` does
9631        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
9632        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
9633        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
9634        d.set_markup_mode(MarkupMode::Full);
9635        caret_at(&mut d, "em");
9636        let rows = drawn_rows(&d);
9637        assert!(
9638            rows.iter().any(|r| r == body.trim_end()),
9639            "the revealed line is its own source: {rows:?}"
9640        );
9641    }
9642
9643    #[test]
9644    fn revealed_heading_shows_its_hashes() {
9645        // The `# ` marker is a block-level prefix, not an inline delimiter, so
9646        // it takes its own path — but it reveals on the same rule.
9647        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
9648        d.set_markup_mode(MarkupMode::Full);
9649
9650        caret_at(&mut d, "Title");
9651        assert!(drawn_rows(&d).iter().any(|r| r == "# Title"), "{:?}", drawn_rows(&d));
9652
9653        caret_at(&mut d, "body");
9654        let rows = drawn_rows(&d);
9655        assert!(rows.iter().any(|r| r == "Title"), "hashes hidden again: {rows:?}");
9656    }
9657
9658    #[test]
9659    fn revealed_delimiters_are_caret_stops() {
9660        // A delimiter that is drawn but can't be reached is worse than one
9661        // that's hidden: the mode exists so the markup can be *edited*. Every
9662        // revealed byte must be somewhere the caret can stand.
9663        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
9664        d.set_markup_mode(MarkupMode::Full);
9665        caret_at(&mut d, "em");
9666        let opener = d.source.find('*').unwrap();
9667        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
9668        assert!(d.vmap.is_stop(opener + 3), "the closing `*` is a caret stop");
9669    }
9670
9671    #[test]
9672    fn setext_heading_reveals_nothing_across_its_newline() {
9673        // A setext heading's underline is on another line, so it is not the
9674        // caret line's to reveal — and emitting it would inject a `\n` glyph
9675        // that splits the row where the author wrote no break.
9676        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
9677        d.set_markup_mode(MarkupMode::Full);
9678        caret_at(&mut d, "Title");
9679        let rows = drawn_rows(&d);
9680        assert!(rows.iter().any(|r| r == "Title"), "title renders alone: {rows:?}");
9681        assert!(!rows.iter().any(|r| r.contains('=')), "no underline leaks in: {rows:?}");
9682    }
9683
9684    #[test]
9685    fn markup_mode_axes_split_the_ladder() {
9686        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
9687        // that authors markup but still hides it, and it's the only rung where
9688        // the two axes disagree.
9689        assert!(!MarkupMode::None.authors());
9690        assert!(!MarkupMode::None.reveals_caret_line());
9691        assert!(MarkupMode::Shortcuts.authors());
9692        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
9693        assert!(MarkupMode::Full.authors());
9694        assert!(MarkupMode::Full.reveals_caret_line());
9695    }
9696
9697    #[test]
9698    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
9699        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
9700        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
9701        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
9702        // nested bullet and `hello` stays prose: the file round-trips instead of
9703        // hiding a heading the user never asked for.
9704        for view in [View::Source, View::Wysiwyg] {
9705            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
9706            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
9707            d.indent();
9708            assert_eq!(d.source, "- hello\n  * \n");
9709            assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading), "no heading");
9710            // And it's genuinely a nested list, not a flat one.
9711            assert_eq!(d.nodes().iter().filter(|n| n.kind == Kind::BulletList).count(), 2);
9712        }
9713    }
9714
9715    #[test]
9716    fn indenting_a_dash_item_with_content_keeps_its_dash() {
9717        // With content, `- x` can't be a setext underline, so there's nothing to
9718        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
9719        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
9720        d.caret = d.source.find('x').unwrap();
9721        d.indent();
9722        assert_eq!(d.source, "- hello\n  - x\n");
9723    }
9724
9725    #[test]
9726    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
9727        // The dash→`*` repair coalesces into the Tab, so a single undo restores
9728        // the whole pre-Tab state rather than stranding a half-collapsed doc.
9729        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
9730        d.caret = d.source.find("- \n").unwrap() + 2;
9731        d.indent();
9732        assert_eq!(d.source, "- hello\n  * \n");
9733        d.undo();
9734        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
9735    }
9736
9737    #[test]
9738    fn indent_leaves_a_nested_lists_first_item_put_too() {
9739        // The guard is about siblings, not depth: the first item of an *inner*
9740        // list (already nested under `a`) still has nothing before it at its own
9741        // level, so Tab can't take it deeper.
9742        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
9743        d.caret = d.source.find('b').unwrap();
9744        d.indent();
9745        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
9746        // But `c` (a sibling of `b`) nests under `b`.
9747        d.caret = d.source.find('c').unwrap();
9748        d.indent();
9749        assert_eq!(d.source, "- a\n  - b\n    - c\n");
9750    }
9751
9752    #[test]
9753    fn backspace_at_a_nested_item_start_outdents_it() {
9754        // Backspace with the caret right after a nested item's marker gives back
9755        // one level of nesting, the mirror of Tab — and renumbers the flattened
9756        // ordered list back to a clean run.
9757        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
9758        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
9759        d.backspace();
9760        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
9761    }
9762
9763    #[test]
9764    fn backspace_at_a_top_level_item_start_strips_the_marker() {
9765        // At the outermost level there's no nesting left to give back, so the same
9766        // keystroke drops the bullet and leaves a plain paragraph.
9767        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
9768        d.caret = d.source.find('b').unwrap(); // right after `- `
9769        d.backspace();
9770        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
9771    }
9772
9773    #[test]
9774    fn backspace_mid_item_still_deletes_a_character() {
9775        // The list behaviour is armed only at the item's content start; anywhere
9776        // else Backspace is the ordinary character delete.
9777        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
9778        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
9779        d.backspace();
9780        assert_eq!(d.source, "- b\n");
9781    }
9782
9783    #[test]
9784    fn backspace_at_a_heading_start_strips_the_marker() {
9785        // The `# ` is markup the rich view hides, so Backspace over it takes the
9786        // whole marker and leaves a paragraph. Deleting a byte of it instead left
9787        // `#Title` — no longer a heading, with the hash now literal text the user
9788        // never typed and has to delete again.
9789        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
9790        d.caret = d.source.find('T').unwrap(); // right after `## `
9791        d.backspace();
9792        assert_eq!(d.source, "Title\n");
9793        assert_eq!(d.caret, 0, "the caret stays with the text it was in front of");
9794    }
9795
9796    #[test]
9797    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
9798        // Only the heading's own marker goes — the quote (or list) it sits in is
9799        // untouched, exactly as un-heading it should be.
9800        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
9801        d.caret = d.source.find('T').unwrap();
9802        d.backspace();
9803        assert_eq!(d.source, "> Title\n");
9804    }
9805
9806    #[test]
9807    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
9808        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
9809        // behind would surface the same stray hash the marker delete just avoided.
9810        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
9811        d.caret = d.source.find('T').unwrap();
9812        d.backspace();
9813        assert_eq!(d.source, "Title\n");
9814        // And it's one edit: a single undo puts the whole heading back.
9815        d.undo();
9816        assert_eq!(d.source, "# Title #\n");
9817    }
9818
9819    #[test]
9820    fn backspace_mid_heading_still_deletes_a_character() {
9821        // The heading behaviour is armed only at the content's start; anywhere
9822        // else Backspace is the ordinary character delete.
9823        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
9824        d.caret = d.source.find('b').unwrap();
9825        d.backspace();
9826        assert_eq!(d.source, "# b\n");
9827    }
9828
9829    #[test]
9830    fn source_view_backspace_still_edits_the_heading_marker_literally() {
9831        // In source view the `# ` is text on the screen the user is deleting a
9832        // byte of, so it keeps its literal meaning — the same split the list
9833        // ladder and Enter draw between the two views.
9834        let mut d = doc_with("bsp_head_src", "# Title\n");
9835        d.caret = d.source.find('T').unwrap();
9836        d.backspace();
9837        assert_eq!(d.source, "#Title\n");
9838    }
9839
9840    #[test]
9841    fn outdent_unnests_an_ordered_item_in_one_press() {
9842        // Shift+Tab gives back exactly the marker width the indent added, so a
9843        // nested ordered item unnests in a single press, and the flattened list
9844        // renumbers back to a clean 1, 2, 3.
9845        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
9846        d.caret = d.source.find('b').unwrap();
9847        d.outdent();
9848        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
9849        let lists = d.nodes().iter().filter(|n| n.kind == Kind::OrderedList).count();
9850        assert_eq!(lists, 1, "back to one flat list");
9851    }
9852
9853    #[test]
9854    fn table_insert_row_adds_a_row_below_the_caret() {
9855        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
9856        d.caret = d.source.find('1').unwrap(); // in the body row
9857        d.table_insert_row(true);
9858        assert_eq!(
9859            d.source,
9860            "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n"
9861        );
9862    }
9863
9864    #[test]
9865    fn table_insert_and_delete_column_at_the_caret() {
9866        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
9867        d.caret = d.source.find('a').unwrap(); // column 0
9868        d.table_insert_column(true); // add a column to the right of `a`
9869        assert_eq!(
9870            d.source,
9871            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
9872        );
9873        d.caret = d.source.find('b').unwrap(); // now the third column
9874        d.table_delete_column();
9875        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
9876    }
9877
9878    // ── ragged formats ───────────────────────────────────────────────────────
9879    // No format spells every gesture. HTML writes the inline marks as a tag pair
9880    // and no heading, list, quote or link; Markdown spells three of the eight
9881    // marks; djot spells all eight and no in-cell break. leaf asks twig per
9882    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
9883    // op discover the fact on its own — one of them didn't.
9884
9885    /// An HTML document in the rich view, ready for a gesture.
9886    fn html_doc(body: &str) -> Doc {
9887        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
9888        d.view = View::Wysiwyg;
9889        d.build_visual(80);
9890        d
9891    }
9892
9893    #[test]
9894    fn a_table_gesture_leaves_an_html_table_alone() {
9895        // The regression this guard exists for. twig's table editor consults no
9896        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
9897        // HTML `<table>` as a *pipe table* and reported success: the whole
9898        // element replaced by `| a | b |`, silently, on one press of a toolbar
9899        // button. Every grid op went the same way.
9900        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
9901        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
9902            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
9903            ("delete row", &|d: &mut Doc| d.table_delete_row()),
9904            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
9905            ("delete column", &|d: &mut Doc| d.table_delete_column()),
9906            ("align", &|d: &mut Doc| d.table_set_alignment(Alignment::Right)),
9907            ("move row", &|d: &mut Doc| d.table_move_row(true)),
9908            ("move column", &|d: &mut Doc| d.table_move_column(true)),
9909        ];
9910        for (name, op) in ops {
9911            let mut d = html_doc(src);
9912            d.caret = d.source.find('a').unwrap();
9913            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
9914            op(&mut d);
9915            assert_eq!(d.source, src, "{name} rewrote an HTML table");
9916            assert!(!d.dirty, "{name} marked the document dirty without editing it");
9917            assert!(d.status.is_some(), "{name} refused without saying why");
9918        }
9919    }
9920
9921    #[test]
9922    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
9923        // A heading is a wrapping tag pair carrying its level in both ends, a
9924        // quote wraps a range rather than prefixing each line, a link's
9925        // destination lives in an attribute — different *shapes*, not a
9926        // different alphabet, so twig spells none of them and neither does leaf.
9927        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
9928        let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
9929            ("heading", &|d: &mut Doc| d.toggle_heading(2)),
9930            ("paragraph", &|d: &mut Doc| d.set_block(BlockKind::Paragraph)),
9931            ("quote", &|d: &mut Doc| d.toggle_blockquote()),
9932            ("list", &|d: &mut Doc| d.toggle_list(false)),
9933            ("task item", &|d: &mut Doc| d.toggle_task_item()),
9934            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
9935            ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
9936            ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
9937            ("video", &|d: &mut Doc| d.insert_media(MediaKind::Video, "clip.mp4", "")),
9938        ];
9939        for (name, op) in ops {
9940            let mut d = html_doc(src);
9941            let at = d.source.find("Hello").unwrap();
9942            d.caret = at;
9943            d.anchor = Some(at + 5); // a selection, for the ops that want one
9944            op(&mut d);
9945            assert_eq!(d.source, src, "{name} edited an HTML document");
9946            assert!(!d.dirty, "{name} marked the document dirty without editing it");
9947            let status = d.status.as_deref().unwrap_or("");
9948            assert!(
9949                status.contains("html"),
9950                "{name}: the refusal should name the format, got {status:?}"
9951            );
9952        }
9953    }
9954
9955    #[test]
9956    fn html_spells_the_inline_marks_and_the_rule() {
9957        // The other half, and why one per-document flag stopped being enough:
9958        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
9959        // already emits and the parser reads straight back as the same mark —
9960        // and the rule button writes an `<hr>`. Refusing these on the old
9961        // "HTML is parse-only" reading would now be leaf's own limitation.
9962        let mut d = html_doc("<p>Hello world</p>\n");
9963        let at = d.source.find("world").unwrap();
9964        d.caret = at;
9965        d.anchor = Some(at + 5);
9966        d.toggle(InlineKind::Strong);
9967        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
9968        assert!(d.dirty);
9969        assert_eq!(d.status, None, "a supported gesture reports nothing");
9970
9971        // And off again — the toggle reverses, which is the property that makes
9972        // authoring in HTML worth offering rather than a one-way trip.
9973        d.toggle(InlineKind::Strong);
9974        assert_eq!(d.source, "<p>Hello world</p>\n");
9975
9976        let mut d = html_doc("<p>Hello world</p>\n");
9977        d.caret = d.source.find("world").unwrap();
9978        d.insert_thematic_break();
9979        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
9980    }
9981
9982    #[test]
9983    fn a_mark_the_format_cannot_spell_arms_nothing() {
9984        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
9985        // sticky mark for the next text typed. Guarding only the twig call
9986        // leaves that path live, promising a highlight Markdown will never spell
9987        // and then swallowing the error inside `insert`. Markdown carries the
9988        // case now that HTML spells `<mark>`: `==mark==` is djot's alone.
9989        let mut d = doc_with("mark", "Hello world\n");
9990        d.view = View::Wysiwyg;
9991        d.build_visual(80);
9992        d.caret = d.source.find("world").unwrap();
9993        d.toggle(InlineKind::Mark);
9994        assert!(d.pending_marks.is_empty(), "no mark should be armed");
9995        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
9996        d.insert("X");
9997        assert_eq!(d.source, "Hello Xworld\n");
9998    }
9999
10000    #[test]
10001    fn html_documents_still_take_typed_text() {
10002        // The guard covers *markup* gestures and must not touch plain editing:
10003        // twig's splicer is language-neutral, and typing into an HTML document
10004        // is the thing that does work today.
10005        let mut d = html_doc("<p>Hello world</p>\n");
10006        d.caret = d.source.find("world").unwrap();
10007        d.insert("big ");
10008        assert_eq!(d.source, "<p>Hello big world</p>\n");
10009        assert!(d.dirty);
10010        d.backspace();
10011        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
10012        d.undo();
10013        d.undo();
10014        assert_eq!(d.source, "<p>Hello world</p>\n");
10015    }
10016
10017    #[test]
10018    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
10019        // `authorable` only separates "there is a door in" from "there is not",
10020        // and HTML is on the near side of that line — which is exactly why a
10021        // toolbar must not be built from it.
10022        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
10023        assert!(html.authorable());
10024        assert!(!Doc::from_source("<r>x</r>".into(), Format::Xml).unwrap().authorable());
10025
10026        let caps = html.capabilities();
10027        assert!(caps.bold && caps.italic && caps.code && caps.mark);
10028        assert!(caps.thematic_break && caps.cell_line_break);
10029        assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
10030        assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
10031        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
10032        // twig's table editor would happily re-emit as `| a | b |`.
10033        assert!(!caps.table);
10034
10035        // The two lightweight formats spell everything leaf offers — and still
10036        // differ from each other, which is the other half of why one boolean
10037        // can't serve.
10038        for fmt in [Format::Markdown, Format::Djot] {
10039            let caps = Capabilities::of(fmt);
10040            assert!(caps.heading && caps.blockquote && caps.ordered_list, "{fmt:?}");
10041            assert!(caps.task && caps.link && caps.image && caps.table, "{fmt:?}");
10042        }
10043        assert!(Capabilities::of(Format::Djot).mark);
10044        assert!(!Capabilities::of(Format::Markdown).mark);
10045        assert!(Capabilities::of(Format::Markdown).cell_line_break);
10046        assert!(!Capabilities::of(Format::Djot).cell_line_break);
10047
10048        // A parse-only format answers no to every one of them, so the coarse
10049        // predicate and the record agree there.
10050        let caps = Capabilities::of(Format::Xml);
10051        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
10052    }
10053
10054    #[test]
10055    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
10056        // The guard exists to name the *document's* format rather than twig's
10057        // internals, so the message has to survive being one leaf writes itself.
10058        // Checked against the gesture twig also refuses, since that is the pair
10059        // most at risk of drifting apart.
10060        let mut d = html_doc("<p>Hello</p>\n");
10061        d.caret = d.source.find("Hello").unwrap();
10062        d.set_code_language("zig");
10063        assert_eq!(d.status.as_deref(), Some("code language: not supported in html"));
10064        assert!(!d.dirty);
10065    }
10066
10067    #[test]
10068    fn table_set_alignment_respells_the_delimiter() {
10069        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10070        d.caret = d.source.find('b').unwrap();
10071        d.table_set_alignment(Alignment::Right);
10072        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
10073    }
10074
10075    #[test]
10076    fn each_empty_table_cell_has_its_own_editable_home() {
10077        // Regression: an empty cell has no twig content_span, so both cells of a
10078        // `|  |  |` row collapsed onto the row's start (before the first `│`).
10079        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
10080        // tell the cells apart. Each empty cell must now have a distinct home
10081        // inside it.
10082        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
10083        let (c0, c1) = {
10084            let cells = &d.vmap.tables[0].grid[1].cells;
10085            (cells[0].start, cells[1].start)
10086        };
10087        assert!(c0 < c1, "the two empty cells have distinct homes: {c0} < {c1}");
10088        d.caret = c0;
10089        d.insert("x");
10090        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| x |  |\n", "typed inside the cell");
10091    }
10092
10093    #[test]
10094    fn arrows_step_into_each_empty_table_cell() {
10095        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
10096        let (c0, c1) = {
10097            let cells = &d.vmap.tables[0].grid[1].cells;
10098            (cells[0].start, cells[1].start)
10099        };
10100        d.caret = d.source.find('b').unwrap(); // in the header's second cell
10101        let mut seen = std::collections::HashSet::new();
10102        for _ in 0..6 {
10103            d.move_right(false);
10104            seen.insert(d.caret);
10105        }
10106        assert!(seen.contains(&c0), "right arrow reaches the first empty cell");
10107        assert!(seen.contains(&c1), "right arrow reaches the second empty cell");
10108    }
10109
10110    #[test]
10111    fn table_op_off_a_table_is_a_no_op_with_a_status() {
10112        let mut d = doc_with("tbl_none", "just text\n");
10113        d.caret = 3;
10114        d.table_insert_row(true);
10115        assert_eq!(d.source, "just text\n", "nothing changed");
10116        assert!(d.status.is_some(), "a status explains why");
10117        assert!(!d.caret_in_table());
10118    }
10119
10120    #[test]
10121    fn enter_in_an_ordered_list_renumbers_the_following_items() {
10122        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
10123        // the renumber pass keeps them sequential, matching what the view draws.
10124        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
10125        d.caret = d.source.find('a').unwrap() + 1; // end of item a
10126        d.newline();
10127        d.insert("x");
10128        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
10129    }
10130
10131    #[test]
10132    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
10133        for view in [View::Source, View::Wysiwyg] {
10134            let mut d = doc_in(view, "outdent_noop", "hello\n");
10135            d.caret = 2;
10136            d.outdent();
10137            assert_eq!(d.source, "hello\n");
10138            assert!(!d.dirty, "a no-op is not a modification");
10139            d.undo();
10140            assert_eq!(d.status.as_deref(), Some("nothing to undo"), "spends no undo step");
10141            assert_eq!(d.source, "hello\n");
10142        }
10143    }
10144
10145    #[test]
10146    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
10147        for view in [View::Source, View::Wysiwyg] {
10148            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
10149            d.anchor = Some(0);
10150            d.caret = 7; // through "two"
10151            d.indent();
10152            assert_eq!(
10153                d.source, "  one\n\n  two\n",
10154                "the blank line keeps no trailing pad"
10155            );
10156            // Selected, so a second Tab lands on the same lines rather than on
10157            // whatever the shifted offsets now cover.
10158            assert_eq!(d.selection(), Some((0, 12)));
10159            d.indent();
10160            assert_eq!(d.source, "    one\n\n    two\n");
10161        }
10162    }
10163
10164    #[test]
10165    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
10166        for view in [View::Source, View::Wysiwyg] {
10167            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
10168            d.anchor = Some(0);
10169            d.caret = 15;
10170            d.outdent();
10171            assert_eq!(d.source, "two\none\nnone\n");
10172        }
10173    }
10174
10175    #[test]
10176    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
10177        for view in [View::Source, View::Wysiwyg] {
10178            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
10179            d.anchor = Some(0);
10180            d.caret = 7;
10181            d.indent();
10182            assert_eq!(d.source, "  one\n\n  two\n");
10183            d.undo();
10184            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
10185            assert_eq!(d.selection(), Some((0, 7)), "with the selection it was aimed at");
10186            d.redo();
10187            assert_eq!(d.source, "  one\n\n  two\n");
10188            assert_eq!(
10189                d.selection(),
10190                Some((0, 12)),
10191                "redo replays the caret the indent placed, not the one splice left"
10192            );
10193        }
10194    }
10195
10196    #[test]
10197    fn vertical_motion_keeps_the_column() {
10198        let mut d = doc_with("move", "abcd\nef\n");
10199        d.caret = 3; // "abc|d" on row 0, col 3
10200        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
10201        assert_eq!(d.caret, 7); // just after "ef"
10202    }
10203
10204    // ── goal column ──────────────────────────────────────────────────────────
10205
10206    #[test]
10207    fn vertical_motion_goal_column_survives_a_short_line() {
10208        // Regression: re-deriving the column from the clamped position on
10209        // every step permanently forgets it once a short line clamps it.
10210        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
10211        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
10212        assert_eq!(
10213            g("abcd|ef\nxy\nghijkl\n", |d| {
10214                d.move_down(false); // clamps to end of "xy"
10215                d.move_down(false); // restores col 4 on the long line
10216            }),
10217            "abcdef\nxy\nghij|kl\n"
10218        );
10219    }
10220
10221    #[test]
10222    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
10223        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
10224        assert_eq!(d.goal_col, None);
10225        d.caret = 4; // row 0, col 4
10226        d.move_down(false); // clamps into "xy"; goal stays the original col
10227        assert_eq!(d.goal_col, Some(4));
10228        assert_eq!(d.caret_pos(), (1, 2));
10229
10230        // A horizontal motion drops the goal column...
10231        d.move_left(false);
10232        assert_eq!(d.goal_col, None);
10233
10234        // ...so the next vertical motion picks up the *new* column (1), not
10235        // the stale one (4).
10236        d.move_down(false);
10237        assert_eq!(d.goal_col, Some(1));
10238        assert_eq!(d.caret_pos(), (2, 1));
10239    }
10240
10241    #[test]
10242    fn editing_clears_the_goal_column() {
10243        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
10244        d.caret = 4;
10245        d.move_down(false);
10246        assert_eq!(d.goal_col, Some(4));
10247        d.insert("Z");
10248        assert_eq!(d.goal_col, None);
10249    }
10250
10251    #[test]
10252    fn vertical_motion_on_an_empty_document_is_a_no_op() {
10253        let mut d = doc_with("empty_vert", "");
10254        d.move_down(false);
10255        assert_eq!(d.caret, 0);
10256        d.move_up(false);
10257        assert_eq!(d.caret, 0);
10258    }
10259
10260    // ── the document's edges ─────────────────────────────────────────────────
10261
10262    #[test]
10263    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
10264        // The reproduction, and the disagreement: Down on the last line ran to
10265        // the end of the document in the source view — by accident, an
10266        // out-of-range row clamping to the end of the string — and did nothing
10267        // whatever in the view leaf opens in. One rule now, in both.
10268        for (view, tag) in VIEWS {
10269            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
10270            d.caret = 1;
10271            d.move_down(false);
10272            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
10273            d.move_up(false);
10274            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
10275        }
10276    }
10277
10278    #[test]
10279    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
10280        // Down off the bottom is a motion like any other, so it latches a goal
10281        // column — and Up comes back to the column the caret left, not to the
10282        // one the document's end happened to be in.
10283        for (view, tag) in VIEWS {
10284            let gap = if view == View::Source { "\n" } else { "\n\n" };
10285            let src = format!("abcdef{gap}ghijkl");
10286            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
10287            d.caret = 2; // row 0, col 2
10288            d.move_down(false);
10289            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
10290            d.move_down(false);
10291            assert_eq!(d.caret, src.len(), "{tag}: Down off the bottom reaches the end");
10292            d.move_up(false);
10293            assert_eq!(d.caret_pos().1, 2, "{tag}: Up returns to the column Down left");
10294        }
10295    }
10296
10297    #[test]
10298    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
10299        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
10300        // Up that did nothing still armed a goal column, and the next Down aimed
10301        // at a column the caret had never been in.
10302        for (view, tag) in VIEWS {
10303            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
10304            d.caret = 0;
10305            d.move_up(false);
10306            assert_eq!(d.caret, 0, "{tag}: already at the start");
10307            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
10308
10309            d.caret = d.source.len();
10310            d.move_down(false);
10311            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
10312            assert_eq!(d.goal_col, None, "{tag}: a no-op Down latched a goal column");
10313        }
10314    }
10315
10316    // ── soft wrap ────────────────────────────────────────────────────────────
10317    // Every other test here builds the map at 80 columns, where no fixture is
10318    // long enough to fold. A wrap is where one offset belongs to two rows at
10319    // once, and it broke everything that asks the caret what row it is on.
10320
10321    /// The wrapped fixture these cases share, folded at 12 columns into
10322    /// `one two ` / `three four ` / `five six ` / `seven eight`.
10323    fn wrapped_doc(name: &str) -> Doc {
10324        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
10325        d.build_visual(12);
10326        d
10327    }
10328
10329    #[test]
10330    fn home_and_end_work_from_a_wrapped_row() {
10331        // The reproduction: offset 19 is the `f` of "five", the first character
10332        // of the third row — and also the offset the second row ends at. It
10333        // resolved to the *second* row, so End aimed at a place the caret was
10334        // already in and did nothing, while Home walked backwards onto a row the
10335        // caret had left.
10336        let mut d = wrapped_doc("wrap_home_end");
10337        d.caret = 19;
10338        assert_eq!(d.caret_pos(), (2, 0), "the wrap boundary opens the third row");
10339        d.move_end(false);
10340        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
10341        d.move_home(false);
10342        assert_eq!(d.caret, 19, "Home left the row the caret was on");
10343    }
10344
10345    #[test]
10346    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
10347        // The row's end is the last offset that is only ever its own: the offset
10348        // past it opens the row below, and aiming there would send a second
10349        // press on to *that* row's end, and a third to the next — End walking
10350        // down the paragraph rather than sitting where it landed.
10351        let mut d = wrapped_doc("wrap_end_twice");
10352        d.caret = 12; // inside "three", on the second row
10353        d.move_end(false);
10354        assert_eq!(d.caret, 18, "the end of `three four`, before the space the wrap ate");
10355        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
10356        d.move_end(false);
10357        assert_eq!(d.caret, 18, "a second End moved the caret");
10358        d.move_home(false);
10359        assert_eq!(d.caret, 8, "Home takes the row's own start");
10360    }
10361
10362    #[test]
10363    fn vertical_motion_crosses_a_soft_wrap() {
10364        // Down aimed at the row below's column 0, an offset that resolved *up*
10365        // to the row above's end — so it landed on the offset it already had and
10366        // the caret could never leave a paragraph's first row.
10367        let mut d = wrapped_doc("wrap_down");
10368        d.caret = 0;
10369        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
10370            d.move_down(false);
10371            assert_eq!(d.caret, want, "Down stalled");
10372            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
10373        }
10374        d.move_down(false);
10375        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
10376
10377        // ...and back up, one row per press. The goal column is the end of the
10378        // last row, past every other row's width, so each press clamps to the
10379        // row's own last offset rather than to the one that opens the next.
10380        let mut d = wrapped_doc("wrap_up");
10381        d.caret = 39;
10382        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
10383            d.move_up(false);
10384            assert_eq!(d.caret, want, "Up stalled");
10385            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
10386        }
10387    }
10388
10389    #[test]
10390    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
10391        // The kills take the same line Home and End do, so in WYSIWYG they take
10392        // the visual row — and a soft wrap has no newline in it to delete, so
10393        // nothing is joined by reaching the end of one.
10394        let mut d = wrapped_doc("wrap_kill");
10395        d.caret = 19; // the `f` of "five", opening the third row
10396        d.delete_to_line_end();
10397        // The space the wrap ate goes with the row it was drawn on: sparing it
10398        // would leave "four  seven", two spaces where the row had been.
10399        assert_eq!(d.source, "one two three four seven eight");
10400
10401        // Backwards from the row's last caret position — which is *before* that
10402        // space, so this one survives, being on the far side of the caret.
10403        let mut d = wrapped_doc("wrap_kill_back");
10404        d.caret = 27;
10405        d.delete_to_line_start();
10406        assert_eq!(d.source, "one two three four  seven eight");
10407    }
10408
10409    // ── document start / end ────────────────────────────────────────────────
10410
10411    #[test]
10412    fn move_doc_start_and_end_jump_to_the_edges() {
10413        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
10414        assert_eq!(g("hello\nwor|ld\n", |d| d.move_doc_start(false)), "|hello\nworld\n");
10415        assert_eq!(g("hel|lo\nworld\n", |d| d.move_doc_end(false)), "hello\nworld\n|");
10416        // Already at the edge: a no-op.
10417        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
10418        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
10419    }
10420
10421    #[test]
10422    fn move_doc_start_and_end_extend_the_selection() {
10423        assert_eq!(
10424            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d.move_doc_end(true)),
10425            "hello wor[ld\n|]"
10426        );
10427        assert_eq!(
10428            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d.move_doc_start(true)),
10429            "[|hello wor]ld\n"
10430        );
10431    }
10432
10433    #[test]
10434    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
10435        let mut d = doc_with("empty_edges", "");
10436        d.move_doc_end(false);
10437        assert_eq!(d.caret, 0);
10438        d.move_doc_start(false);
10439        assert_eq!(d.caret, 0);
10440    }
10441
10442    // ── arrow collapses an active selection ─────────────────────────────────
10443
10444    #[test]
10445    fn arrow_collapses_selection_to_its_near_edge() {
10446        let mut d = doc_with("collapse", "hello world\n");
10447
10448        // Forward selection (anchor before caret): Right -> end, Left -> start.
10449        d.anchor = Some(2);
10450        d.caret = 7;
10451        d.move_right(false);
10452        assert_eq!((d.caret, d.anchor), (7, None));
10453
10454        d.anchor = Some(2);
10455        d.caret = 7;
10456        d.move_left(false);
10457        assert_eq!((d.caret, d.anchor), (2, None));
10458
10459        // Backward selection (anchor after caret): edges are the same
10460        // regardless of which end the caret started on.
10461        d.anchor = Some(7);
10462        d.caret = 2;
10463        d.move_right(false);
10464        assert_eq!((d.caret, d.anchor), (7, None));
10465
10466        d.anchor = Some(7);
10467        d.caret = 2;
10468        d.move_left(false);
10469        assert_eq!((d.caret, d.anchor), (2, None));
10470    }
10471
10472    #[test]
10473    fn arrow_with_extend_keeps_growing_the_selection() {
10474        let mut d = doc_with("collapse_extend", "hello world\n");
10475        d.anchor = Some(2);
10476        d.caret = 7;
10477        d.move_right(true); // extend: no collapse, caret steps one further
10478        assert_eq!((d.caret, d.anchor), (8, Some(2)));
10479    }
10480
10481    #[test]
10482    fn arrow_without_a_selection_moves_one_character_as_before() {
10483        let mut d = doc_with("no_collapse", "hello\n");
10484        d.caret = 2;
10485        d.move_right(false);
10486        assert_eq!(d.caret, 3);
10487        d.move_left(false);
10488        assert_eq!(d.caret, 2);
10489    }
10490
10491    /// Press Right until it stops, collecting the offsets walked through. Every
10492    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
10493    /// two stops sharing one source offset can't be moved between, so the caret
10494    /// stalls on the first of them and the walk never reaches the rest.
10495    fn walk_right(d: &mut Doc) -> Vec<usize> {
10496        let mut seen = vec![d.caret];
10497        for _ in 0..2000 {
10498            let before = d.caret;
10499            d.move_right(false);
10500            if d.caret == before {
10501                break;
10502            }
10503            seen.push(d.caret);
10504        }
10505        seen
10506    }
10507
10508    #[test]
10509    fn the_caret_crosses_a_soft_break() {
10510        // A newline inside a paragraph is a `soft_break`, which twig gives no
10511        // span of its own — the space it renders as used to borrow the offset of
10512        // the character before it, and a caret can't move without changing
10513        // offset. Right must walk clean off the end of the first line.
10514        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
10515        d.caret = 0;
10516        let seen = walk_right(&mut d);
10517        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
10518    }
10519
10520    #[test]
10521    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
10522        // The paragraph holds one soft break. Folded (the default) it lays out as
10523        // a single reflowed row; Preserve re-lays it as a row per source line.
10524        // The setter must invalidate the cached map for the change to show, and
10525        // again on the way back — so a round trip returns to the folded layout.
10526        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
10527        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
10528        d.build_visual(80);
10529        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
10530
10531        d.set_line_flow(LineFlow::Preserve);
10532        d.build_visual(80);
10533        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
10534
10535        d.set_line_flow(LineFlow::Fold);
10536        d.build_visual(80);
10537        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
10538    }
10539
10540    #[test]
10541    fn the_caret_still_crosses_a_preserved_soft_break() {
10542        // Preserve renders the soft break as a row boundary rather than a space,
10543        // but the caret must still reach every offset — the break's own offset is
10544        // the first row's end stop, so Right walks clean off the end of line one
10545        // onto line two, exactly as it does when the break is folded.
10546        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
10547        d.set_line_flow(LineFlow::Preserve);
10548        d.build_visual(80);
10549        d.caret = 0;
10550        let seen = walk_right(&mut d);
10551        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
10552    }
10553
10554    #[test]
10555    fn the_caret_walks_a_code_block() {
10556        // Every glyph of a code block used to map to the block's start, so the
10557        // whole block was a single offset and the caret couldn't move inside it.
10558        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
10559        let mut d = wysiwyg_doc("code_walk", src);
10560        d.caret = 0;
10561        let seen = walk_right(&mut d);
10562        // The fences are markup: hidden, and no caret stop. The code between
10563        // them is reached a character at a time.
10564        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
10565        for off in code.clone() {
10566            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
10567        }
10568        assert!(seen.contains(&code.end), "no stop after the last line");
10569    }
10570
10571    #[test]
10572    fn the_caret_walks_an_indented_code_block() {
10573        // An indented block's text has the four-space indent stripped, so it
10574        // isn't a verbatim slice and its lines have to be re-found. The caret
10575        // lands on the code, never in the indent.
10576        let src = "    indented\n    code\n";
10577        let mut d = wysiwyg_doc("indent_code_walk", src);
10578        d.caret = 0;
10579        let seen = walk_right(&mut d);
10580        assert!(seen.contains(&src.find("indented").unwrap()));
10581        assert!(seen.contains(&src.find("code").unwrap()));
10582        assert!(
10583            !seen.contains(&0) || seen[0] == 0,
10584            "the caret starts where it was put"
10585        );
10586        // Nothing in the stripped indent is a stop.
10587        for off in [1, 2, 3] {
10588            assert!(!seen.contains(&off), "landed in the indent at {off}");
10589        }
10590    }
10591
10592    #[test]
10593    fn the_caret_leaves_a_tight_heading() {
10594        // "# H" with text directly under it: the heading row's end and the
10595        // separator row's end are the same offset. Right used to find the
10596        // separator's copy, set the caret to where it already was, and stop.
10597        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
10598        d.caret = 2; // the "H"
10599        let seen = walk_right(&mut d);
10600        assert!(seen.len() > 2, "Right stalled at the heading's end: {seen:?}");
10601        assert!(seen.contains(&8), "never reached the end of \"text\": {seen:?}");
10602    }
10603
10604    #[test]
10605    fn the_caret_skips_the_gap_between_two_paragraphs() {
10606        // The blank line between two paragraphs is the boundary itself. The
10607        // caret used to be able to sit on it, and typing there landed in the
10608        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
10609        // soft break, so the text visibly snapped back up.
10610        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
10611        d.caret = 1; // the end of "A"
10612        d.move_right(false);
10613        assert_eq!(d.caret, 3, "Right stopped in the gap");
10614        d.insert("x");
10615        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
10616    }
10617
10618    #[test]
10619    fn down_from_a_paragraph_lands_on_the_next_one() {
10620        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
10621        d.caret = 0;
10622        d.move_down(false);
10623        assert_eq!(d.caret, 3, "Down stopped in the gap");
10624    }
10625
10626    #[test]
10627    fn clicking_the_gap_lands_on_real_text() {
10628        // A click can still *reach* the gap — it's drawn, so it's clickable.
10629        // It has to resolve to somewhere the caret can be.
10630        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
10631        d.click(1, 0, false); // the gap row
10632        assert!(d.caret == 1 || d.caret == 3, "click left the caret in the gap at {}", d.caret);
10633        d.insert("x");
10634        // Either edge of the boundary is a fair place to land; inside it isn't.
10635        assert!(
10636            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
10637            "click in the gap typed into the boundary: {:?}",
10638            d.source
10639        );
10640    }
10641
10642    #[test]
10643    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
10644        // Enter inserts a paragraph break, which leaves a blank line spare on
10645        // either side of a new one. That middle line is a real empty paragraph:
10646        // the caret lands there, and typing makes a paragraph rather than
10647        // extending a neighbour.
10648        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
10649        d.caret = 1;
10650        d.newline();
10651        assert_eq!(d.source, "A\n\n\n\nB\n");
10652        d.build_visual(80);
10653        let (row, _) = d.caret_pos();
10654        assert!(d.vmap.row_is_navigable(row), "the caret landed on a gap row");
10655        d.insert("x");
10656        assert_eq!(d.source, "A\n\nx\n\nB\n", "the new paragraph merged into a neighbour");
10657    }
10658
10659    #[test]
10660    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
10661        let mut d = wysiwyg_doc("gap_eof", "A\n");
10662        d.caret = 1;
10663        d.newline();
10664        d.build_visual(80);
10665        let (row, _) = d.caret_pos();
10666        assert!(d.vmap.row_is_navigable(row), "the caret landed on a gap row");
10667        d.insert("x");
10668        assert!(
10669            d.source.starts_with("A\n\n") && d.source.contains('x'),
10670            "typing at the end merged into A: {:?}",
10671            d.source
10672        );
10673    }
10674
10675    #[test]
10676    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
10677        // A paragraph broken over two source lines is one paragraph. Selecting
10678        // it must not stop at the newline inside it — that newline is markup the
10679        // rich-text view exists to hide.
10680        let src = "one two\nthree four\n\nnext\n";
10681        let mut d = wysiwyg_doc("triple_para", src);
10682        d.select_block_at(2);
10683        assert_eq!(d.selected_text(), Some("one two\nthree four"), "stopped at the soft break");
10684    }
10685
10686    #[test]
10687    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
10688        // The reader scrolls down past the caret's row. Nothing moved the
10689        // caret, so the view must stay where it was put — the old code revealed
10690        // the caret every frame, which dragged the view straight back and made
10691        // the document unscrollable past the caret.
10692        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
10693        d.caret = 0;
10694        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
10695        d.scroll = 4; // the wheel
10696        d.follow_caret(0, 3, 9);
10697        assert_eq!(d.scroll, 4, "the wheel was overruled by a caret that never moved");
10698    }
10699
10700    #[test]
10701    fn moving_the_caret_brings_the_view_back_to_it() {
10702        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
10703        d.caret = 0;
10704        d.follow_caret(0, 3, 9);
10705        d.scroll = 6; // scrolled away
10706        d.move_right(false); // ...and now the caret moves
10707        let (row, _) = d.caret_pos();
10708        d.follow_caret(row, 3, 9);
10709        assert!(d.scroll <= row && row < d.scroll + 3, "caret row {row} off screen at scroll {}", d.scroll);
10710    }
10711
10712    #[test]
10713    fn scrolling_stops_at_the_last_row() {
10714        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
10715        d.caret = 0;
10716        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
10717        d.scroll = 999; // the wheel, spun hard
10718        d.follow_caret(0, 3, 3);
10719        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
10720    }
10721
10722    #[test]
10723    fn every_cell_of_a_wide_table_is_reachable() {
10724        // A table whose cells are far wider than the surface: the columns are
10725        // cut to fit and the text wraps inside them, so no cell hangs off the
10726        // right edge where the caret can never go.
10727        let src = "| Ingredient | Notes |\n|---|---|\n\
10728                   | flour milled coarse | sift it twice before folding it in |\n";
10729        let mut d = wysiwyg_doc("wide_table_walk", src);
10730        d.build_visual(30);
10731        d.caret = 0;
10732        let seen = walk_right(&mut d);
10733        for word in ["Ingredient", "Notes", "coarse", "folding"] {
10734            let at = src.find(word).unwrap();
10735            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
10736        }
10737    }
10738
10739    // ── view parity ──────────────────────────────────────────────────────────
10740    // `doc_with` pins the source view, so everything above tests a view users
10741    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
10742    // deletion golden cases through *both*, plus the WYSIWYG cases the two
10743    // can't share: where the source carries markup the rendered text is a
10744    // different string, and the views agreeing would itself be the bug.
10745
10746    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
10747
10748    /// Run `action` in both views on one `|`-marked fixture and assert they
10749    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
10750    /// source verbatim, so the two views are looking at the same text and any
10751    /// disagreement is one of them having lost the plot.
10752    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
10753        let (src, caret) = parse_caret(marked);
10754        let run = |view: View, tag: &str| {
10755            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
10756            d.caret = caret;
10757            action(&mut d);
10758            render_caret(&d)
10759        };
10760        let source = run(VIEWS[0].0, VIEWS[0].1);
10761        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
10762        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
10763        source
10764    }
10765
10766    #[test]
10767    fn word_motion_agrees_across_the_views_on_plain_prose() {
10768        let g = both_views;
10769        assert_eq!(g("par_wl", "hello wor|ld", |d| d.move_word_left(false)), "hello |world");
10770        assert_eq!(g("par_wl2", "hello| world", |d| d.move_word_left(false)), "|hello world");
10771        assert_eq!(g("par_wr", "hel|lo world", |d| d.move_word_right(false)), "hello| world");
10772        assert_eq!(g("par_wr2", "hello| world", |d| d.move_word_right(false)), "hello world|");
10773        assert_eq!(g("par_punct", "|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
10774        assert_eq!(
10775            g("par_ext", "hello |world", |d| d.move_word_right(true)),
10776            "hello [world|]"
10777        );
10778    }
10779
10780    #[test]
10781    fn word_deletion_agrees_across_the_views_on_plain_prose() {
10782        let g = both_views;
10783        assert_eq!(g("par_db", "hello world|", |d| d.delete_word_back()), "hello |");
10784        assert_eq!(g("par_df", "hello |world", |d| d.delete_word_forward()), "hello |");
10785        assert_eq!(g("par_db2", "foo |bar baz", |d| d.delete_word_back()), "|bar baz");
10786        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
10787    }
10788
10789    #[test]
10790    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
10791        let g = both_views;
10792        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
10793        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
10794        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
10795        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
10796    }
10797
10798    #[test]
10799    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
10800        // The reproduction: the stop table was built one stop per `char`, so
10801        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
10802        // source view, which steps by grapheme, can't reach and backspace can't
10803        // survive. The two views must land on the same offset.
10804        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
10805        for (view, tag) in VIEWS {
10806            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
10807            d.caret = 1;
10808            d.move_right(false);
10809            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
10810
10811            // ...and the edit that used to sever a joiner off the front of it.
10812            d.backspace();
10813            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
10814            assert_eq!(d.caret, 1);
10815        }
10816    }
10817
10818    #[test]
10819    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
10820        for (view, tag) in VIEWS {
10821            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
10822            d.caret = 0;
10823            d.move_right(false);
10824            assert_eq!(d.caret, "e\u{0301}".len(), "{tag} stopped on the combining mark");
10825        }
10826    }
10827
10828    #[test]
10829    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
10830        // The general form: whatever route the caret takes through a document
10831        // full of clusters, it never lands between the codepoints of one — so no
10832        // motion-then-backspace sequence can leave a dangling joiner behind.
10833        use unicode_segmentation::UnicodeSegmentation;
10834
10835        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
10836        let mut d = wysiwyg_doc("cluster_walk", src);
10837        d.caret = 0;
10838        let boundaries: Vec<usize> = src
10839            .grapheme_indices(true)
10840            .map(|(i, _)| i)
10841            .chain(std::iter::once(src.len()))
10842            .collect();
10843        for off in walk_right(&mut d) {
10844            assert!(
10845                boundaries.contains(&off),
10846                "Right stopped at {off}, inside a grapheme cluster"
10847            );
10848        }
10849    }
10850
10851    #[test]
10852    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
10853        // The reproduction: ⌥→ from inside the opening `**` computed its
10854        // boundary over the raw source and landed on byte 8 — inside the
10855        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
10856        // "bold". The caret drew past the bold word and sat inside it.
10857        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
10858        d.caret = 2;
10859        d.move_word_right(false);
10860        assert!(d.vmap.is_stop(d.caret), "landed at {}, not a caret stop", d.caret);
10861        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
10862        // The rendered row is "a bold c": column 6 is the space just past "bold",
10863        // and now the caret is really there rather than only drawn there.
10864        assert_eq!(d.caret_pos(), (0, 6));
10865
10866        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
10867        d.move_word_left(false);
10868        assert_eq!(d.caret, 4);
10869        assert_eq!(d.caret_pos(), (0, 2));
10870    }
10871
10872    #[test]
10873    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
10874        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
10875        // inside the closing `**`, and left "a ** c\n" — delimiters with no
10876        // opener. Glyph space covers the word alone, which would leave
10877        // "a **** c": markup wrapped around nothing. The word and the styling
10878        // that was only ever the word's go together.
10879        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
10880        d.caret = 10;
10881        d.delete_word_back();
10882        assert_eq!(d.source, "a  c\n");
10883        assert_eq!(d.caret, 2);
10884
10885        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
10886        d.caret = 4; // the "b"
10887        d.delete_word_forward();
10888        assert_eq!(d.source, "a  c\n");
10889    }
10890
10891    #[test]
10892    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
10893        let src = "a ***bold*** c\n";
10894        let mut d = wysiwyg_doc("wys_word_del_nest", src);
10895        d.caret = src.find(" c").unwrap();
10896        d.delete_word_back();
10897        assert_eq!(d.source, "a  c\n", "the emph inside the strong empties it too");
10898
10899        let src = "a `code` c\n";
10900        let mut d = wysiwyg_doc("wys_word_del_code", src);
10901        d.caret = src.find(" c").unwrap();
10902        d.delete_word_back();
10903        assert_eq!(d.source, "a  c\n");
10904    }
10905
10906    #[test]
10907    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
10908        // Only an *emptied* node goes. Take one word of two and the `**` still
10909        // has a job to do — over the word that's left, with the space the delete
10910        // pushed against the opening delimiter moved out in front of it, or the
10911        // run would be no run at all (`** words**` is literal asterisks — see
10912        // the mark-edge rule on `splice`).
10913        let src = "a **two words** c\n";
10914        let mut d = wysiwyg_doc("wys_word_del_partial", src);
10915        d.caret = src.find(" words").unwrap();
10916        d.delete_word_back();
10917        assert_eq!(d.source, "a  **words** c\n");
10918    }
10919
10920    #[test]
10921    fn source_view_word_motion_still_walks_the_markup() {
10922        // The other half of the decision: in the source view the `**` are
10923        // characters like any other — they're on the screen, so word motion has
10924        // to stop at them and a word-delete has to leave them behind. Only
10925        // WYSIWYG hides them, so only WYSIWYG steps over them.
10926        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
10927        assert_eq!(
10928            g("src_word_motion", "a |**bold** c\n", |d| d.move_word_right(false)),
10929            "a **bold|** c\n"
10930        );
10931        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
10932        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
10933        assert_eq!(
10934            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
10935            "a **| c\n"
10936        );
10937    }
10938
10939    #[test]
10940    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
10941        // The single invariant both bugs violated: the caret draws and edits at
10942        // the same place only when it's on a stop. `debug_assert_on_a_stop`
10943        // makes the same claim in-place; this pins it from the outside, over a
10944        // document with every kind of thing the map has to be careful about.
10945        // At two widths: the wide one every other test builds at, where no
10946        // fixture folds, and one narrow enough that they all do. A soft wrap is
10947        // where an offset stops being on exactly one row, and testing only the
10948        // width that never wraps is how the caret came to be pinned at the first
10949        // one Down reached.
10950        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
10951                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
10952        let motions: [(&str, fn(&mut Doc)); 8] = [
10953            ("right", |d| d.move_right(false)),
10954            ("left", |d| d.move_left(false)),
10955            ("word_right", |d| d.move_word_right(false)),
10956            ("word_left", |d| d.move_word_left(false)),
10957            ("down", |d| d.move_down(false)),
10958            ("up", |d| d.move_up(false)),
10959            ("home", |d| d.move_home(false)),
10960            ("end", |d| d.move_end(false)),
10961        ];
10962        for width in [80, 12] {
10963            let mut d = wysiwyg_doc("stop_invariant", src);
10964            d.build_visual(width);
10965            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
10966            assert!(stops.len() > 20, "fixture should have plenty of stops");
10967            for start in stops {
10968                for (name, motion) in &motions {
10969                    d.caret = start;
10970                    d.anchor = None;
10971                    motion(&mut d);
10972                    assert!(
10973                        d.vmap.is_stop(d.caret),
10974                        "{name} from {start} at width {width} landed at {} — not a caret stop",
10975                        d.caret
10976                    );
10977                }
10978            }
10979        }
10980    }
10981
10982    #[test]
10983    fn no_wysiwyg_motion_is_a_dead_end() {
10984        // Down held to the bottom of a document reaches the bottom, and Up held
10985        // to the top reaches the top — from anywhere, at a width that wraps. The
10986        // invariant above says a motion lands somewhere legal; this one says it
10987        // gets somewhere at all, which is what a caret pinned at a wrap boundary
10988        // was quietly failing to do while every assertion around it held.
10989        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
10990                   - item one two three four five\n\nlast\n";
10991        for width in [80, 12] {
10992            let mut d = wysiwyg_doc("no_dead_end", src);
10993            d.build_visual(width);
10994            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
10995            let (first, last) = (stops[0], stops[stops.len() - 1]);
10996            for &start in &stops {
10997                for (name, motion, want) in [
10998                    ("down", (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc), last),
10999                    ("up", |d: &mut Doc| d.move_up(false), first),
11000                ] {
11001                    d.caret = start;
11002                    d.anchor = None;
11003                    d.goal_col = None;
11004                    // Every row, plus the presses the edges take, plus slack.
11005                    for _ in 0..d.vmap.num_rows() + 4 {
11006                        motion(&mut d);
11007                    }
11008                    assert_eq!(
11009                        d.caret, want,
11010                        "{name} held from {start} at width {width} never arrived"
11011                    );
11012                }
11013            }
11014        }
11015    }
11016    // ── display columns ──────────────────────────────────────────────────────
11017    // A `col` is a terminal cell, not a character. The two are the same number
11018    // for the ASCII the fixtures above are written in, which is how they came
11019    // apart in the first place: `你` is one character drawn in two cells, so a
11020    // column counted in characters names a cell the text isn't in — one earlier
11021    // for every wide character to its left.
11022
11023    #[test]
11024    fn a_wide_character_is_two_columns_wide() {
11025        // The reproduction: `你` is one char and two cells, so the caret just
11026        // past it drew at column 1 — inside the character it had already left.
11027        for (view, tag) in VIEWS {
11028            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
11029            d.caret = "你".len();
11030            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
11031            d.caret = "你好".len();
11032            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
11033        }
11034    }
11035
11036    #[test]
11037    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
11038        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
11039        // two-cell — measuring six cells one at a time, but the character they
11040        // spell is drawn in two. Width belongs to the cluster, not the glyph,
11041        // and the frontends measure it the same way.
11042        let family = "👨‍👩‍👧";
11043        for (view, tag) in VIEWS {
11044            let src = format!("a{family}b\n");
11045            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
11046            d.caret = 1 + family.len();
11047            assert_eq!(d.caret_pos(), (0, 3), "{tag}: 'a' is one cell, the family two");
11048        }
11049    }
11050
11051    #[test]
11052    fn both_cells_of_a_wide_character_mean_the_character() {
11053        // Clicking the far half of `好` is still clicking `好`: half a character
11054        // is not a place the caret can be, so it comes to rest at the
11055        // character's start — the column it would have been drawn at anyway.
11056        for (view, tag) in VIEWS {
11057            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
11058            for col in [2, 3] {
11059                d.caret = 0;
11060                d.click(0, col, false);
11061                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
11062                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
11063            }
11064            // Past the last cell is the line's end, as it is for ASCII.
11065            d.click(0, 9, false);
11066            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
11067        }
11068    }
11069
11070    #[test]
11071    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
11072        // The mapping is only a mapping if it inverts: the cell the caret is
11073        // drawn in has to be the cell that brings it back to the same offset.
11074        // Over a fixture where a character may be one cell or two, and one
11075        // codepoint or five.
11076        use unicode_segmentation::UnicodeSegmentation;
11077
11078        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
11079
11080        let mut d = doc_in(View::Source, "roundtrip_source", src);
11081        // Every offset the source view's caret can occupy: it steps by grapheme
11082        // cluster, so those are its boundaries.
11083        for (off, _) in src.grapheme_indices(true).chain(std::iter::once((src.len(), ""))) {
11084            d.caret = off;
11085            let (row, col) = d.caret_pos();
11086            d.click(row, col, false);
11087            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
11088        }
11089
11090        // And in WYSIWYG, where the offsets the caret can occupy are the map's
11091        // stops rather than every boundary.
11092        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
11093        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11094        assert!(stops.len() > 20, "fixture should have plenty of stops");
11095        for off in stops {
11096            d.caret = off;
11097            let (row, col) = d.caret_pos();
11098            d.click(row, col, false);
11099            assert_eq!(d.caret, off, "wysiwyg: {off} → ({row}, {col}) → {}", d.caret);
11100        }
11101    }
11102
11103    #[test]
11104    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
11105        // Down from under `世` lands under the glyph in that cell, not two
11106        // characters further along the line. The goal is a column, so a line of
11107        // wide characters and a line of ASCII line up the way they're drawn.
11108        //
11109        // The gap differs by view: a bare newline inside a paragraph is a soft
11110        // break, which WYSIWYG draws as a space on a single row. The views share
11111        // a grid only where the source's lines are the renderer's rows too.
11112        for (view, tag) in VIEWS {
11113            let gap = if view == View::Source { "\n" } else { "\n\n" };
11114            let src = format!("你好世{gap}abcdef\n");
11115            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
11116            d.caret = "你好".len();
11117            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
11118            d.move_down(false);
11119            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
11120            assert!(d.source[d.caret..].starts_with('e'), "{tag}: landed on the wrong glyph");
11121        }
11122    }
11123
11124    #[test]
11125    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
11126        // Down from column 3 onto `你好`, whose characters start at columns 0
11127        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
11128        // between the cells of one character, so the caret rests on it — and on
11129        // its start, which is the only offset there that is a caret stop.
11130        for (view, tag) in VIEWS {
11131            let gap = if view == View::Source { "\n" } else { "\n\n" };
11132            let src = format!("abcdef{gap}你好\n");
11133            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
11134            let line = src.find('你').unwrap();
11135            d.caret = 3;
11136            d.move_down(false);
11137            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
11138            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
11139        }
11140    }
11141
11142    #[test]
11143    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
11144        // The column the cell's text is laid out in is measured in cells, so the
11145        // caret walking that text has to be too — the two agreeing is the whole
11146        // point of the grid staying square.
11147        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
11148        let at = d.source.find("你").unwrap();
11149        d.caret = at;
11150        let (row, col) = d.caret_pos();
11151        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
11152        // cells further along.
11153        assert_eq!(col, 2, "the cell's first character");
11154        d.move_right(false);
11155        assert_eq!(d.caret_pos(), (row, 4), "`好` is drawn past `你`'s two cells");
11156        assert_eq!(d.caret, at + "你".len());
11157    }
11158
11159    // ── active inline marks ───────────────────────────────────────────────────
11160
11161    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
11162    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
11163        let (src, caret) = parse_caret(marked);
11164        let mut d = doc_in(view, name, &src);
11165        d.caret = caret;
11166        d.active_inline_marks().iter().collect()
11167    }
11168
11169    /// The marks over the selection `[start, end)`.
11170    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
11171        let mut d = doc_in(view, name, src);
11172        d.anchor = Some(start);
11173        d.caret = end;
11174        d.active_inline_marks().iter().collect()
11175    }
11176
11177    #[test]
11178    fn a_caret_in_a_mark_reports_it() {
11179        for (view, tag) in VIEWS {
11180            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
11181            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
11182            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
11183            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
11184            // Plain text under no mark lights nothing — the toolbar's resting state.
11185            assert_eq!(m("a| **bold** b"), [], "{tag}");
11186            assert!(m("plain t|ext").is_empty(), "{tag}");
11187        }
11188    }
11189
11190    #[test]
11191    fn nested_marks_all_report() {
11192        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
11193        // the ancestor chain is a chain, and every mark on it is in force.
11194        for (view, tag) in VIEWS {
11195            assert_eq!(
11196                marks(view, &format!("marks_nested_{tag}"), "**bold and *bo|th*** end"),
11197                [InlineKind::Strong, InlineKind::Emph],
11198                "{tag}"
11199            );
11200        }
11201    }
11202
11203    #[test]
11204    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
11205        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
11206        // the first byte of its text and the byte after its last — both inside
11207        // the mark's span, both places typing lands inside the bold. The offset
11208        // past the closing delimiter is the next text, and reports nothing.
11209        let src = "a **bold** b";
11210        let inner_start = src.find("bold").unwrap(); // 4
11211        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
11212        for (view, tag) in VIEWS {
11213            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
11214            for off in [2, 3, inner_start, inner_end, 9] {
11215                d.caret = off;
11216                assert!(
11217                    d.active_inline_marks().contains(InlineKind::Strong),
11218                    "{tag}: offset {off} is inside the strong span"
11219                );
11220            }
11221            for off in [0, 1, 10, 11, 12] {
11222                d.caret = off;
11223                assert!(
11224                    !d.active_inline_marks().contains(InlineKind::Strong),
11225                    "{tag}: offset {off} is outside the strong run"
11226                );
11227            }
11228        }
11229    }
11230
11231    #[test]
11232    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
11233        // Regression: twig resolves an offset that is one node's end and the
11234        // next one's start to the node that *starts* there, so `**bold**|\n`
11235        // isn't bold. With nothing following there's no tie to break and the
11236        // chain still ended at the mark, which made a trailing `\n` — not the
11237        // text — decide whether the caret after a bold word reported bold. It's
11238        // the offset past the mark either way, and typing there is plain either
11239        // way. A blank document typed into is exactly this shape.
11240        for (view, tag) in VIEWS {
11241            let m = |name: String, marked| marks(view, &name, marked);
11242            assert_eq!(m(format!("marks_eob_{tag}"), "**bold**|"), [], "{tag}: no trailing newline");
11243            assert_eq!(m(format!("marks_eol_{tag}"), "**bold**|\n"), [], "{tag}: with one");
11244            // And the last offset that *is* in the mark still is.
11245            assert_eq!(
11246                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
11247                [InlineKind::Strong],
11248                "{tag}"
11249            );
11250        }
11251    }
11252
11253    #[test]
11254    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
11255        let src = "a **bold** b";
11256        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
11257        for (view, tag) in VIEWS {
11258            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
11259            // The whole bold word, and a slice of it.
11260            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
11261            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
11262            // Ending exactly at the closing delimiter's start is still all-bold:
11263            // an exclusive end sits *past* the last selected character, so the
11264            // question is asked of the character, not the boundary.
11265            assert_eq!(m(b, d_ + 2), [InlineKind::Strong], "{tag}: through the close");
11266            // Half in, half out: Bold lit here would claim a press turns it off.
11267            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
11268            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
11269        }
11270    }
11271
11272    #[test]
11273    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
11274        // Both ends are bold, but the space between them isn't — two runs are two
11275        // nodes, which is exactly what the node id catches and a kind-only
11276        // comparison would not.
11277        let src = "**one** **two**";
11278        for (view, tag) in VIEWS {
11279            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
11280            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
11281        }
11282    }
11283
11284    #[test]
11285    fn marks_read_the_document_as_it_is_edited() {
11286        // The point of asking twig every frame instead of caching: the answer has
11287        // to follow the toggle that changed it.
11288        let mut d = wysiwyg_doc("marks_live", "one two\n");
11289        d.anchor = Some(0);
11290        d.caret = 3;
11291        assert!(d.active_inline_marks().is_empty(), "plain to start");
11292        d.toggle(InlineKind::Strong);
11293        assert_eq!(d.source, "**one** two\n");
11294        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
11295        assert!(d.active_inline_marks().contains(InlineKind::Strong));
11296        d.toggle(InlineKind::Strong);
11297        assert!(d.active_inline_marks().is_empty(), "and off again");
11298    }
11299
11300    #[test]
11301    fn a_link_is_not_an_inline_mark() {
11302        // `link`/`str` are inline nodes, but nothing on the inline toolbar
11303        // toggles them — a set with a "link mark" in it would have no button.
11304        for (view, tag) in VIEWS {
11305            assert_eq!(marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"), [], "{tag}");
11306        }
11307    }
11308
11309    // ── blank documents ───────────────────────────────────────────────────────
11310
11311    #[test]
11312    fn a_blank_document_is_untitled_empty_and_markdown() {
11313        let mut d = Doc::blank().unwrap();
11314        assert!(d.is_untitled());
11315        assert_eq!(d.path, PathBuf::new());
11316        assert_eq!(d.file_name(), "untitled", "the header has to show something");
11317        assert_eq!(d.format_name(), "markdown");
11318        assert_eq!(d.source, "");
11319        assert!(!d.dirty, "nothing typed yet is nothing to lose");
11320        assert_eq!(d.disk_state(), DiskState::Untitled);
11321        // And it's a document you can be in: the default view renders it.
11322        d.build_visual(80);
11323        assert_eq!(d.caret, 0);
11324    }
11325
11326    #[test]
11327    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
11328        let mut d = Doc::blank().unwrap();
11329        d.insert("hello");
11330        assert!(d.dirty);
11331        d.save();
11332        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
11333        assert!(d.dirty, "it must not come away believing it saved");
11334        assert!(d.is_untitled(), "and it still has no file");
11335    }
11336
11337    #[test]
11338    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
11339        let p = temp_path("blank_save_as");
11340        let mut d = Doc::blank().unwrap();
11341        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
11342        // be kept literal (`\#`); this test is about save-as, not escaping (which
11343        // has its own test), so it types nothing that escaping would touch.
11344        d.insert("hi");
11345        d.save_as(p.clone());
11346        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
11347        assert!(!d.is_untitled());
11348        assert!(!d.dirty);
11349        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
11350        assert_eq!(d.disk_state(), DiskState::Unchanged, "the watermark is stamped");
11351        // And ⌘S is a plain save from here on.
11352        d.insert("!");
11353        d.save();
11354        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
11355        let _ = std::fs::remove_file(&p);
11356    }
11357
11358    // ── save as ───────────────────────────────────────────────────────────────
11359
11360    /// A unique path in the temp dir that no fixture wrote — a Save As target.
11361    fn temp_path(name: &str) -> PathBuf {
11362        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11363        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
11364        let mut p = std::env::temp_dir();
11365        p.push(format!("leaf_test_target_{name}_{seq}.md"));
11366        let _ = std::fs::remove_file(&p);
11367        p
11368    }
11369
11370    #[test]
11371    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
11372        let mut d = doc_with("save_as_move", "original\n");
11373        let old = d.path.clone();
11374        let new = temp_path("save_as_move");
11375        d.insert("edited: ");
11376        d.save_as(new.clone());
11377
11378        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
11379        assert_eq!(
11380            std::fs::read_to_string(&old).unwrap(),
11381            "original\n",
11382            "Save As doesn't touch the file it came from"
11383        );
11384        assert_eq!(d.path, new, "the document moved");
11385        assert!(!d.dirty);
11386        assert_eq!(d.status.as_deref(), Some(&*format!("saved {}", d.file_name())));
11387
11388        // Every later save follows it, which is the whole difference from a copy.
11389        d.caret = 0;
11390        d.insert("re-");
11391        d.save();
11392        assert_eq!(std::fs::read_to_string(&new).unwrap(), "re-edited: original\n");
11393        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
11394        let _ = std::fs::remove_file(&new);
11395    }
11396
11397    #[test]
11398    fn save_as_overwrites_an_existing_target() {
11399        // The picker already asked; asking again down here is the same question
11400        // twice, and the second one has no way to be answered.
11401        let new = temp_path("save_as_over");
11402        std::fs::write(&new, "theirs\n").unwrap();
11403        let mut d = doc_with("save_as_over", "ours\n");
11404        d.save_as(new.clone());
11405        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
11406        let _ = std::fs::remove_file(&new);
11407    }
11408
11409    #[test]
11410    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
11411        let mut d = doc_with("save_as_fail", "body\n");
11412        let old = d.path.clone();
11413        d.insert("x");
11414        // A directory that doesn't exist: the write can't land.
11415        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
11416        d.save_as(bad);
11417
11418        assert_eq!(d.path, old, "the document must not move to a file that isn't there");
11419        assert!(d.dirty, "and must not believe it saved");
11420        assert!(
11421            d.status.as_deref().unwrap().starts_with("save failed:"),
11422            "the same failure a plain save reports, got {:?}",
11423            d.status
11424        );
11425        // The original is still the document's file, and still saveable.
11426        d.save();
11427        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
11428        assert!(!d.dirty);
11429    }
11430
11431    #[test]
11432    fn save_as_renames_without_reparsing_the_format() {
11433        // `.dj` on the name doesn't make the buffer djot: it was parsed as
11434        // Markdown and still is, and saying otherwise would be a conversion the
11435        // user never asked for (and an undo history thrown away to do it).
11436        let mut d = doc_with("save_as_format", "**b**\n");
11437        let mut new = temp_path("save_as_format");
11438        new.set_extension("dj");
11439        d.save_as(new.clone());
11440        assert_eq!(d.format_name(), "markdown");
11441        let _ = std::fs::remove_file(&new);
11442    }
11443
11444    // ── external change / reload ──────────────────────────────────────────────
11445
11446    #[test]
11447    fn an_untouched_file_reports_unchanged() {
11448        let mut d = doc_with("disk_clean", "body\n");
11449        assert_eq!(d.disk_state(), DiskState::Unchanged);
11450        // Editing the buffer is not editing the file.
11451        d.insert("x");
11452        assert_eq!(d.disk_state(), DiskState::Unchanged);
11453        assert!(d.dirty);
11454        // Saving re-stamps the watermark rather than reporting our own bytes back.
11455        d.save();
11456        assert_eq!(d.disk_state(), DiskState::Unchanged);
11457    }
11458
11459    #[test]
11460    fn a_file_written_underneath_reports_changed() {
11461        let mut d = doc_with("disk_changed", "body\n");
11462        std::fs::write(&d.path, "someone else\n").unwrap();
11463        assert_eq!(d.disk_state(), DiskState::Changed);
11464        // Dirty *and* changed is the clobber: both halves are readable, and
11465        // leaf-core takes neither side.
11466        d.insert("x");
11467        assert!(d.dirty && d.disk_state() == DiskState::Changed);
11468        // Saving anyway is allowed — the frontend asked, or chose not to.
11469        d.save();
11470        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
11471        assert_eq!(d.disk_state(), DiskState::Unchanged);
11472    }
11473
11474    #[test]
11475    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
11476        // The hash is what makes this honest: the file was written (a fresh
11477        // mtime), and nothing about the document is stale.
11478        let d = doc_with("disk_same_bytes", "body\n");
11479        std::fs::write(&d.path, "body\n").unwrap();
11480        assert_eq!(d.disk_state(), DiskState::Unchanged);
11481    }
11482
11483    #[test]
11484    fn a_deleted_file_reports_missing() {
11485        let mut d = doc_with("disk_missing", "body\n");
11486        std::fs::remove_file(&d.path).unwrap();
11487        assert_eq!(d.disk_state(), DiskState::Missing);
11488        // A save recreates it, and the document is whole again.
11489        d.save();
11490        assert_eq!(d.disk_state(), DiskState::Unchanged);
11491        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
11492    }
11493
11494    #[test]
11495    fn reload_replaces_the_document_with_the_file() {
11496        for (view, tag) in VIEWS {
11497            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
11498            d.insert("edited ");
11499            assert!(d.dirty);
11500            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
11501            d.reload();
11502
11503            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
11504            assert!(!d.dirty, "{tag}: the file is what we have");
11505            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
11506            assert_eq!(d.status.as_deref(), Some(&*format!("reloaded {}", d.file_name())));
11507            // The reloaded tree is live, not the old parse.
11508            d.caret = d.source.find("three").unwrap();
11509            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
11510        }
11511    }
11512
11513    #[test]
11514    fn reload_clamps_the_caret_and_drops_the_selection() {
11515        let mut d = doc_with("reload_caret", "a long first line\n");
11516        d.caret = 12;
11517        d.anchor = Some(4);
11518        std::fs::write(&d.path, "short\n").unwrap();
11519        d.reload();
11520        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
11521        assert_eq!(d.anchor, None, "a selection over bytes that changed is a lie");
11522        assert!(d.selection().is_none());
11523
11524        // A caret the file still has room for stays put.
11525        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
11526        d.caret = 2;
11527        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
11528        d.reload();
11529        assert_eq!(d.caret, 2);
11530    }
11531
11532    #[test]
11533    fn reload_drops_the_undo_history() {
11534        // twig's stack belongs to the buffer, and these are different bytes:
11535        // replaying a step recorded against the old ones would corrupt the file.
11536        let mut d = doc_with("reload_undo", "body\n");
11537        d.insert("x");
11538        std::fs::write(&d.path, "replaced\n").unwrap();
11539        d.reload();
11540        d.undo();
11541        assert_eq!(d.source, "replaced\n", "an undo must not resurrect the old buffer");
11542        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
11543    }
11544
11545    #[test]
11546    fn a_reload_that_cant_read_leaves_the_document_alone() {
11547        let mut d = doc_with("reload_gone", "body\n");
11548        d.insert("x");
11549        std::fs::remove_file(&d.path).unwrap();
11550        d.reload();
11551        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
11552        assert!(d.dirty);
11553        assert!(d.status.as_deref().unwrap().starts_with("reload failed:"), "{:?}", d.status);
11554
11555        // And an untitled document has nothing to reload from.
11556        let mut d = Doc::blank().unwrap();
11557        d.insert("typed");
11558        d.reload();
11559        assert_eq!(d.source, "typed");
11560        assert_eq!(d.status.as_deref(), Some("no file to reload"));
11561    }
11562}
11563
11564/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
11565/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
11566/// twig applies writing the mark out, so the toolbar can light the same button
11567/// that made the node.
11568///
11569/// `None` for every other kind, including the inline nodes that aren't marks at
11570/// all (`str`, `link`, `image`, the math and break kinds): they're things a
11571/// caret stands in, not formatting a button toggles.
11572fn inline_kind(kind: &Kind) -> Option<InlineKind> {
11573    Some(match kind {
11574        Kind::Strong => InlineKind::Strong,
11575        Kind::Emph => InlineKind::Emph,
11576        Kind::Verbatim => InlineKind::Verbatim,
11577        Kind::Mark => InlineKind::Mark,
11578        Kind::Superscript => InlineKind::Superscript,
11579        Kind::Subscript => InlineKind::Subscript,
11580        Kind::Insert => InlineKind::Insert,
11581        Kind::Delete => InlineKind::Delete,
11582        _ => return None,
11583    })
11584}
11585
11586/// A watermark for a file's contents (see `Doc::disk_hash`).
11587///
11588/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
11589/// watermark is compared only against one taken by the same process moments
11590/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
11591/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
11592fn hash_bytes(bytes: &[u8]) -> u64 {
11593    use std::hash::{Hash, Hasher};
11594    let mut h = std::collections::hash_map::DefaultHasher::new();
11595    bytes.hash(&mut h);
11596    h.finish()
11597}
11598
11599#[cfg(feature = "fs")]
11600fn detect_format(path: &Path) -> Result<Format> {
11601    let ext = path
11602        .extension()
11603        .and_then(|e| e.to_str())
11604        .unwrap_or("")
11605        .to_ascii_lowercase();
11606    Ok(match ext.as_str() {
11607        "dj" | "djot" => Format::Djot,
11608        "md" | "markdown" => Format::Markdown,
11609        "xml" => Format::Xml,
11610        "html" | "htm" => Format::Html,
11611        other => return Err(anyhow!("unknown document extension: .{other}")),
11612    })
11613}