leaf_core/doc.rs
1//! The document model: a `twig::Editor` plus a byte-offset caret and selection.
2//!
3//! Where bough moves a selection through the *tree*, leaf moves a *caret*
4//! through the *characters* — a normal text editor's model — and expresses
5//! every mutation as one of twig's offset-addressed ops:
6//!
7//! - typing / delete → `edit_range(start, end, text)` (P0)
8//! - re-anchoring → the returned `Change` (P1)
9//! - cursor context → `node_at` / `ancestors_at` (P3)
10//! - the toolbar → `wrap_range`/`toggle_inline`/`set_block`,
11//! `toggle_block_container`/`insert_link` (P5)
12//!
13//! twig reparses after every edit and leaves everything outside the splice
14//! byte-for-byte untouched, so the document stays a live, navigable AST while
15//! you type into it.
16
17// `PathBuf` names the `path` field and the untitled marker on every build;
18// `Path` is only touched by the filesystem I/O gated behind the `fs` feature.
19// The docs in this file lay their `- key → meaning` lists out in aligned
20// columns, which puts a continuation line further right than clippy's
21// list-indent rule likes. A lazy continuation renders as the same paragraph
22// either way, and the alignment is what makes those tables readable, so the
23// layout wins over the lint.
24#![allow(clippy::doc_overindented_list_items)]
25
26use std::collections::HashMap;
27use std::ops::Range;
28#[cfg(feature = "fs")]
29use std::path::Path;
30use std::path::PathBuf;
31
32#[cfg(feature = "fs")]
33use anyhow::Context;
34use anyhow::{Result, anyhow};
35use twig::{
36 Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture,
37 InlineKind, Kind, MarkdownExtensions, NodeId, QueryMatch,
38};
39use unicode_segmentation::GraphemeCursor;
40
41use crate::html;
42use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
43
44/// Which view the body shows.
45#[derive(Clone, Copy, PartialEq, Eq, Debug)]
46pub enum View {
47 /// The raw document with a caret in source bytes.
48 Source,
49 /// Markup resolved to real styles, caret riding the rendered glyphs.
50 Wysiwyg,
51}
52
53/// How much of the source markup the WYSIWYG view exposes — a per-editor
54/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
55/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
56/// terms, and every rung below is about *delimiters*, whatever grammar spells
57/// them. The examples are Markdown only because that is what most documents are.
58///
59/// A single ladder over two underlying axes, because only three of their four
60/// combinations are coherent:
61///
62/// | | authoring off | authoring on |
63/// |---|---|---|
64/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
65/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
66///
67/// The empty quadrant would show delimiters on the caret's line and then escape
68/// the ones you type — a surface that displays a syntax it refuses to accept.
69/// Someone who wants to read raw markup without authoring it has
70/// [`View::Source`], which is the better tool for it.
71///
72/// The two axes are read separately by the code that cares — see
73/// [`reveals_caret_line`](Self::reveals_caret_line) and
74/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
75/// as a ladder.
76#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
77pub enum MarkupMode {
78 /// Delimiters stay hidden even on the caret's line, and typed syntax stays
79 /// literal — twig escapes anything that would open markup, so formatting
80 /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
81 /// reading surface for people who don't write markup by hand; the default,
82 /// and what Diaryx ships.
83 #[default]
84 None,
85 /// Delimiters stay hidden, but typing them authors real markup: `*x*`
86 /// becomes italic and the asterisks disappear into the styling
87 /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
88 /// clean surface back once it has been applied.
89 Shortcuts,
90 /// The caret's line shows its raw markup while every other line renders
91 /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
92 /// — for people fluent in the document's grammar who want to see and edit
93 /// the delimiters they type.
94 Full,
95}
96
97impl MarkupMode {
98 /// Whether the rich view shows raw delimiters on the line holding the caret.
99 /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
100 /// WYSIWYG builder.
101 pub fn reveals_caret_line(self) -> bool {
102 matches!(self, MarkupMode::Full)
103 }
104
105 /// Whether typed markup characters author real formatting. The editing axis
106 /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
107 pub fn authors(self) -> bool {
108 !matches!(self, MarkupMode::None)
109 }
110}
111
112/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
113/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
114/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
115/// either flow. The renderer consults it when it lays a block's inline content
116/// into visual rows.
117#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
118pub enum LineFlow {
119 /// A soft break folds into a space and the paragraph reflows to the
120 /// viewport width — flowing prose, where the source's line wrapping is
121 /// insignificant. The default, and what Diaryx ships.
122 #[default]
123 Fold,
124 /// A soft break renders as a line break exactly where it was written, so
125 /// the author's source line structure shows on screen unchanged — the mode
126 /// for people who lay out their prose deliberately (one sentence or clause
127 /// per line, semantic line breaks). The break is still a soft break in the
128 /// source; only its rendering changes.
129 Preserve,
130}
131
132/// What the file behind a document looks like right now, against the bytes leaf
133/// last read from it or wrote to it — the question a frontend asks before it
134/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
135/// happen) or when its window regains focus. See [`Doc::disk_state`].
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum DiskState {
138 /// The file holds exactly the bytes leaf last read or wrote.
139 Unchanged,
140 /// Someone else wrote the file since. Saving overwrites their work; see
141 /// [`Doc::reload`] for the other direction.
142 Changed,
143 /// The file is gone — deleted or renamed away. A save recreates it.
144 Missing,
145 /// There is a path, but the file couldn't be read (permissions, a directory
146 /// in the way): leaf can't tell, and won't guess.
147 Unreadable,
148 /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
149 /// changed under a document that was never on disk.
150 Untitled,
151}
152
153/// The inline marks in force at a point in the document — what a toolbar
154/// lights up. A `Copy` bitset rather than a `HashSet`, because
155/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
156/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
158pub struct InlineMarks(u8);
159
160impl InlineMarks {
161 /// Every kind, in the order [`InlineMarks::iter`] yields them.
162 const ALL: [InlineKind; 8] = [
163 InlineKind::Strong,
164 InlineKind::Emph,
165 InlineKind::Verbatim,
166 InlineKind::Mark,
167 InlineKind::Superscript,
168 InlineKind::Subscript,
169 InlineKind::Insert,
170 InlineKind::Delete,
171 ];
172
173 pub const fn empty() -> Self {
174 InlineMarks(0)
175 }
176
177 /// Private: the set is an *answer*, and adding a mark to it doesn't mark
178 /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
179 fn insert(&mut self, kind: InlineKind) {
180 self.0 |= Self::bit(kind);
181 }
182
183 /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
184 fn flip(&mut self, kind: InlineKind) {
185 self.0 ^= Self::bit(kind);
186 }
187
188 /// The symmetric difference: which marks differ between the two sets. Used
189 /// to resolve the marks already in force at the caret against the pending
190 /// delta — a bit set in the delta flips the base mark for the next keystroke.
191 fn xor(self, other: InlineMarks) -> InlineMarks {
192 InlineMarks(self.0 ^ other.0)
193 }
194
195 /// Whether `kind` is in force — the toolbar's "is Bold active?".
196 pub fn contains(self, kind: InlineKind) -> bool {
197 self.0 & Self::bit(kind) != 0
198 }
199
200 pub fn is_empty(self) -> bool {
201 self.0 == 0
202 }
203
204 /// The marks in force, for a frontend that renders whatever is on rather
205 /// than asking after a fixed list.
206 pub fn iter(self) -> impl Iterator<Item = InlineKind> {
207 Self::ALL.into_iter().filter(move |&k| self.contains(k))
208 }
209
210 fn bit(kind: InlineKind) -> u8 {
211 1 << match kind {
212 InlineKind::Strong => 0,
213 InlineKind::Emph => 1,
214 InlineKind::Verbatim => 2,
215 InlineKind::Mark => 3,
216 InlineKind::Superscript => 4,
217 InlineKind::Subscript => 5,
218 InlineKind::Insert => 6,
219 InlineKind::Delete => 7,
220 }
221 }
222}
223
224impl FromIterator<InlineKind> for InlineMarks {
225 fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
226 let mut m = InlineMarks::empty();
227 for k in iter {
228 m.insert(k);
229 }
230 m
231 }
232}
233
234/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
235/// into one undo step (a run of typed characters undoes together); `Other` never
236/// coalesces, so a paste, format toggle, or block change is always its own step.
237#[derive(Clone, Copy, PartialEq, Eq)]
238enum EditKind {
239 Insert,
240 Delete,
241 /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
242 /// rather than `Insert`'s because a composition is not typing: each step
243 /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
244 /// though no two steps insert the same bytes, and it must not fold into the
245 /// typed characters on either side of it.
246 Compose,
247 Other,
248}
249
250/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
251/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
252/// the caret), `Forward` is Delete (one starting at it).
253#[derive(Clone, Copy)]
254enum BreakEdge {
255 Backward,
256 Forward,
257}
258
259/// A re-spelling of one inline mark run, held ready in case the edit about to
260/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
261/// Every offset in it is in the coordinates the document will have *after* the
262/// plain edit, since that is when it may be applied.
263struct MarkEdgeFix {
264 /// The run's kind, and an offset inside what was its content: together they
265 /// answer "did the plain edit actually break this mark?" — the question that
266 /// decides whether any of this is applied at all.
267 kind: InlineKind,
268 probe: usize,
269 /// The byte range to re-spell (the run's delimiters included) and its new
270 /// spelling, with the edge whitespace moved outside the delimiters.
271 start: usize,
272 end: usize,
273 text: String,
274 /// Where the caret belongs afterwards — the same place on screen it would
275 /// have had, which is now on the other side of a delimiter.
276 caret: usize,
277 /// The marks in force for text typed at that caret. The caret can land
278 /// outside a run it was inside, and the marks have to survive the move or
279 /// the toolbar goes dark mid-word.
280 want: InlineMarks,
281}
282
283/// The caret and selection at one moment — the part of a history step twig's
284/// `Change` cannot carry, because the caret is leaf's state and twig only knows
285/// about bytes. leaf serializes it into the opaque per-state blob twig now
286/// stores in its own undo history (see `record_caret`), so undo and redo hand
287/// back the caret that matches the source they restore.
288#[derive(Clone, Copy)]
289struct CaretState {
290 caret: usize,
291 anchor: Option<usize>,
292}
293
294impl CaretState {
295 /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
296 /// then an anchor-present flag and the anchor. twig copies these bytes and
297 /// never reads them.
298 fn to_blob(self) -> [u8; 17] {
299 let mut b = [0u8; 17];
300 b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
301 if let Some(a) = self.anchor {
302 b[8] = 1;
303 b[9..].copy_from_slice(&(a as u64).to_le_bytes());
304 }
305 b
306 }
307
308 /// Recover a state from twig's blob, or `None` when it is empty or the wrong
309 /// length — a state twig restored that never had a caret set on it, which
310 /// leaves the caller to fall back to the edit site.
311 fn from_blob(b: &[u8]) -> Option<Self> {
312 let b: &[u8; 17] = b.try_into().ok()?;
313 let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
314 let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
315 Some(CaretState { caret, anchor })
316 }
317}
318
319/// A footnote reference and the note it names — the answer to
320/// [`Doc::footnote_at`].
321///
322/// The two `Option`s move together: a reference whose definition is missing has
323/// neither a body to show nor a place to jump to, and one that resolved has
324/// both.
325#[derive(Clone, PartialEq, Eq, Debug)]
326pub struct FootnoteRef {
327 /// The reference's label — the `1` of `[^1]`, with neither the `^` that
328 /// spells it a footnote nor the brackets around it.
329 pub label: String,
330 /// The note's body as source bytes (see
331 /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
332 /// document defines no `[^label]:` to read one from.
333 pub text: Option<String>,
334 /// Where the note's *body* starts, for a "go to note" that moves the caret
335 /// there. `None` alongside a `None` `text`.
336 ///
337 /// The body rather than the definition, because this is an offset to put a
338 /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
339 /// aiming at the definition's first byte snaps to the nearest real stop,
340 /// which is up in the paragraph above the note. It is also simply where a
341 /// reader following a reference wants to land: at the note's first word,
342 /// ready to read or amend it.
343 pub offset: Option<usize>,
344 /// Where the note's body ends, exclusive — so a frontend can ask which
345 /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
346 ///
347 /// The rows are the note with its markup resolved: `see *later*` reaches a
348 /// frontend as an italic run, not as asterisks. `text` is the source bytes
349 /// and stays the honest answer for anything that wants the note as written
350 /// (a search index, a copy); this pair of offsets is for anything that wants
351 /// it as *read*. `None` alongside a `None` `offset`.
352 pub end: Option<usize>,
353}
354
355/// A footnote definition and the reference that sends a reader to it — the
356/// answer to [`Doc::footnote_definition_at`], and the other half of the round
357/// trip [`FootnoteRef`] starts.
358///
359/// A note is a place a reader *arrives*, so the useful thing to know while
360/// standing in one is the way back. Without this the jump to a note is a
361/// one-way door: the definitions sit at the foot of the document, so returning
362/// by hand means scrolling back up and finding the sentence again.
363#[derive(Clone, PartialEq, Eq, Debug)]
364pub struct FootnoteDef {
365 /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
366 /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
367 pub label: String,
368 /// Where the reference's *label* is, for a "back to reference" that moves
369 /// the caret there. `None` for a note nothing refers to — an orphan, which
370 /// is worth being able to say rather than silently doing nothing.
371 ///
372 /// The label rather than the reference's first byte, for
373 /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
374 /// and its label is the only part of it the caret can rest on.
375 ///
376 /// The *first* reference, when a label is cited more than once: a repeated
377 /// citation has no one true home, and the first is both the one a reader
378 /// most likely came from and the only choice that doesn't depend on how
379 /// they got here.
380 pub offset: Option<usize>,
381}
382
383/// Where a locator lands — the answer to [`Doc::locate`].
384///
385/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
386/// document, and a place is a span rather than a point: a reader following one
387/// wants the caret at its first byte, and a reader merely *peeking* at one wants
388/// the block it covers drawn. Both are served by carrying the whole span, and
389/// only one of the two can be recovered from an offset alone.
390#[derive(Clone, PartialEq, Eq, Debug)]
391pub struct Landing {
392 /// The first byte of the block the locator names — where a caret goes.
393 pub start: usize,
394 /// One past its last byte, so a frontend can map the pair through
395 /// [`Doc::pos_for_offset`] to the rendered rows the block occupies and draw
396 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
397 pub end: usize,
398}
399
400pub struct Doc {
401 editor: Editor,
402 pub format: Format,
403 pub path: PathBuf,
404 /// Current source, refreshed from the editor after every successful edit.
405 pub source: String,
406 /// The caret, as a byte offset into `source` (always on a char boundary).
407 pub caret: usize,
408 /// The selection's fixed end, if a selection is active; the moving end is
409 /// the caret. `None` means no selection.
410 pub anchor: Option<usize>,
411 pub dirty: bool,
412 pub status: Option<String>,
413 pub view: View,
414 /// How much of the source markup the rich view exposes — a frontend preference (see
415 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
416 /// [`reveal_line`](Self::reveal_line), the editing one by
417 /// [`insert`](Self::insert).
418 markup_mode: MarkupMode,
419 /// Whether soft breaks fold into the reflowed paragraph or render where
420 /// they were written (see [`LineFlow`]) — an independent frontend
421 /// preference the WYSIWYG builder consults when it lays out a block.
422 line_flow: LineFlow,
423 /// The kind of the last edit, for coalescing: twig owns the undo *history*
424 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
425 /// call, so leaf decides when a run continues and tells twig to coalesce.
426 last_edit_kind: Option<EditKind>,
427 /// The inline marks the user has toggled *at a collapsed caret* with no
428 /// selection — "start typing bold here". Held as the XOR delta from the marks
429 /// already in force at [`pending_at`](Self::pending_at): a set bit means
430 /// "flip this kind for the next typed text", so it both turns a mark on where
431 /// none is (type into bold) and off where one already covers the caret (type
432 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
433 /// freshly typed text and then clears it — a mark once realised is carried by
434 /// the caret sitting inside the run, not by this delta.
435 pending_marks: InlineMarks,
436 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
437 /// delta is live only while the caret still stands here with no selection;
438 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
439 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
440 pending_at: Option<usize>,
441 /// The source as of the last open/save — `dirty` is `source != clean_source`,
442 /// so undoing back to the saved state correctly clears the modified flag.
443 clean_source: String,
444 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
445 /// while the document has no file behind it. [`Doc::disk_state`] compares
446 /// the file against this to catch an edit made *outside* leaf before a save
447 /// silently overwrites it — `clean_source` only knows what leaf itself did.
448 ///
449 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
450 /// writes inside one filesystem timestamp tick are indistinguishable, a
451 /// clock that steps backwards (or a writer that restores an mtime) hides a
452 /// real change, and a `touch` invents one. The whole point of the watermark
453 /// is to not clobber someone's work, so it reads the bytes and compares what
454 /// is actually there. That costs a file read per question, which is why the
455 /// question is asked on a user event (focus, save) and not every frame.
456 disk_hash: Option<u64>,
457 /// The "sticky" display column vertical motion aims for, in the active
458 /// view's grid. Set on the first `move_up`/`move_down` of a run and
459 /// reused by every subsequent one in that run, so passing through a
460 /// shorter line doesn't permanently forget the original column. Any
461 /// horizontal motion or edit clears it.
462 ///
463 /// A column, not a character index: dropping down a line of `你好` onto one
464 /// of ASCII has to land under the glyph the caret was drawn beneath, which
465 /// is the only thing the user can see to aim by. Where the goal falls inside
466 /// a wide character on the target line, the mapping resolves it to that
467 /// character — the caret lands on it rather than between its cells.
468 goal_col: Option<usize>,
469 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
470 /// and clicks read it to stay in visible space.
471 pub vmap: VisualMap,
472 /// Everything the map is built from, as one number: bumped whenever the
473 /// document's text changes, and never by a motion, a selection, or a save.
474 /// A frontend can hold work against it — see [`Doc::revision`].
475 revision: u64,
476 /// What `vmap` was built from, or `None` before the first build. The map is
477 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
478 /// moved, rebuilding it produces the identical map — see
479 /// [`Doc::build_visual`].
480 ///
481 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
482 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
483 /// text and width alone, and a caret motion still rebuilds nothing.
484 vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
485 /// Per-block row cache backing the incremental rebuild: when the text
486 /// changes, only the top-level blocks whose bytes moved are re-rendered and
487 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
488 /// builds; a pure accelerator, so it's never read for correctness.
489 block_cache: wysiwyg::BlockCache,
490 /// How many visual rows each block image reserves, keyed by its destination —
491 /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
492 /// measured the pictures. Core does no image I/O, so this is the only way it
493 /// learns a picture's height; a destination not in the map reserves the bare
494 /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
495 /// sizes each placeholder, and folded into `vmap_key` so a height change
496 /// rebuilds the map.
497 media_rows: HashMap<String, usize>,
498
499 // View geometry the renderer stamps each frame, so mouse events can map a
500 // screen cell back to a byte offset.
501 pub scroll: usize,
502 pub body_origin: (u16, u16),
503 pub body_height: u16,
504 /// The caret as of the last frame drawn, or `None` before the first.
505 ///
506 /// Scrolling is the viewport's business, not the caret's: the view follows
507 /// the caret when the caret *moves*, but a wheel that doesn't touch the
508 /// caret has to be free to scroll away from it — otherwise the view is
509 /// pinned to the caret and stops dead at the edge of the document you can
510 /// see. Comparing against this is what tells the two apart, and it catches a
511 /// caret set by any route, including a frontend assigning the field itself.
512 pub drawn_caret: Option<usize>,
513}
514
515/// The Markdown extensions every leaf document is parsed with. `html_elements`
516/// and `directives` depart from twig's defaults. `html_elements` promotes
517/// embedded raw HTML (`<img>`, `<picture>`, `<source>`, …) into semantic AST
518/// nodes, so a picture becomes a real `image` node the frontends can frame and
519/// rasterize instead of opaque `raw_block` text. `directives` turns on generic
520/// `:::name{.class}` fenced-div containers (`directive` nodes), which a host
521/// app uses for its own semantics (diaryx's `:::vis{.audience}` visibility
522/// blocks) — core renders any directive as a plain tinted container, agnostic
523/// of `name`. Both flags are inert for non-Markdown formats, so it's safe to
524/// pass them unconditionally. Threading this through every constructor (not
525/// just `open`) keeps `from_source`, `blank`, and `reload` parsing the same
526/// document the same way — twig reparses with these same flags after each edit.
527fn parse_extensions() -> MarkdownExtensions {
528 MarkdownExtensions {
529 html_elements: true,
530 directives: true,
531 ..Default::default()
532 }
533}
534
535/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
536/// mapping twig's error into the `anyhow` context every constructor shares.
537fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
538 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
539}
540
541/// Does `format` spell a table as a **pipe table** — the one grid twig's table
542/// editor knows how to emit?
543///
544/// This is the single capability leaf still has to answer for itself, and the
545/// only hand-maintained format list left in this file. Every other gesture is
546/// [`Format::supports`], which is twig's own answer read across the C ABI — but
547/// twig deliberately leaves the table ops out of that query, because they read
548/// no `Syntax` table at all. They rewrite a grid that is already in the source
549/// and refuse on *position*, never on format. Handed a caret inside an HTML
550/// `<table>`, `table_insert_row` therefore re-emits the whole element as
551/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
552/// `dirty` flag, and nothing downstream able to tell it from a good edit.
553///
554/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
555/// wildcard answers "no" for a format leaf has never heard of: a new twig
556/// language that *does* spell pipe tables loses its grid controls until this
557/// line is updated, which shows up as a missing button. The other default hands
558/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
559fn spells_pipe_tables(format: Format) -> bool {
560 matches!(format, Format::Markdown | Format::Djot)
561}
562
563/// Which of leaf's authoring controls this document's format can actually
564/// spell — one flag per toolbar button, resolved once so a frontend can build
565/// its chrome instead of discovering each refusal on a click.
566///
567/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
568/// the matching [`Doc`] method calls, so this record cannot drift from what the
569/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
570/// export.
571///
572/// **The formats are ragged, and that is the point.** A single per-document
573/// boolean was enough while the two authorable formats were Markdown and djot
574/// and everything else spelled nothing. HTML is neither: it writes seven of the
575/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
576/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
577/// no link — because its versions of those have a different *shape*, not a
578/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
579/// one flag can say that. Markdown and djot differ from each other too:
580/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
581#[derive(Clone, Copy, Debug, Eq, PartialEq)]
582pub struct Capabilities {
583 /// ⌘B — `InlineKind::Strong`.
584 pub bold: bool,
585 /// ⌘I — `InlineKind::Emph`.
586 pub italic: bool,
587 /// Inline code — `InlineKind::Verbatim`.
588 pub code: bool,
589 /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
590 pub mark: bool,
591 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
592 pub underline: bool,
593 /// Strikethrough — `InlineKind::Delete`.
594 pub strike: bool,
595 pub superscript: bool,
596 pub subscript: bool,
597 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
598 pub heading: bool,
599 pub blockquote: bool,
600 pub bullet_list: bool,
601 pub ordered_list: bool,
602 /// The checkbox controls: giving an item a box, and ticking one.
603 pub task: bool,
604 pub link: bool,
605 /// Covers [`Doc::insert_media`] too — see the note there on why the three
606 /// media kinds stand or fall together.
607 pub image: bool,
608 /// The horizontal-rule button. HTML spells this one (`<hr>`).
609 pub thematic_break: bool,
610 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
611 /// the pair; HTML has no footnote of its own, so the button goes away rather
612 /// than writing brackets that would render as brackets.
613 pub footnote: bool,
614 /// Setting a fenced block's language — a control only ever offered with the
615 /// caret already in a fence.
616 pub code_language: bool,
617 /// The grid controls: insert/delete/move a row or column, set a column's
618 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
619 /// question — an HTML `<table>` holds the caret and still can't be edited.
620 pub table: bool,
621 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
622 /// idiomatic in-cell break.
623 pub cell_line_break: bool,
624}
625
626impl Capabilities {
627 /// Resolve every flag for `format`. Pure and cheap — twig computes each from
628 /// a static table — but a frontend that wants to hold them can.
629 pub fn of(format: Format) -> Self {
630 let inline = |k| format.supports(Gesture::ToggleInline(k));
631 let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
632 Self {
633 bold: inline(InlineKind::Strong),
634 italic: inline(InlineKind::Emph),
635 code: inline(InlineKind::Verbatim),
636 mark: inline(InlineKind::Mark),
637 underline: inline(InlineKind::Insert),
638 strike: inline(InlineKind::Delete),
639 superscript: inline(InlineKind::Superscript),
640 subscript: inline(InlineKind::Subscript),
641 heading: format.supports(Gesture::SetBlock),
642 blockquote: container(BlockContainerKind::BlockQuote),
643 bullet_list: container(BlockContainerKind::BulletList),
644 ordered_list: container(BlockContainerKind::OrderedList),
645 // Both halves of the checkbox story, and leaf offers no control that
646 // needs only one: the item gesture mints the box, the checked one
647 // ticks it, and a format spelling a `task_marker` spells both.
648 task: format.supports(Gesture::ToggleTaskItem)
649 && format.supports(Gesture::ToggleTaskChecked),
650 link: format.supports(Gesture::InsertLink),
651 image: format.supports(Gesture::InsertImage),
652 thematic_break: format.supports(Gesture::InsertThematicBreak),
653 footnote: format.supports(Gesture::InsertFootnote),
654 code_language: format.supports(Gesture::SetCodeLanguage),
655 table: spells_pipe_tables(format),
656 cell_line_break: format.supports(Gesture::InsertLineBreak),
657 }
658 }
659}
660
661impl Doc {
662 #[cfg(feature = "fs")]
663 pub fn open(path: PathBuf) -> Result<Self> {
664 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
665 let format = detect_format(&path)?;
666 let editor = new_editor(&bytes, format)?;
667 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
668 let disk_hash = Some(hash_bytes(source.as_bytes()));
669 // Store the document's *absolute* path. A relative one (`leaf README.md`)
670 // has an empty parent, so a frontend can't resolve a relative image
671 // destination (``) against the document's directory and the
672 // picture silently falls back to its text placeholder. `absolute` is
673 // purely lexical — it prefixes the current directory and normalizes, but
674 // reads nothing and resolves no symlinks — so `file_name` and save are
675 // unchanged; it only gives `path.parent()` something to join against.
676 let path = std::path::absolute(&path).unwrap_or(path);
677 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
678 }
679
680 /// Build a document from an in-memory string, the format named explicitly —
681 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
682 /// path and sniffs the format from its extension). A wasm or FFI host, which
683 /// has no path to read, uses this: it hands over bytes it fetched however it
684 /// could, and later persists [`Doc::source`] however it can (a browser
685 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
686 ///
687 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
688 /// true) exactly like a [`Doc::blank`] that has been given content.
689 pub fn from_source(source: String, format: Format) -> Result<Self> {
690 let editor = new_editor(source.as_bytes(), format)?;
691 Ok(Doc::from_parts(
692 editor,
693 format,
694 PathBuf::new(),
695 source,
696 None,
697 ))
698 }
699
700 /// An untitled, empty document — the `+` button and a `leaf` launched with
701 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
702 ///
703 /// It is Markdown, because a format has to be chosen before a name exists to
704 /// read one from: `detect_format` reads the extension and an untitled
705 /// document has neither. Markdown is what leaf's own files are, what its
706 /// block markers are already written for (`insert_block_prefix`), and the
707 /// extension a Save As will overwhelmingly pick — a wrong guess here would
708 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
709 /// *doesn't* revisit this: see [`Doc::save_as`].
710 pub fn blank() -> Result<Self> {
711 let format = Format::Markdown;
712 let editor = new_editor(b"", format)?;
713 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
714 // field two frontends already read; making it an `Option` to say this
715 // would break both). `is_untitled` is the question to ask, not the
716 // representation to copy.
717 Ok(Doc::from_parts(
718 editor,
719 format,
720 PathBuf::new(),
721 String::new(),
722 None,
723 ))
724 }
725
726 /// The fields every constructor agrees on, so `open` and `blank` can't drift
727 /// apart in the ones neither of them has an opinion about.
728 fn from_parts(
729 editor: Editor,
730 format: Format,
731 path: PathBuf,
732 source: String,
733 disk_hash: Option<u64>,
734 ) -> Self {
735 Doc {
736 editor,
737 format,
738 path,
739 disk_hash,
740 clean_source: source.clone(),
741 source,
742 caret: 0,
743 anchor: None,
744 dirty: false,
745 status: None,
746 // leaf opens in the rich-text (WYSIWYG) view by default — the
747 // markup-resolved surface is leaf's differentiator. Frontends can
748 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
749 // toggles at runtime.
750 view: View::Wysiwyg,
751 // `None` by default — the clean surface Diaryx ships, with typed
752 // syntax kept literal; a markup-fluent frontend can climb the
753 // ladder to `Shortcuts` or `Full`.
754 markup_mode: MarkupMode::default(),
755 // Fold by default — flowing prose that reflows to the viewport, the
756 // behaviour every frontend had before this preference existed.
757 line_flow: LineFlow::default(),
758 last_edit_kind: None,
759 pending_marks: InlineMarks::empty(),
760 pending_at: None,
761 goal_col: None,
762 vmap: VisualMap::default(),
763 revision: 0,
764 // No map yet — the first `build_visual` always builds.
765 vmap_key: None,
766 block_cache: wysiwyg::BlockCache::default(),
767 media_rows: HashMap::new(),
768 scroll: 0,
769 body_origin: (0, 0),
770 body_height: 0,
771 drawn_caret: None,
772 }
773 }
774
775 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
776 /// has never been saved. The question a ⌘S handler asks to know it should
777 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
778 /// header asks to know the name it shows is a placeholder.
779 pub fn is_untitled(&self) -> bool {
780 self.path.as_os_str().is_empty()
781 }
782
783 pub fn toggle_view(&mut self) {
784 self.view = match self.view {
785 View::Source => View::Wysiwyg,
786 View::Wysiwyg => View::Source,
787 };
788 self.scroll = 0;
789 self.status = None;
790 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
791 // lift it to the first rendered offset.
792 self.clamp_caret();
793 }
794
795 /// The current markup-exposure preference (see [`MarkupMode`]).
796 pub fn markup_mode(&self) -> MarkupMode {
797 self.markup_mode
798 }
799
800 /// Set the markup-exposure preference. Both of its axes take effect at
801 /// once: the editing one on the next [`insert`](Self::insert), and the
802 /// rendering one on the next build — which is why this drops the cached
803 /// visual map and the per-block render cache, exactly as
804 /// [`set_line_flow`](Self::set_line_flow) does.
805 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
806 if self.markup_mode == mode {
807 return;
808 }
809 self.markup_mode = mode;
810 // Neither cache is keyed on the mode, and moving between `Full` and the
811 // hidden modes changes every row the caret's line renders to — so
812 // invalidate both explicitly.
813 self.vmap_key = None;
814 self.block_cache = wysiwyg::BlockCache::default();
815 }
816
817 /// The source byte range of the line the caret sits on, when that line
818 /// should render its raw delimiters — `None` in every mode and view that
819 /// hides them, which is what the builder reads as "reveal nothing".
820 ///
821 /// A *source* line (newline to newline), not a visual row: a wrapped
822 /// paragraph and a `LineFlow::Preserve` soft break both split one source
823 /// line across several rows, and revealing half a delimiter pair because the
824 /// other half wrapped would be worse than revealing neither. The range
825 /// excludes the terminating newline and is empty-but-present on a blank
826 /// line, which reveals nothing but still keys the caches correctly.
827 ///
828 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
829 /// there is nothing there to reveal.
830 pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
831 if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
832 return None;
833 }
834 Some(source_line_range(&self.source, self.caret))
835 }
836
837 /// The current soft-break flow preference (see [`LineFlow`]).
838 pub fn line_flow(&self) -> LineFlow {
839 self.line_flow
840 }
841
842 /// Set the soft-break flow preference. The mode changes how every block lays
843 /// out, so a change drops the cached visual map and the per-block render
844 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
845 ///
846 /// [`build_visual`]: Self::build_visual
847 pub fn set_line_flow(&mut self, mode: LineFlow) {
848 if self.line_flow == mode {
849 return;
850 }
851 self.line_flow = mode;
852 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
853 // so invalidate them explicitly, or the next build would reuse rows laid
854 // out under the old flow.
855 self.vmap_key = None;
856 self.block_cache = wysiwyg::BlockCache::default();
857 }
858
859 pub fn view_name(&self) -> &'static str {
860 match self.view {
861 View::Source => "source",
862 View::Wysiwyg => "wysiwyg",
863 }
864 }
865
866 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
867 /// (called by the renderer each frame it's in the WYSIWYG view).
868 /// Build the WYSIWYG map, wrapped at `width` display columns.
869 ///
870 /// Cheap to call every frame, which is what both frontends do: the map is a
871 /// pure function of the document and the wrap width, so a call that would
872 /// rebuild the same map returns the one already built. Only an edit (or a
873 /// resize) pays.
874 ///
875 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
876 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
877 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
878 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
879 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
880 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
881 pub fn build_visual(&mut self, width: usize) {
882 self.build_map(Some(width));
883 }
884
885 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
886 /// frontend (the GUI) that wraps at its own proportional pixel width rather
887 /// than a fixed character column.
888 pub fn build_visual_unwrapped(&mut self) {
889 self.build_map(None);
890 }
891
892 /// Tell the model how many visual rows each block image should reserve, keyed
893 /// by the image's destination. A terminal frontend calls this once it has
894 /// decoded and measured its pictures — core does no image I/O, so this is the
895 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
896 /// placeholder out that tall (the label row plus blank filler rows the
897 /// frontend paints the raster over). A destination left out of the map falls
898 /// back to the bare one-row placeholder, which is also what a frontend that
899 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
900 /// by never calling this.
901 ///
902 /// Cheap to call every frame with the same map: only a *change* invalidates
903 /// the built map (and the block-row cache, since a height isn't part of a
904 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
905 /// no-op, so a frontend can just hand over its current measurements each frame.
906 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
907 if self.media_rows == rows {
908 return;
909 }
910 self.media_rows = rows;
911 // A height lives outside the block's source bytes, so the content-keyed
912 // block cache would hand back the old-height rows on a hit. Drop it (and
913 // the splice layout it carries) so the next build re-renders every block
914 // at the new heights, and force that build by clearing the map key.
915 self.block_cache = wysiwyg::BlockCache::default();
916 self.vmap_key = None;
917 }
918
919 /// The revision the document's text is at — bumped by every edit, undo,
920 /// redo, and reload, and by nothing else. A frontend caches against this to
921 /// tell a repaint that needs new work from one that doesn't.
922 ///
923 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
924 /// lands on the same text two revisions later. Work is only ever rebuilt
925 /// needlessly, never wrongly reused.
926 pub fn revision(&self) -> u64 {
927 self.revision
928 }
929
930 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
931 /// runs on every call: the caret moves without the document changing, and
932 /// keeping it on a legal stop is this function's job either way.
933 fn build_map(&mut self, wrap: Option<usize>) {
934 // Under `MarkupMode::Full` the map is a function of the caret's *line*
935 // as well as the text, so the line joins the key: moving within a line
936 // still reuses the map, and crossing into another one rebuilds it. In
937 // every other mode `reveal_line` is `None` and the key is what it was,
938 // so caret motion goes on costing nothing.
939 let reveal = self.reveal_line();
940 let key = (self.revision, wrap, reveal.clone());
941 if self.vmap_key.as_ref() != Some(&key) {
942 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
943 // A subtree is pulled only for the block(s) that actually changed, so
944 // the FFI marshal shrinks from O(document) to O(edited block).
945 let top = self.top_blocks();
946
947 // Fast path: when twig reports a dirty byte range, try to patch the
948 // previous map in place — a single-block edit moves the prefix,
949 // shifts the suffix, and re-renders only one block. `build_spliced`
950 // returns `None` (and we fall back to the always-correct full rebuild)
951 // whenever the edit reshaped the block structure, hit a table, or
952 // there's no previous map to patch.
953 // Preserve soft breaks as written when the flow preference asks for
954 // it — the builder renders each as its own visual row instead of
955 // folding it into the reflowed paragraph.
956 let preserve_soft = self.line_flow == LineFlow::Preserve;
957 let spliced = match self.editor.dirty_range() {
958 Some(dirty) => {
959 let prev = std::mem::take(&mut self.vmap);
960 let source = &self.source;
961 let cache = &mut self.block_cache;
962 let media_rows = &self.media_rows;
963 let editor = &mut self.editor;
964 wysiwyg::build_spliced(
965 prev,
966 source,
967 wrap,
968 preserve_soft,
969 &top,
970 dirty,
971 media_rows,
972 reveal.clone(),
973 cache,
974 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
975 )
976 }
977 None => None,
978 };
979 self.vmap = spliced.unwrap_or_else(|| {
980 let source = &self.source;
981 let cache = &mut self.block_cache;
982 let media_rows = &self.media_rows;
983 let editor = &mut self.editor;
984 wysiwyg::build_cached(
985 &top,
986 source,
987 wrap,
988 preserve_soft,
989 media_rows,
990 reveal,
991 cache,
992 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
993 )
994 });
995 // Acknowledge the dirty range so the next edit's range starts fresh.
996 self.editor.clear_dirty();
997 self.vmap_key = Some(key);
998 }
999 self.clamp_caret();
1000 }
1001
1002 fn nodes(&mut self) -> Vec<FlatNode> {
1003 self.editor.nodes().unwrap_or_default()
1004 }
1005
1006 /// The document's top-level blocks for the incremental render. See
1007 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1008 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1009 wysiwyg::top_blocks(&mut self.editor)
1010 }
1011
1012 pub fn format_name(&self) -> &'static str {
1013 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1014 // required. It also covers `Asciidoc`, which twig parses but cannot
1015 // serialize — leaf never opens a document in it (see `Doc::open`).
1016 match self.format {
1017 Format::Djot => "djot",
1018 Format::Markdown => "markdown",
1019 Format::Xml => "xml",
1020 Format::Html => "html",
1021 _ => "unknown",
1022 }
1023 }
1024
1025 /// Whether this document's format offers *any* door in — `false` only for a
1026 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1027 /// a frontend may as well open the file read-only.
1028 ///
1029 /// This is a much weaker claim than the name suggests, and driving per-button
1030 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1031 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1032 /// while a heading, a quote, a list, a task box, a link and a code fence all
1033 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1034 /// [`supports`](Self::supports) — per control.
1035 pub fn authorable(&self) -> bool {
1036 self.format.is_authorable()
1037 }
1038
1039 /// Whether this document's format can spell `gesture`, which is twig's own
1040 /// answer rather than a copy of it: `Format::supports` reads the same
1041 /// `Syntax` table the `Editor` method consults before refusing.
1042 ///
1043 /// It is a fact about the *format*, not about the caret. `true` does not
1044 /// promise the gesture succeeds where it is standing — a link over a table
1045 /// border still fails — only that it will not fail with
1046 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1047 /// will work here".
1048 pub fn supports(&self, gesture: Gesture) -> bool {
1049 self.format.supports(gesture)
1050 }
1051
1052 /// Every control's enabled state in one read — what a toolbar builds itself
1053 /// from when a document opens or its format changes. See [`Capabilities`].
1054 pub fn capabilities(&self) -> Capabilities {
1055 Capabilities::of(self.format)
1056 }
1057
1058 /// Refuse a gesture this document's format cannot spell, saying so in the
1059 /// status line. `true` means the caller must return without calling twig.
1060 ///
1061 /// Most of these refusals duplicate one twig would make anyway, and they are
1062 /// made here regardless because a message naming the *document's* format
1063 /// reads better than one naming twig's internals. Two of them are not
1064 /// duplicates and are the reason this is a guard rather than an error
1065 /// translation:
1066 ///
1067 /// - The table family (see [`table_op`](Self::table_op)) consults no
1068 /// `Syntax` table, so twig does not refuse it at all.
1069 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1070 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1071 /// could not keep.
1072 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1073 self.refuse_unless(what, self.supports(gesture))
1074 }
1075
1076 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1077 /// answers itself — today only [`spells_pipe_tables`].
1078 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1079 if supported {
1080 return false;
1081 }
1082 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1083 true
1084 }
1085
1086 /// The name to show for this document. An untitled one has no file to name
1087 /// it, and both frontends put this straight on screen — an empty path
1088 /// renders as an empty header, so it says so instead.
1089 pub fn file_name(&self) -> String {
1090 if self.is_untitled() {
1091 return "untitled".into();
1092 }
1093 self.path
1094 .file_name()
1095 .map(|s| s.to_string_lossy().into_owned())
1096 .unwrap_or_else(|| self.path.display().to_string())
1097 }
1098
1099 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1100 /// caret and anchor coincide (an empty selection is no selection).
1101 pub fn selection(&self) -> Option<(usize, usize)> {
1102 self.anchor
1103 .map(|a| (a.min(self.caret), a.max(self.caret)))
1104 .filter(|(s, e)| s != e)
1105 }
1106
1107 /// The selected text, or `None` when there's no selection — the source
1108 /// slice a copy/cut hands to the system clipboard.
1109 pub fn selected_text(&self) -> Option<&str> {
1110 self.selection().map(|(s, e)| &self.source[s..e])
1111 }
1112
1113 /// The AST breadcrumb at the caret (root → deepest), e.g.
1114 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1115 pub fn breadcrumb(&mut self) -> String {
1116 match self.editor.ancestors_at(self.caret) {
1117 Ok(chain) => chain
1118 .iter()
1119 .map(|m| m.kind.as_str())
1120 .collect::<Vec<_>>()
1121 .join(" › "),
1122 Err(_) => String::new(),
1123 }
1124 }
1125
1126 // ── editing ──────────────────────────────────────────────────────────────
1127
1128 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1129 /// after it. The public form of the internal splice — a pixel frontend that
1130 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1131 /// edits through this, the same twig `edit_range` the caret ops use.
1132 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1133 self.splice(start, end, text, EditKind::Other);
1134 }
1135
1136 /// Insert typed `text` at the caret, replacing the selection if there is one.
1137 /// A single typed character coalesces with the run of typing before it; a
1138 /// newline or a multi-character insert is its own undo step.
1139 ///
1140 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1141 pub fn insert(&mut self, text: &str) {
1142 // Typing against a block picture would dissolve it — see
1143 // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1144 // what the caret was standing beside stays a picture.
1145 self.open_paragraph_at_block_media(text);
1146 // Armed sticky marks (⌘b with no selection) turn the next typed text
1147 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1148 // the exception: it takes no mark of its own and keeps the delta armed
1149 // for the character behind it — see `insert_space_with_marks`.
1150 let pending = self.pending_here();
1151 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1152 if text.trim().is_empty() {
1153 self.insert_space_with_marks(self.caret, text, pending);
1154 } else {
1155 self.insert_with_marks(self.caret, text, pending);
1156 }
1157 return;
1158 }
1159 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1160 // anything that would open markup, so a Diaryx user never mints
1161 // formatting by keyboard (it comes from commands instead). The other two
1162 // rungs of the ladder author markup from what you type, which is the
1163 // whole difference between them and this one. Only in the rendered view
1164 // (source view is for typing raw markup) and only where the format has a
1165 // literal spelling at all: escaping is a backslash before a byte from the
1166 // format's own alphabet, and a format with no such alphabet (HTML escapes
1167 // with entities, XML spells nothing) would have `\&` written into it,
1168 // which is two literal characters and not an escape. Marks (⌘b) still
1169 // format — that path returned above; and leaf's own structural inserts go
1170 // through `insert_raw`, never here, so a list marker or quote gutter is
1171 // written as the markup it is.
1172 if !self.markup_mode.authors()
1173 && self.view == View::Wysiwyg
1174 && !text.is_empty()
1175 && self.supports(Gesture::InsertLiteral)
1176 {
1177 self.insert_literal_typed(text);
1178 return;
1179 }
1180 self.insert_raw(text);
1181 }
1182
1183 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1184 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1185 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1186 /// markup by design and must not be escaped.
1187 fn insert_raw(&mut self, text: &str) {
1188 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1189 self.splice(s, e, text, typed_edit_kind(text));
1190 }
1191
1192 /// Open a paragraph for text about to be inserted at one of a block media's
1193 /// two caret stops, and leave the caret standing in it.
1194 ///
1195 /// A block image is a paragraph whose entire content is the picture, and the
1196 /// caret's only homes on it are in front of it and just past it (see
1197 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1198 /// *that* paragraph — and a paragraph holding anything besides the image is
1199 /// no longer a block image but a line of text with an inline one in it. The
1200 /// frontend that was painting a photo there paints a text run instead; the
1201 /// picture is still in the file, and nothing said a word. Those two offsets
1202 /// are also exactly where a click on the picture lands, so the whole accident
1203 /// is one tap and one keystroke.
1204 ///
1205 /// So the break goes in first and the text lands in the new empty paragraph —
1206 /// what pressing Return before typing would have done, which is a habit no
1207 /// one should have to learn from losing a photo. A no-op everywhere else, and
1208 /// over a selection (which is replaced, not joined into).
1209 ///
1210 /// A picture inside a quote or a list leaves its container, because `\n\n`
1211 /// ends the block. The alternative is worse: the `\n> ` / next-item
1212 /// continuation [`newline`](Self::newline) writes stays in the same
1213 /// *paragraph*, which is the thing being prevented.
1214 ///
1215 /// Only in the rendered view. Source view is for typing raw markup, where
1216 /// putting a character against an image is exactly what it looks like.
1217 fn open_paragraph_at_block_media(&mut self, text: &str) {
1218 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1219 return;
1220 }
1221 if self.selection().is_some() {
1222 return;
1223 }
1224 // The map may be a revision behind (nothing has drawn since the last
1225 // edit), and this asks it about offsets — a stale answer would splice a
1226 // break into the wrong place. Free when it is already current, which it
1227 // is whenever a frontend drew a frame between keystrokes.
1228 self.rebuild_map();
1229 let at = self.caret;
1230 let Some((side, _)) = self.vmap.block_media_stop(at) else {
1231 return;
1232 };
1233 if !self.splice(at, at, "\n\n", EditKind::Other) {
1234 return;
1235 }
1236 // The break is part of the keystroke, not an edit of its own: leave the
1237 // run marked as typing so the character about to arrive folds into it and
1238 // one undo puts the document back the way it was found. (A paste, or a
1239 // multi-character insert, is `EditKind::Other` and stays its own step —
1240 // as it would have been anywhere else in the document.)
1241 self.last_edit_kind = Some(EditKind::Insert);
1242 if side == MediaStop::Before {
1243 // The break went in above the picture and the caret rode to the end
1244 // of it — which is still hard against the picture. Step back onto the
1245 // blank line it opened, so the text lands above rather than in front.
1246 self.caret = at;
1247 }
1248 }
1249
1250 /// A delete key pressed at one of a block picture's two caret stops, handled
1251 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1252 /// the key was consumed.
1253 ///
1254 /// The caret rests in front of a block image and just past it, never inside
1255 /// its markup — which the rendered view doesn't show. So the byte a delete
1256 /// key nominally takes there is one the writer cannot see, and taking it
1257 /// leaves the picture as broken markup rather than as anything anyone asked
1258 /// for: Backspace at the stop past `` removes the closing paren, and
1259 /// a photo becomes the literal text `
1262 /// prevents from the typing side, and it cost this repository's own test vault
1263 /// a photo before it was found.
1264 ///
1265 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1266 /// when it is behind the caret, Delete when it is in front — which is what
1267 /// every editor does with an embed, and one undo away. The key aimed *away*
1268 /// from it would otherwise delete the paragraph break and merge a neighbour
1269 /// into the picture's own paragraph, which dissolves it just as surely; it
1270 /// steps the caret over the boundary instead and leaves the
1271 /// next press to delete in the block it has reached — the same "first press
1272 /// steps out of the atom, second press deletes" every delete key here gets,
1273 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1274 /// above, and reaches it on the second press rather than taking the break and
1275 /// the picture with it on the first).
1276 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1277 // The map answers about offsets, so it has to be this revision's — see
1278 // the same call in `open_paragraph_at_block_media`.
1279 self.rebuild_map();
1280 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1281 return false;
1282 };
1283 let aimed_at_it = side
1284 == if forward {
1285 MediaStop::Before
1286 } else {
1287 MediaStop::After
1288 };
1289 if !aimed_at_it {
1290 let over = if forward {
1291 self.vmap.stop_after(self.caret)
1292 } else {
1293 self.vmap.stop_before(self.caret)
1294 };
1295 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1296 self.caret = off;
1297 self.anchor = None;
1298 self.goal_col = None;
1299 }
1300 return true;
1301 }
1302 // Take the break that held the picture apart from its neighbour with it,
1303 // so the delete doesn't leave a blank paragraph standing where the
1304 // picture was. The last arm is a picture that is the whole document.
1305 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1306 (span.start - 2, span.end)
1307 } else if self.source[span.end..].starts_with("\n\n") {
1308 (span.start, span.end + 2)
1309 } else {
1310 (span.start, span.end)
1311 };
1312 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1313 true
1314 }
1315
1316 /// The Hidden-mode typing path: replace any selection, then insert `text`
1317 /// escaped so it stays literal. When it replaces a selection the two edits
1318 /// fold into one undo step, so an overwrite undoes atomically (and restores
1319 /// the selection) exactly as a plain one does.
1320 fn insert_literal_typed(&mut self, text: &str) {
1321 let kind = typed_edit_kind(text);
1322 match self.selection() {
1323 Some((s, e)) => {
1324 if !self.splice(s, e, "", EditKind::Other) {
1325 return;
1326 }
1327 // Typing over a whole marked run takes its delimiters with it
1328 // (the empty content couldn't hold them — see
1329 // `repair_mark_edges`) and leaves its marks armed at the caret.
1330 // The text taking the run's place inherits them, exactly as it
1331 // would have by landing inside a run that survived.
1332 let pending = self.pending_here();
1333 if !pending.is_empty() && !text.trim().is_empty() {
1334 self.insert_with_marks(self.caret, text, pending);
1335 return;
1336 }
1337 self.insert_literal_at(self.caret, text, kind, true);
1338 }
1339 None => {
1340 self.insert_literal_at(self.caret, text, kind, false);
1341 }
1342 }
1343 }
1344
1345 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1346 /// at a collapsed caret, but only while the caret still stands where they
1347 /// were armed and nothing is selected. Empty otherwise, so a stale delta
1348 /// never styles text it wasn't meant for.
1349 fn pending_here(&self) -> InlineMarks {
1350 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1351 self.pending_marks
1352 } else {
1353 InlineMarks::empty()
1354 }
1355 }
1356
1357 /// Drop the armed sticky marks — any caret motion, selection, or edit does
1358 /// this, so "start bold here" only ever applies at the exact spot it was
1359 /// asked for.
1360 fn clear_pending(&mut self) {
1361 self.pending_marks = InlineMarks::empty();
1362 self.pending_at = None;
1363 }
1364
1365 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1366 /// force is wrapped around the freshly typed text; a mark the caret already
1367 /// stands inside is *shed* — the text is inserted past the run's end so it
1368 /// lands unmarked ("type normally again"). The caret comes to rest inside any
1369 /// added runs, so continued typing inherits the marks with no re-wrapping,
1370 /// and the delta is cleared: the marks now live in the document, not here.
1371 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1372 let base = self.mark_spans_at(at);
1373 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1374 // Nothing to shed, and a run of exactly these marks standing just behind
1375 // the caret: carry on writing *that* run rather than opening a second
1376 // one beside it.
1377 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1378 return;
1379 }
1380 // Shed the marks we're turning off: step the insertion point past the
1381 // end of each run the caret sits in, so the new text falls outside it.
1382 let mut ins_at = at;
1383 for (kind, span) in &base {
1384 if marks.contains(*kind) {
1385 ins_at = ins_at.max(span.end);
1386 }
1387 }
1388 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1389 return;
1390 }
1391 // The plain splice inserted exactly `text` at `ins_at`; that byte range
1392 // is the content every added mark wraps.
1393 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1394 for kind in marks.iter() {
1395 if !base_set.contains(kind) {
1396 let (ncs, nce) = self.wrap_span(cs, ce, kind);
1397 cs = ncs;
1398 ce = nce;
1399 }
1400 }
1401 self.caret = ce.min(self.source.len());
1402 self.anchor = None;
1403 self.last_edit_kind = None;
1404 // Realised: the marks are in the document now, and the caret sits inside
1405 // them, so there is no delta left to carry. Arm nothing, but remember the
1406 // spot so a *further* toggle before typing starts a clean delta here.
1407 self.pending_marks = InlineMarks::empty();
1408 self.pending_at = Some(self.caret);
1409 self.clamp_caret();
1410 self.record_caret();
1411 }
1412
1413 /// Carry on the marked run just behind `at` — moving its closing delimiters
1414 /// out past the new text — instead of opening a second run of the same marks
1415 /// beside it. Returns whether it did.
1416 ///
1417 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1418 /// A space typed after a bold word steps the caret out of the run, because
1419 /// `**bold **` is not bold; the next character has to step back *in*, or the
1420 /// writer who typed one bold phrase is left with `**bold** **and**` — two
1421 /// runs that read the same to a reader but spell the file in a way nobody
1422 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1423 /// words it isn't marking), and the marks behind it must be exactly the ones
1424 /// armed — a run of *some* other kind is a neighbour, not this phrase.
1425 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1426 if text.is_empty() || text.trim() != text {
1427 return false;
1428 }
1429 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1430 // Walk in through the delimiters stacked at that point, innermost last:
1431 // `***both*** ` closes two runs with one `***`, and rejoining means
1432 // getting behind all of them.
1433 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1434 while let Some((kind, content_end)) = self
1435 .editor
1436 .ancestors_at(prev_boundary(&self.source, cut))
1437 .unwrap_or_default()
1438 .into_iter()
1439 .filter(|m| m.span.end == cut)
1440 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1441 {
1442 if content_end >= cut {
1443 break; // a mark with no closing delimiter to step behind
1444 }
1445 kinds.insert(kind);
1446 cut = content_end;
1447 }
1448 if cut == gap_at || kinds != marks {
1449 return false;
1450 }
1451 // Re-spell the tail: the gap, then the new text, then the delimiters that
1452 // used to close in front of them — read out of the document rather than
1453 // written from a table, so whatever twig spells them with is what moves.
1454 let tail = format!(
1455 "{}{text}{}",
1456 &self.source[gap_at..at],
1457 &self.source[cut..gap_at]
1458 );
1459 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1460 return false;
1461 }
1462 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1463 self.anchor = None;
1464 self.last_edit_kind = None;
1465 self.pending_marks = InlineMarks::empty();
1466 self.pending_at = Some(self.caret);
1467 self.clamp_caret();
1468 self.record_caret();
1469 true
1470 }
1471
1472 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1473 /// never itself wrapped: a mark around a space draws nothing a reader can
1474 /// see, and in Markdown and Djot it draws its own delimiters instead
1475 /// (`** **`). So the space goes in unmarked — outside any run the armed
1476 /// marks are shedding — and the marks stay armed for the character after it,
1477 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1478 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1479 let base = self.mark_spans_at(at);
1480 // What the *next* character carries: the armed delta resolved against the
1481 // marks in force here, which the space must not quietly drop.
1482 let want = base
1483 .iter()
1484 .map(|(k, _)| *k)
1485 .collect::<InlineMarks>()
1486 .xor(marks);
1487 let mut ins_at = at;
1488 for (kind, span) in &base {
1489 if marks.contains(*kind) {
1490 ins_at = ins_at.max(span.end);
1491 }
1492 }
1493 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1494 return;
1495 }
1496 self.rearm(want);
1497 self.record_caret();
1498 }
1499
1500 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1501 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1502 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1503 /// added split evenly around the content — half the growth on each side.
1504 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1505 match self.editor.toggle_inline(s, e, kind) {
1506 Ok(change) => {
1507 self.last_edit_kind = None;
1508 self.refresh();
1509 self.dirty = self.source != self.clean_source;
1510 let added = (change.new.end - change.new.start).saturating_sub(e - s);
1511 let half = added / 2;
1512 (change.new.start + half, change.new.end - half)
1513 }
1514 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1515 // rather than lose the keystroke.
1516 Err(e2) => {
1517 self.status = Some(format!("{kind:?}: {e2}"));
1518 (s, e)
1519 }
1520 }
1521 }
1522
1523 /// The safe offset to splice a block-level break at, given a caret that may
1524 /// sit exactly between an inline mark's content and its own closing
1525 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1526 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1527 /// with nothing following it on the line: the closing `**` renders no
1528 /// glyph of its own, so the caret's "end of line" offset lands right
1529 /// before it). Splicing a paragraph/list/quote break at `off` itself would
1530 /// sever the delimiter from its content, stranding it alone on the new
1531 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1532 /// nested marks closing at the same point (`**_x_**`) all clear together.
1533 /// A no-op everywhere else — mid-run, or past real trailing content, no
1534 /// mark's `content_span` ends exactly at `off`.
1535 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1536 let off = off.min(self.source.len());
1537 self.editor
1538 .ancestors_at(off)
1539 .unwrap_or_default()
1540 .into_iter()
1541 .filter(|m| inline_kind(&m.kind).is_some())
1542 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1543 .map(|m| m.span.end)
1544 .max()
1545 .unwrap_or(off)
1546 }
1547
1548 /// The offset a *delete* aimed at the character before `off` should stop at,
1549 /// when `off` is the start of a run's text and the bytes behind it are that
1550 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1551 /// byte behind the caret at the start of a bold word is not a character the
1552 /// writer can see, let alone one they aimed Backspace at: taking it leaves
1553 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1554 /// delete steps over the whole delimiter to the visible character in front of
1555 /// it instead. Walks out to the *outermost* mark opening there, so
1556 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1557 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1558 let off = off.min(self.source.len());
1559 self.editor
1560 .ancestors_at(off)
1561 .unwrap_or_default()
1562 .into_iter()
1563 .filter(|m| inline_kind(&m.kind).is_some())
1564 .filter(|m| {
1565 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
1566 })
1567 .map(|m| m.span.start)
1568 .min()
1569 .unwrap_or(off)
1570 }
1571
1572 /// `off` moved *inside* the run whose closing delimiters end there — the
1573 /// other offset the rich view draws in the same place, since a `**` renders
1574 /// no glyph of its own. `**bold**` has a caret home on each side of its
1575 /// closing delimiter, one column apart on screen and eight bytes and a whole
1576 /// run apart in the file, and a plain ← lands on the outer one whenever a
1577 /// space follows the phrase. The inner one is what the writer is pointing at
1578 /// there: the end of their bold word. Walks in through every mark closing at
1579 /// that point, innermost last, so `***both***` lands inside both. A no-op
1580 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1581 fn step_inside_close_delims(&mut self, off: usize) -> usize {
1582 let mut off = off.min(self.source.len());
1583 loop {
1584 let inner = self
1585 .editor
1586 .ancestors_at(prev_boundary(&self.source, off))
1587 .unwrap_or_default()
1588 .into_iter()
1589 .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1590 .filter_map(|m| m.content_span.clone().map(|c| c.end))
1591 .filter(|&end| end < off)
1592 .max();
1593 match inner {
1594 Some(end) => off = end,
1595 None => return off,
1596 }
1597 }
1598 }
1599
1600 /// The mirror at the opening edge: `off` moved inside the run whose
1601 /// delimiters *start* there, onto the first character of its text. See
1602 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1603 fn step_inside_open_delims(&mut self, off: usize) -> usize {
1604 let mut off = off.min(self.source.len());
1605 loop {
1606 let inner = self
1607 .editor
1608 .ancestors_at(off)
1609 .unwrap_or_default()
1610 .into_iter()
1611 .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
1612 .filter_map(|m| m.content_span.clone().map(|c| c.start))
1613 .filter(|&start| start > off)
1614 .min();
1615 match inner {
1616 Some(start) => off = start,
1617 None => return off,
1618 }
1619 }
1620 }
1621
1622 /// The inline mark kinds whose span covers `off`, each with that span — the
1623 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
1624 /// ids instead. Used to shed a mark by stepping past the end of its run.
1625 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
1626 let off = off.min(self.source.len());
1627 self.editor
1628 .ancestors_at(off)
1629 .unwrap_or_default()
1630 .into_iter()
1631 .filter(|m| off < m.span.end)
1632 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
1633 .collect()
1634 }
1635
1636 /// Insert clipboard `text` at the caret, replacing the selection if there is
1637 /// one — always its own undo step, whatever its length.
1638 ///
1639 /// Provenance is the whole point, and only the caller has it. `insert` reads
1640 /// a lone character as a keystroke and folds it into the run around it,
1641 /// which is right for typing and wrong for a one-character paste: that paste
1642 /// would vanish mid-run on an undo it was never part of, and the characters
1643 /// the user actually typed would go with it. Length can't tell the two
1644 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
1645 /// caller comes through is what says which happened.
1646 pub fn paste(&mut self, text: &str) {
1647 // Pasting against a block picture dissolves it exactly as typing does,
1648 // and for the same reason — see `open_paragraph_at_block_media`.
1649 self.open_paragraph_at_block_media(text);
1650 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1651 self.splice(s, e, text, EditKind::Other);
1652 }
1653
1654 /// Replace `[start, end)` with `text` as one step of an IME composition —
1655 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
1656 /// folds into a single undo.
1657 ///
1658 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
1659 /// dozen calls here, each replacing the last one's provisional bytes, and an
1660 /// undo step per call means undoing a word means pressing ⌘Z until the reading
1661 /// unspools backwards through kana — the intermediate states were never text
1662 /// the user wrote. Only the frontend knows a call is provisional (the bytes
1663 /// look like any other edit), so the door the caller comes through is what
1664 /// says so, exactly as it is for [`paste`](Self::paste) versus
1665 /// [`insert`](Self::insert).
1666 ///
1667 /// Pair with [`end_composition`](Self::end_composition), or the *next*
1668 /// composition folds into this one.
1669 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
1670 self.splice(start, end, text, EditKind::Compose);
1671 }
1672
1673 /// Close the open composition run, so the next one is its own undo step.
1674 /// Call when the IME commits or withdraws a composition.
1675 ///
1676 /// Only clears a *composition* run: a frontend that reports an end it never
1677 /// began (some IMEs unmark unprompted) would otherwise split the run of
1678 /// typing around it into two undo steps for no reason the user can see.
1679 pub fn end_composition(&mut self) {
1680 if self.last_edit_kind == Some(EditKind::Compose) {
1681 self.last_edit_kind = None;
1682 }
1683 }
1684
1685 // ── the clipboard's rich flavor ──────────────────────────────────────────
1686
1687 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
1688 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
1689 /// nothing is selected, or when the selection doesn't render (the caller
1690 /// still has [`selected_text`](Self::selected_text), which is what to publish
1691 /// as `text/plain` either way).
1692 ///
1693 /// **The fragment is a source substring, and that is the honest limit here.**
1694 /// It's parsed standalone, so a selection whose meaning depends on its
1695 /// surroundings converts as what it literally says rather than what it looks
1696 /// like on screen: half a list item is a paragraph, a row torn out of a table
1697 /// is the text of a row, the `**` of a bold run selected without its closing
1698 /// `**` is two asterisks. Every one of those still *renders* — there's no
1699 /// error to report — it just renders as the fragment and not as the document.
1700 /// Widening the range to whole blocks would publish text the user didn't
1701 /// select, which is a worse lie than a fragment being a fragment; the plain
1702 /// flavor has the same substring, so the two flavors at least agree.
1703 pub fn selection_html(&mut self) -> Option<String> {
1704 let (start, end) = self.selection()?;
1705 let inline = self.selection_is_inline(start, end);
1706 let html = html::render_fragment(&self.source[start..end], self.format)?;
1707 Some(match inline {
1708 true => html::strip_sole_paragraph(html),
1709 false => html,
1710 })
1711 }
1712
1713 /// Paste the clipboard's `text/html` flavor, converting it to this document's
1714 /// format first. Its own undo step, like any [`paste`](Self::paste).
1715 ///
1716 /// Returns whether it landed. `false` means the HTML didn't convert to
1717 /// anything worth pasting — the caller should fall back to the plain flavor
1718 /// rather than treat it as an error. The `html` module has the full list of
1719 /// what that covers: a table twig won't build, markup it doesn't recognise,
1720 /// an empty result.
1721 pub fn paste_html(&mut self, html: &str) -> bool {
1722 match html::parse_fragment(html, self.format) {
1723 Some(source) => {
1724 self.paste(&source);
1725 true
1726 }
1727 None => false,
1728 }
1729 }
1730
1731 /// Does the selection live *inside* a single top-level block?
1732 ///
1733 /// The question [`selection_html`](Self::selection_html) needs and the
1734 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
1735 /// whether the user selected one word of a sentence or a whole paragraph, and
1736 /// only the document knows which. Selecting a word and pasting into Docs
1737 /// should extend the line you paste into; selecting the paragraph should make
1738 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
1739 /// is an artifact of standalone parsing), and one that covers a whole block —
1740 /// or spans two — keeps its structure.
1741 ///
1742 /// Reads the block from twig rather than guessing from the bytes:
1743 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
1744 /// block containing an offset, and two ends inside the same one cannot have
1745 /// crossed a block boundary.
1746 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
1747 // The last *character*, not `end - 1`: the selection's end is exclusive
1748 // and may sit mid-codepoint's-worth of bytes past the last char.
1749 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
1750 return false;
1751 };
1752 let (Some(head), Some(tail)) =
1753 (self.top_block_span(start), self.top_block_span(start + off))
1754 else {
1755 return false;
1756 };
1757 head == tail && !(start <= head.start && end >= head.end)
1758 }
1759
1760 /// The byte span of the top-level block containing `offset`, or `None` at an
1761 /// offset that belongs to no block (the blank line between two of them).
1762 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
1763 self.editor
1764 .ancestors_at(offset)
1765 .ok()?
1766 .get(1)
1767 .map(|m| m.span.clone())
1768 }
1769
1770 // ── indentation ──────────────────────────────────────────────────────────
1771
1772 /// One indent level.
1773 ///
1774 /// Two spaces, not the four both frontends type for Tab today, because in a
1775 /// markdown document four columns isn't a width — it's a *meaning*. Four
1776 /// spaces at the head of a line is markdown's indented-code-block marker, so
1777 /// one Tab on a paragraph would reparse it into code and style it as such;
1778 /// two cannot, and the line stays the prose it was. Two is also exactly
1779 /// where a `- ` bullet's content starts, so an indented line lands under its
1780 /// parent item's text instead of beside it — the column a list-aware indent
1781 /// has to hit anyway, which keeps this width from being relitigated later.
1782 const INDENT: &'static str = " ";
1783
1784 /// Indent the selected lines — or the caret's line, with no selection — by
1785 /// one level (Tab).
1786 pub fn indent(&mut self) {
1787 self.reindent(true);
1788 // Nesting changes an ordered list's numbering (the nested item restarts,
1789 // its old siblings resume) — keep the source markers in step.
1790 self.renumber_here();
1791 // Nesting an empty `-` item under a text line reparses that text as a
1792 // setext heading; swap the dash for a `*` before it can (a no-op unless
1793 // the collapse actually happened).
1794 self.avoid_setext_collapse();
1795 }
1796
1797 /// Take one indent level back off the selected lines, or the caret's line
1798 /// (Shift+Tab). A line with no indentation is left exactly as it is.
1799 ///
1800 /// A line with *less* than a full level gives back what it has rather than
1801 /// refusing: outdent's job is to walk a line left, and real documents — hand
1802 /// written, or reflowed by some other editor — are full of indentation that
1803 /// was never a clean multiple of anything. Refusing there would strand the
1804 /// line at a depth Shift+Tab couldn't undo.
1805 pub fn outdent(&mut self) {
1806 self.reindent(false);
1807 self.renumber_here();
1808 }
1809
1810 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
1811 ///
1812 /// One splice across the whole line range, never one per line: a Tab is one
1813 /// thing the user did, so it has to be one undo step and one reparse. Per
1814 /// line, twig would reparse the document once per line and leave a stack of
1815 /// steps that Shift+⌘Z walks back one line at a time.
1816 fn reindent(&mut self, add: bool) {
1817 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
1818 let start = source_line_range(&self.source, sel_start).start;
1819 let end = source_line_range(&self.source, sel_end).end;
1820 let region = self.source[start..end].to_string();
1821 let lines: Vec<&str> = region.split('\n').collect();
1822 // A blank line has no text to move, and padding it would leave nothing
1823 // but trailing whitespace — but Tab on a blank line *is* a request for
1824 // indentation to type into, so the skip only applies where the op has
1825 // other lines to do real work on.
1826 let skip_blank = add && lines.len() > 1;
1827
1828 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
1829 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
1830 let mut line_off = start;
1831 for (i, full) in lines.iter().enumerate() {
1832 if i > 0 {
1833 out.push('\n');
1834 }
1835 // A list item moves by having its whole leading prefix *replaced*,
1836 // never by having spaces pushed in front of the line. twig spells
1837 // both prefixes, so the quote markers, the parent's indent and an
1838 // ordered marker's extra column all come out right without leaf
1839 // measuring any of them — and a line that only looks like an item
1840 // (a Djot continuation) reports no marker and is left to the plain
1841 // path, where a Tab is just a Tab.
1842 let marker = self.list_marker_on_line(line_off);
1843 let own = marker
1844 .as_ref()
1845 .map(|m| m.marker_start - m.line_start)
1846 .unwrap_or(0);
1847 let delta = if add {
1848 if skip_blank && full.trim().is_empty() {
1849 out.push_str(full);
1850 0
1851 } else if marker.is_some() && self.first_item_of_list(line_off) {
1852 // The first item of a list has no preceding sibling to nest
1853 // under, so a Tab here can't spell a sub-list — twig would
1854 // reparse the shoved-over marker as the same list, only
1855 // indented, which Shift+Tab then can't cleanly undo. Leave the
1856 // item where it is, the way every list editor refuses to
1857 // over-indent a list's first line.
1858 out.push_str(full);
1859 0
1860 } else if marker.is_some() {
1861 // Nesting means standing where a *continuation* of this line
1862 // would stand: past the parent's marker, inside its content
1863 // column. That is `continuation_prefix`, less a checkbox.
1864 let new = self.nesting_prefix_at(line_off);
1865 let delta = new.len() as isize - own as isize;
1866 out.push_str(&new);
1867 out.push_str(&full[own..]);
1868 delta
1869 } else {
1870 out.push_str(Self::INDENT);
1871 out.push_str(full);
1872 Self::INDENT.len() as isize
1873 }
1874 } else if marker.is_some() {
1875 // Unnesting is the mirror: stand where the parent item's own
1876 // line starts, which drops exactly the level it contributed.
1877 let new = self.outdent_prefix_at(line_off);
1878 let delta = new.len() as isize - own as isize;
1879 out.push_str(&new);
1880 out.push_str(&full[own..]);
1881 delta
1882 } else {
1883 // A plain line gives back the ordinary step.
1884 let strip = outdent_width(full, Self::INDENT.len());
1885 out.push_str(&full[strip..]);
1886 -(strip as isize)
1887 };
1888 deltas.push(delta);
1889 line_off += full.len() + 1;
1890 }
1891 // Nothing to give back. Returning before the splice keeps an outdent at
1892 // column zero from spending an undo step on a document it never changed.
1893 if deltas.iter().all(|d| *d == 0) {
1894 return;
1895 }
1896
1897 // Every line's text keeps its offset *within the line*, so the caret is
1898 // remapped by its column, not by its byte offset — which the prefixes on
1899 // the lines above it have already invalidated.
1900 let remap = |off: usize| -> usize {
1901 let (mut old_ls, mut new_ls) = (start, start);
1902 for (line, delta) in lines.iter().zip(&deltas) {
1903 let old_le = old_ls + line.len();
1904 let new_len = (line.len() as isize + delta) as usize;
1905 if off <= old_le {
1906 let col = (off - old_ls) as isize;
1907 return new_ls + ((col + delta).max(0) as usize).min(new_len);
1908 }
1909 old_ls = old_le + 1;
1910 new_ls += new_len + 1;
1911 }
1912 start + out.len()
1913 };
1914 let placed = match self.selection() {
1915 // Keep the rewritten region selected, the way a container toggle
1916 // keeps its own: it leaves a second Tab aimed at the same lines
1917 // rather than at whatever the shifted offsets now happen to cover.
1918 Some(_) => (start + out.len(), Some(start)),
1919 None => (remap(self.caret), None),
1920 };
1921
1922 // A rolled-back splice leaves the old source in place, where every offset
1923 // computed above addresses text that was never written.
1924 if !self.splice(start, end, &out, EditKind::Other) {
1925 return;
1926 }
1927 // `splice` re-anchors to the end of the `Change`, which for a whole-region
1928 // rewrite is the last line's end — nowhere the caret was. Place it, then
1929 // re-record the caret so this is the state redo restores, not the one
1930 // `splice` left behind from the `Change`.
1931 self.caret = placed.0.min(self.source.len());
1932 self.anchor = placed.1;
1933 self.clamp_caret();
1934 self.record_caret();
1935 }
1936
1937 /// The Enter key.
1938 ///
1939 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
1940 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
1941 /// caret is in decides what actually gets written.
1942 ///
1943 /// - paragraph → twig's [`Editor::split_block`], which parts the
1944 /// block at the caret and reopens its container
1945 /// - list item → likewise: the next item, its indent, quote
1946 /// prefix and `[ ]` box all reproduced by twig —
1947 /// except an *empty* item, which exits the list
1948 /// - block quote → likewise: a new paragraph inside the quote
1949 /// - heading → a new *paragraph*, not another heading
1950 /// - code block → a literal newline (stay in the block)
1951 /// - blank line → a literal newline (one Backspace undoes it)
1952 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
1953 /// visible line
1954 ///
1955 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
1956 /// and it is better at it: it drops the whitespace the caret was sitting in
1957 /// front of instead of stranding it at the head of the second half, and it
1958 /// knows continuations leaf's marker scan never covered — a checklist item
1959 /// continues as an *unchecked* checklist item rather than a plain bullet.
1960 ///
1961 /// The exceptions above are exceptions because `split_block` is either wrong
1962 /// there or refuses: parting a fence yields two fences with the code split
1963 /// between them, parting a heading yields a second heading where every editor
1964 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
1965 /// table all report an error rather than a split.
1966 pub fn newline(&mut self) {
1967 if self.view == View::Source {
1968 self.insert_raw("\n");
1969 return;
1970 }
1971 // Enter over a selection replaces it with a paragraph break.
1972 if let Some((s, e)) = self.selection() {
1973 self.splice(s, e, "\n\n", EditKind::Other);
1974 return;
1975 }
1976 // A caret resting exactly between an inline mark's content and its own
1977 // closing delimiter (`**bold**` with nothing after it on the line —
1978 // the WYSIWYG caret's natural end-of-line position) must not splice a
1979 // block break there: every path below eventually does via
1980 // `insert_raw`/`self.caret`, and splicing before the hidden closing
1981 // delimiter would strand it alone on the new line.
1982 self.caret = self.skip_trailing_close_delims(self.caret);
1983 // The block the caret is in. `block_offset_for_caret` nudges off a line
1984 // end (where the caret sits at the doc level); on a bare line (e.g. an
1985 // empty list item) fall back to the caret so the enclosing list/quote is
1986 // still visible in the ancestors.
1987 let off = self.block_offset_for_caret().unwrap_or(self.caret);
1988 let kinds: Vec<Kind> = self
1989 .editor
1990 .ancestors_at(off)
1991 .map(|c| c.into_iter().map(|m| m.kind).collect())
1992 .unwrap_or_default();
1993 let has = |k: Kind| kinds.contains(&k);
1994
1995 if has(Kind::CodeBlock) {
1996 self.insert_raw("\n");
1997 return;
1998 }
1999 // An *empty* list item exits the list — the standard double-Enter — which
2000 // `split_block` reports as an error rather than a split (there is no
2001 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2002 // the AST gate — it answers from the tree, so a `- ` that reads as a
2003 // marker byte-for-byte but opens no item (a setext underline, a Djot
2004 // continuation line) never reaches here.
2005 if let Some(marker) = self.list_marker_on_line(self.caret)
2006 && self.item_is_empty(&marker)
2007 {
2008 self.exit_list(&marker);
2009 return;
2010 }
2011 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2012 // not another full paragraph break — so it moves down one line and one
2013 // Backspace undoes it, not two. (`split_block` errors here too.)
2014 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2015 let line_end = self.source[self.caret..]
2016 .find('\n')
2017 .map_or(self.source.len(), |i| self.caret + i);
2018 if self.source[line_start..line_end].trim().is_empty() {
2019 self.insert_raw("\n");
2020 return;
2021 }
2022 // In `Preserve` flow a soft break is a *visible* line the author means to
2023 // make, so Enter writes a single `\n` and typing continues the same
2024 // paragraph on the next line — the behaviour of an ordinary text editor.
2025 // A second Enter then lands on the blank line above and takes the
2026 // empty-line branch, so double-Enter still promotes to a full paragraph
2027 // break; and Backspace, which deletes a lone `\n` over a soft break,
2028 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2029 // render as an invisible space, so Enter keeps making the paragraph break
2030 // that actually shows.
2031 //
2032 // Only in running prose. A list or a quote has a continuation of its own
2033 // to write, and a `\n` there is not a soft line but a lost container.
2034 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2035 if self.line_flow == LineFlow::Preserve && !in_container {
2036 self.insert_raw("\n");
2037 return;
2038 }
2039 // A heading gets a *paragraph*, never a second heading: Enter at the end
2040 // of a title is how every editor is asked for the body under it, and
2041 // `split_block` would repeat the `#` instead. Whitespace at the split
2042 // point goes with the break rather than opening the new paragraph, which
2043 // is what `split_block` does everywhere else.
2044 if has(Kind::Heading) {
2045 let mut end = self.caret;
2046 while self.source.as_bytes().get(end) == Some(&b' ') {
2047 end += 1;
2048 }
2049 self.splice(self.caret, end, "\n\n", EditKind::Other);
2050 return;
2051 }
2052 self.split_block_here();
2053 }
2054
2055 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2056 /// the caret in the second half.
2057 ///
2058 /// twig reopens whatever the first half was inside of — the bullet with its
2059 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2060 /// reason this replaced the markup leaf used to spell from the line's bytes.
2061 /// It renumbers nothing, though: a new item mid-list is written with its
2062 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2063 /// behind it, folded into the same undo step.
2064 ///
2065 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2066 /// shape still moves the caret down rather than swallowing the keystroke.
2067 fn split_block_here(&mut self) {
2068 match self.editor.split_block(self.caret) {
2069 Ok(change) => {
2070 self.last_edit_kind = None;
2071 self.refresh();
2072 self.anchor = None;
2073 self.caret = change.new.end;
2074 self.dirty = self.source != self.clean_source;
2075 self.status = None;
2076 self.clamp_caret();
2077 self.record_caret();
2078 // Aimed at the new block's *start*: the caret twig leaves is one
2079 // past the marker it wrote, where there is no list in reach.
2080 self.renumber_at(change.new.start);
2081 }
2082 Err(_) => self.insert_raw("\n\n"),
2083 }
2084 }
2085
2086 /// Whether the item on the marker's line carries no content — the shape
2087 /// double-Enter reads as "I'm done with this list."
2088 fn item_is_empty(&self, line: &ListMarker) -> bool {
2089 let content_start = line.content_start().min(self.source.len());
2090 let line_end = self.source[self.caret..]
2091 .find('\n')
2092 .map(|i| self.caret + i)
2093 .unwrap_or(self.source.len());
2094 self.source[content_start..line_end.max(content_start)]
2095 .trim()
2096 .is_empty()
2097 }
2098
2099 /// Leave the list: replace the empty item's marker with a blank line, so the
2100 /// caret lands in a fresh paragraph below it.
2101 ///
2102 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2103 /// quote), and the caret's new line keeps the `> ` it was already behind —
2104 /// leaving the list without also leaving the quote.
2105 fn exit_list(&mut self, line: &ListMarker) {
2106 let prefix = self.quote_prefix_at(line.marker_start);
2107 let blank = prefix.trim_end();
2108 self.splice(
2109 line.line_start,
2110 self.caret,
2111 &format!("{blank}\n{prefix}"),
2112 EditKind::Other,
2113 );
2114 }
2115
2116 /// What a line continuing the containers at `off` has to open with — the
2117 /// quote markers reproduced, each enclosing item's marker as its width in
2118 /// spaces. Also the column a nested item's marker stands in, which is what
2119 /// makes it Tab's answer.
2120 fn continuation_prefix_at(&mut self, off: usize) -> String {
2121 self.editor
2122 .document()
2123 .and_then(|mut d| d.continuation_prefix(off))
2124 .map(|p| p.text)
2125 .unwrap_or_default()
2126 }
2127
2128 /// The column a *nested list* may open at inside the item at `off` — which
2129 /// is not always where the item's own text continues.
2130 ///
2131 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2132 /// it is markup a rich view hides, and the item's own wrapped text does
2133 /// stand past it. But a nested list may only open at the *list* marker's
2134 /// column, and four columns further in is an indented continuation of the
2135 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2136 /// So the box's own width goes back.
2137 ///
2138 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2139 /// reports the list marker's column apart from the box; `checked` is what
2140 /// says a box is there at all, so only its width is being measured here.
2141 fn nesting_prefix_at(&mut self, off: usize) -> String {
2142 let cont = self.continuation_prefix_at(off);
2143 let Some(item) = self.innermost_list_item(off) else {
2144 return cont;
2145 };
2146 if item.checked.is_none() {
2147 return cont;
2148 }
2149 let box_width = item
2150 .marker_span
2151 .and_then(|m| self.source.get(m))
2152 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2153 .unwrap_or(0);
2154 // The trailing columns are the ones the item's own marker contributed,
2155 // so trimming from the end leaves any quote prefix standing.
2156 cont[..cont.len().saturating_sub(box_width)].to_string()
2157 }
2158
2159 /// Where the line of the item *containing* the item at `off` begins — the
2160 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2161 /// parent contributed. The quote prefix alone for a top-level item, which
2162 /// has no level left to give.
2163 fn outdent_prefix_at(&mut self, off: usize) -> String {
2164 let items: Vec<usize> = self
2165 .editor
2166 .document()
2167 .and_then(|mut d| d.ancestors_at_caret(off))
2168 .map(|c| {
2169 c.into_iter()
2170 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2171 .map(|m| m.span.start)
2172 .collect()
2173 })
2174 .unwrap_or_default();
2175 // The second-innermost item is the parent; its own line's indent is the
2176 // target. `list_marker_on_line` gives that line's prefix directly.
2177 let parent = items.len().checked_sub(2).map(|i| items[i]);
2178 match parent.and_then(|p| self.list_marker_on_line(p)) {
2179 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2180 None => self.quote_prefix_at(off),
2181 }
2182 }
2183
2184 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2185 /// inside one, `"> > "` inside two.
2186 ///
2187 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2188 /// the `>` and the space after it are twig's spelling rather than leaf's.
2189 /// The whole line prefix can't answer this: it also carries the indent of
2190 /// whatever the quote holds, which a blank separator line must *not* repeat.
2191 fn quote_prefix_at(&mut self, off: usize) -> String {
2192 let Ok(chain) = self
2193 .editor
2194 .document()
2195 .and_then(|mut d| d.ancestors_at_caret(off))
2196 else {
2197 return String::new();
2198 };
2199 let quotes: Vec<usize> = chain
2200 .iter()
2201 .filter(|m| m.kind == Kind::BlockQuote)
2202 .map(|m| m.node_id as usize)
2203 .collect();
2204 let Ok(nodes) = self.editor.nodes() else {
2205 return String::new();
2206 };
2207 quotes
2208 .iter()
2209 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2210 .filter_map(|s| self.source.get(s))
2211 .collect()
2212 }
2213
2214 /// Whether the item at `off` sits inside another one — the test Backspace
2215 /// uses to choose between outdenting and dropping the marker.
2216 ///
2217 /// Counted from the AST rather than from the line's leading whitespace,
2218 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2219 fn item_is_nested(&mut self, off: usize) -> bool {
2220 self.editor
2221 .document()
2222 .and_then(|mut d| d.ancestors_at_caret(off))
2223 .map(|c| {
2224 c.into_iter()
2225 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2226 .count()
2227 > 1
2228 })
2229 .unwrap_or(false)
2230 }
2231
2232 /// The innermost list item containing `probe`, under twig's **caret**
2233 /// containment rule — a block's end is inside it.
2234 ///
2235 /// Half-open containment can't answer this. An empty item's span is exactly
2236 /// its marker, so the caret sitting after `- ` is one past the end and the
2237 /// item it is plainly in tests as out of reach; that is the shape
2238 /// double-Enter has to recognise to leave the list.
2239 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2240 let chain = self
2241 .editor
2242 .document()
2243 .and_then(|mut d| d.ancestors_at_caret(probe))
2244 .ok()?;
2245 let id = chain
2246 .iter()
2247 .rev()
2248 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2249 .node_id as usize;
2250 self.editor.nodes().ok()?.get(id).cloned()
2251 }
2252
2253 /// The list marker opening `off`'s line, per twig — `None` when that line
2254 /// opens no list item.
2255 ///
2256 /// [`Document::line_prefix`] is the whole hidden run from the line start:
2257 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
2258 /// and it is `None` on a *continuation* line, which opens nothing. That last
2259 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
2260 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2261 /// a paragraph and ` - b` is literal text — identical bytes, and only the
2262 /// parser knows which document it is looking at.
2263 ///
2264 /// The item's own marker is separated out via its
2265 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2266 /// the containers around it contribute and what the item does.
2267 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2268 let off = off.min(self.source.len());
2269 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2270 // The prefix belongs to a list only when an item's marker closes it —
2271 // a heading's `# ` or a bare quote's `> ` is a prefix too.
2272 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2273 let marker = item.marker_span.clone()?;
2274 if marker.end != prefix.end {
2275 return None;
2276 }
2277 Some(ListMarker {
2278 line_start: prefix.start,
2279 marker_start: marker.start,
2280 text: self.source.get(prefix)?.to_string(),
2281 })
2282 }
2283
2284 /// Whether the list item on `line_start`'s line is the **first item** of its
2285 /// list — the one Tab must not nest, because nesting needs a preceding
2286 /// sibling to become the new parent and a first item has none. `false` for a
2287 /// line that isn't a list item, and for an item with a sibling above it (the
2288 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2289 /// the same in a setext underline that opens no list at all.
2290 fn first_item_of_list(&mut self, line_start: usize) -> bool {
2291 let Some(marker) = self.list_marker_on_line(line_start) else {
2292 return false;
2293 };
2294 // Probe just inside the marker, where the item's own node is in reach —
2295 // the marker offset itself can resolve to the enclosing list, not the
2296 // `list_item`, whose span starts at the marker.
2297 let probe = marker.content_start().min(self.source.len());
2298 let Some(item) = self.innermost_list_item(probe) else {
2299 return false;
2300 };
2301 let Ok(nodes) = self.editor.nodes() else {
2302 return false;
2303 };
2304 match item.parent {
2305 // First when the parent list opens with this very item.
2306 Some(pid) => nodes
2307 .get(pid.0 as usize)
2308 .is_some_and(|p| p.first_child == Some(item.id)),
2309 // A parentless item is trivially the first (and only) one.
2310 None => true,
2311 }
2312 }
2313
2314 pub fn backspace(&mut self) {
2315 if let Some((s, e)) = self.selection() {
2316 self.splice(s, e, "", EditKind::Other);
2317 return;
2318 }
2319 // WYSIWYG: Backspace at the very start of a list item's content is a
2320 // structural key, not a character delete — it walks the "un-indent, then
2321 // un-list" ladder every list editor gives that keystroke (outdent a
2322 // nested item, strip a top-level one's marker to a paragraph). In source
2323 // view the `- ` is visible text the user is deleting a byte of, so it
2324 // keeps its literal meaning there, like Enter does.
2325 if self.view != View::Source && self.backspace_list_start() {
2326 return;
2327 }
2328 // WYSIWYG: and the same at the start of a heading's content — the `# `
2329 // there is markup the rich view hides, not text the user typed.
2330 if self.view != View::Source && self.backspace_heading_start() {
2331 return;
2332 }
2333 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2334 // the markup apart under a caret that cannot see it — see
2335 // `delete_around_block_media`.
2336 if self.view != View::Source && self.delete_around_block_media(false) {
2337 return;
2338 }
2339 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2340 // stop, not a single newline. On a line with no text of its own, the byte
2341 // before the caret is a `\n` that spells part of a block boundary — the gap
2342 // between two blocks, drawn but never a caret home. Removing just it strands
2343 // the caret in that gap and leaves an odd blank line the eye reads as one
2344 // separator but the caret can't land on: the "extra newline" left behind
2345 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2346 // Deleting to the previous stop instead collapses the whole break at once,
2347 // landing the caret at the end of the block above. Two blank lines in a row
2348 // are one stop apart, so this still removes exactly one — the lone-Enter /
2349 // lone-Backspace symmetry the empty-line case is built on is untouched.
2350 if self.view != View::Source
2351 && self.caret > self.caret_floor()
2352 && self.caret_on_blank_line()
2353 && let Some(stop) = self.vmap.stop_before(self.caret)
2354 {
2355 let stop = stop.max(self.caret_floor());
2356 if stop < self.caret {
2357 self.splice(stop, self.caret, "", EditKind::Delete);
2358 return;
2359 }
2360 }
2361 if self.caret > self.caret_floor() {
2362 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2363 // takes the whole tag — a single-byte step would leave a broken `<br`
2364 // showing in the cell. Rich view only (source view edits the literal).
2365 if self.view != View::Source
2366 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2367 {
2368 let start = start.max(self.caret_floor());
2369 if start < end {
2370 self.splice(start, end, "", EditKind::Delete);
2371 return;
2372 }
2373 }
2374 // Aim the delete at the character the writer can *see* behind the
2375 // caret, never at a delimiter the rich view drew nothing for. Two
2376 // steps, and either can apply: from the far side of a run's closing
2377 // `**` step back into the run (the caret is drawn at the end of its
2378 // word), and at the start of a run's text step out past its opening
2379 // `**` to the character in front of it, leaving the run standing.
2380 // Without them a plain Backspace unspells the phrase it is editing
2381 // and leaves a literal asterisk on screen.
2382 let end = if self.view == View::Source {
2383 self.caret
2384 } else {
2385 let inside = self.step_inside_close_delims(self.caret);
2386 self.skip_leading_open_delims(inside)
2387 .max(self.caret_floor())
2388 };
2389 // Never delete back across the floor — that would eat hidden
2390 // frontmatter the WYSIWYG caret can't even see.
2391 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2392 // Take a hidden escape backslash with the char it escapes: the rich
2393 // view draws `\*` as a single `*`, so Backspace over it must delete
2394 // both bytes, never strand the `\` as a lone visible backslash (the
2395 // mirror of the Hidden-mode typing that wrote the escape). Source view
2396 // shows the `\`, so there it is an ordinary character.
2397 if self.view != View::Source
2398 && prev > self.caret_floor()
2399 && self.is_hidden_escape(prev - 1)
2400 {
2401 prev -= 1;
2402 }
2403 if prev < end {
2404 self.splice(prev, end, "", EditKind::Delete);
2405 }
2406 }
2407 }
2408
2409 /// Whether the caret's own source line holds nothing but whitespace — an
2410 /// empty paragraph, or the blank line a block boundary is spelled with. The
2411 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2412 /// no text of its own, so the newline before the caret belongs to the gap
2413 /// between blocks rather than to any word the caret is editing.
2414 fn caret_on_blank_line(&self) -> bool {
2415 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2416 let line_end = self.source[self.caret..]
2417 .find('\n')
2418 .map_or(self.source.len(), |i| self.caret + i);
2419 self.source[line_start..line_end].trim().is_empty()
2420 }
2421
2422 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2423 /// `edge` side — the byte range to delete whole. A table row is one source
2424 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2425 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2426 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2427 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2428 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2429 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
2430 /// apart — no ancestor walk needed. Rich view only; source view shows the
2431 /// literal tag and deletes it a byte at a time.
2432 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2433 let caret = self.caret;
2434 let nodes = self.nodes();
2435 let src = self.source.as_bytes();
2436 nodes
2437 .iter()
2438 .find(|n| {
2439 n.kind == Kind::HardBreak
2440 && n.span.start < n.span.end
2441 && src.get(n.span.start) == Some(&b'<')
2442 && match edge {
2443 BreakEdge::Backward => n.span.end == caret,
2444 BreakEdge::Forward => n.span.start == caret,
2445 }
2446 })
2447 .map(|n| (n.span.start, n.span.end))
2448 }
2449
2450 /// Whether the source byte at `off` is a backslash twig consumed as an escape
2451 /// (hidden in the rich view), as against a literal backslash (drawn). A
2452 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2453 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2454 /// round-trip needed.
2455 fn is_hidden_escape(&self, off: usize) -> bool {
2456 let b = self.source.as_bytes();
2457 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2458 }
2459
2460 /// Backspace's list behaviour: when the caret sits exactly at the start of a
2461 /// list item's content (right after its marker), outdent the item if it's
2462 /// nested, else strip the marker so it becomes a paragraph. Returns whether
2463 /// it acted — `false` leaves Backspace its ordinary character delete.
2464 fn backspace_list_start(&mut self) -> bool {
2465 let Some(marker) = self.list_marker_on_line(self.caret) else {
2466 return false;
2467 };
2468 // Only right after the marker. That the line opens a real item is
2469 // already settled: `list_marker_on_line` answers from the tree.
2470 if self.caret != marker.content_start() {
2471 return false;
2472 }
2473 if self.item_is_nested(marker.marker_start) {
2474 // Nested: give back one level, keeping the marker and carrying the
2475 // caret with it.
2476 self.outdent();
2477 } else {
2478 // Top level: drop the marker, leaving a paragraph, then renumber the
2479 // siblings the removed item was counted among. Only the marker goes —
2480 // a quote prefix in front of it still has a quote to hold up.
2481 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2482 self.renumber_here();
2483 }
2484 true
2485 }
2486
2487 /// Backspace's heading behaviour: with the caret exactly at the start of an
2488 /// ATX heading's content — right after the `#` marker the rich view hides —
2489 /// strip the marker so the line becomes a paragraph. The peer of
2490 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2491 /// reasoning: hidden block markup is structure, so the keystroke over it is
2492 /// structural.
2493 ///
2494 /// Without this the ordinary delete takes the space out of `# Title` and
2495 /// leaves `#Title`, which is no longer a heading at all — the hash the view
2496 /// had been hiding surfaces as literal text the user has to delete a second
2497 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2498 /// other end) goes with the marker for the same reason.
2499 ///
2500 /// Returns whether it acted; `false` leaves Backspace its character delete.
2501 fn backspace_heading_start(&mut self) -> bool {
2502 let caret = self.caret;
2503 // The heading whose content opens exactly at the caret. A bare `#` has no
2504 // content span at all — its content starts (and ends) where the line does.
2505 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2506 let (start, end) = match &n.content_span {
2507 Some(c) => (c.start, c.end),
2508 None => (n.span.end, n.span.end),
2509 };
2510 (n.kind == Kind::Heading && start == caret)
2511 .then(|| (n.span.clone(), end, n.marker_span.clone()))
2512 }) else {
2513 return false;
2514 };
2515 // twig reports the marker's own extent, so there is nothing to walk back
2516 // over and no `#` in this file. A setext heading has no marker — its
2517 // content opens the line — so it falls through to the ordinary delete,
2518 // as does anything else sitting at a content start.
2519 // `m.end == caret` is what excludes a setext heading, whose marker is the
2520 // underline *after* the content rather than a prefix before it.
2521 let Some(marker) = marker.filter(|m| m.end == caret) else {
2522 return false;
2523 };
2524 let start = marker.start;
2525 // A closing `#` sequence is hidden too, so it can't be left behind. Only
2526 // when the tail really is one: trailing spaces alone are nothing to strip.
2527 let tail = &self.source[content_end..span.end];
2528 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2529 let kept = self.source[caret..content_end].to_string();
2530 self.splice(start, span.end, &kept, EditKind::Other);
2531 // The splice leaves the caret past the text it re-wrote; the caret
2532 // belongs where the content now starts, which is where it already was.
2533 self.caret = start;
2534 self.record_caret();
2535 } else {
2536 self.splice(start, caret, "", EditKind::Other);
2537 }
2538 true
2539 }
2540
2541 pub fn delete_forward(&mut self) {
2542 if let Some((s, e)) = self.selection() {
2543 self.splice(s, e, "", EditKind::Other);
2544 } else if self.caret < self.source.len() {
2545 // The mirror of Backspace's: forward-delete in front of a picture
2546 // would eat the `!` off its markup and leave a link where a photo was.
2547 if self.view != View::Source && self.delete_around_block_media(true) {
2548 return;
2549 }
2550 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2551 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2552 // strands a broken `<br` in the cell.
2553 if self.view != View::Source
2554 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2555 {
2556 self.splice(start, end, "", EditKind::Delete);
2557 return;
2558 }
2559 // The mirror of Backspace's two steps: from in front of a run's
2560 // opening `**` step into it, onto the first letter of its text, and
2561 // at the end of a run's text step out past its closing `**` to the
2562 // character beyond. Either way Delete takes the character it looks
2563 // like it is pointing at, and never a delimiter drawn as nothing.
2564 // The caret then settles back inside the run it was standing in —
2565 // see `settle_inside_close_delims`.
2566 let from = if self.view == View::Source {
2567 self.caret
2568 } else {
2569 let inside = self.step_inside_open_delims(self.caret);
2570 self.skip_trailing_close_delims(inside)
2571 };
2572 let next = next_boundary(&self.source, from);
2573 if from < next {
2574 self.splice(from, next, "", EditKind::Delete);
2575 }
2576 }
2577 }
2578
2579 /// Delete from the caret back to the start of the previous word (⌥⌫ /
2580 /// Ctrl+⌫). Deletes the selection instead when one is active.
2581 pub fn delete_word_back(&mut self) {
2582 if let Some((s, e)) = self.selection() {
2583 self.splice(s, e, "", EditKind::Other);
2584 } else {
2585 // A word back from just past a picture is a word *of its markup*, and
2586 // a word back from in front of one runs through the paragraph break
2587 // into the prose above — dissolving the picture either way. See
2588 // `delete_around_block_media`.
2589 if self.view != View::Source && self.delete_around_block_media(false) {
2590 return;
2591 }
2592 let start = self.word_left_from(self.caret).max(self.caret_floor());
2593 if start < self.caret {
2594 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2595 self.splice(s, e, "", EditKind::Delete);
2596 }
2597 }
2598 }
2599
2600 /// Delete from the caret forward to the end of the next word (⌥⌦ /
2601 /// Ctrl+Del). Deletes the selection instead when one is active.
2602 pub fn delete_word_forward(&mut self) {
2603 if let Some((s, e)) = self.selection() {
2604 self.splice(s, e, "", EditKind::Other);
2605 } else {
2606 // The mirror: a word forward from in front of a picture is its markup.
2607 if self.view != View::Source && self.delete_around_block_media(true) {
2608 return;
2609 }
2610 let end = self.word_right_from(self.caret);
2611 if end > self.caret {
2612 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2613 self.splice(s, e, "", EditKind::Delete);
2614 }
2615 }
2616 }
2617
2618 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
2619 /// selection instead when one is active, as every other delete here does.
2620 ///
2621 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
2622 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
2623 /// stops at the first character and this takes the indentation with it, the
2624 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
2625 /// leave an indent behind that nothing can then ask to delete, where a caret
2626 /// left at column 0 is one press of Home away from either.
2627 pub fn delete_to_line_start(&mut self) {
2628 if let Some((s, e)) = self.selection() {
2629 self.splice(s, e, "", EditKind::Other);
2630 return;
2631 }
2632 // Never back across the floor: hidden frontmatter isn't on this line, or
2633 // on any line the WYSIWYG caret can see.
2634 let (start, _) = self.line_span();
2635 let start = start.max(self.caret_floor());
2636 if start < self.caret {
2637 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2638 self.splice(s, e, "", EditKind::Delete);
2639 }
2640 }
2641
2642 /// Kill from the caret to the end of its line (^K). Deletes the selection
2643 /// instead when one is active.
2644 ///
2645 /// At the end of the line it does nothing, rather than pulling the line
2646 /// below up into this one. Joining has no meaning to give it in both views
2647 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
2648 /// there is nothing there to delete, while the newline a *source* line ends
2649 /// with is only half of the blank line that separates two paragraphs —
2650 /// deleting one leaves a soft break, which is not the join it looks like.
2651 /// The views agreeing is worth more than emacs' second press, and Delete is
2652 /// already the key that joins.
2653 pub fn delete_to_line_end(&mut self) {
2654 if let Some((s, e)) = self.selection() {
2655 self.splice(s, e, "", EditKind::Other);
2656 return;
2657 }
2658 let (_, end) = self.line_span();
2659 if end > self.caret {
2660 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2661 self.splice(s, e, "", EditKind::Delete);
2662 }
2663 }
2664
2665 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
2666 ///
2667 /// A glyph-space range covers what the user can see, which for `**bold**` is
2668 /// the word and never the delimiters around it — so deleting the word on its
2669 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
2670 /// word, and the styling was the word's; the two go together. Only the
2671 /// node's delimiters are taken, and those are hidden here anyway, so nothing
2672 /// visible outside the range is lost.
2673 ///
2674 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
2675 /// the strong, and only then is the strong empty too.
2676 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
2677 if self.view == View::Source {
2678 return (start, end);
2679 }
2680 let nodes = self.nodes();
2681 let (mut s, mut e) = (start, end);
2682 loop {
2683 let mut grew = false;
2684 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
2685 let Some(text) = inline_content_span(n, &self.source) else {
2686 continue;
2687 };
2688 // Some of its text survives, so the node still has a job.
2689 if text.start < s || text.end > e {
2690 continue;
2691 }
2692 if n.span.start < s || n.span.end > e {
2693 s = s.min(n.span.start);
2694 e = e.max(n.span.end);
2695 grew = true;
2696 }
2697 }
2698 if !grew {
2699 return (s, e);
2700 }
2701 }
2702 }
2703
2704 /// One splice of document text, keeping the **mark-edge rule**: an inline
2705 /// mark's content never begins or ends with whitespace. In Markdown and Djot
2706 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
2707 /// is four literal asterisks around a word, and a rich view drawing the
2708 /// document faithfully has no choice but to show them. That is correct
2709 /// rendering of what the file says, and nobody typing a space after a bold
2710 /// word meant to say it.
2711 ///
2712 /// So the space goes *outside* the run instead — `**bold** ` — which is the
2713 /// same document to a reader and a live one to a parser. The caret follows it
2714 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
2715 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
2716 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
2717 ///
2718 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
2719 /// through here, so the rule holds however the whitespace arrives at the
2720 /// edge. The repair is decided *after* the plain edit, by asking whether the
2721 /// mark actually died: a code span's backticks aren't whitespace-sensitive
2722 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
2723 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2724 let fix = self.mark_edge_fix(start, end, text);
2725 if !self.splice_exact(start, end, text, kind) {
2726 return false;
2727 }
2728 if let Some(fix) = fix {
2729 self.repair_mark_edges(fix);
2730 }
2731 if text.is_empty() && end > start {
2732 self.settle_inside_close_delims();
2733 }
2734 true
2735 }
2736
2737 /// After a delete, take a caret left standing past a run's closing delimiters
2738 /// back inside the run.
2739 ///
2740 /// A delete leaves the caret where the deleted bytes began, and when those
2741 /// bytes were the last thing after a marked phrase — the space the mark-edge
2742 /// rule pushed out of `**bold** `, say — that spot is the far side of the
2743 /// closing `**`. The rich view has nothing to draw there: the delimiters are
2744 /// hidden, so the caret shows at the end of the word either way, and the two
2745 /// offsets are one place on screen with two different meanings. Typing at the
2746 /// outer one lands past the run, so the writer who backspaced a space out of
2747 /// their bold phrase watches the next character come out plain, and the
2748 /// toolbar button go dark, with the caret never appearing to move.
2749 ///
2750 /// The end of the run's text is the caret's home there — a delete that took
2751 /// away everything after a phrase leaves the caret at the end of that phrase,
2752 /// which is inside it — so it settles onto that
2753 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
2754 /// walk, through every mark closing at the point): the word stays bold, the
2755 /// button stays lit, and the next character carries on the phrase.
2756 ///
2757 /// Rich view only, and only where a mark really closes at the caret — mid-run
2758 /// or in plain prose no span ends there and the caret stays put. The opening
2759 /// edge is left alone on purpose: a caret in front of a run inherits from the
2760 /// text on its left, which is the plain text outside.
2761 fn settle_inside_close_delims(&mut self) {
2762 if self.view != View::Wysiwyg {
2763 return;
2764 }
2765 let at = self.step_inside_close_delims(self.caret);
2766 if at != self.caret {
2767 self.caret = at;
2768 self.clear_pending();
2769 self.record_caret();
2770 }
2771 }
2772
2773 /// The splice exactly as asked, with no mark-edge repair — for the callers
2774 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
2775 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
2776 /// the bytes they inserted.
2777 ///
2778 /// One `edit_range` through twig, then re-anchor the caret from the returned
2779 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
2780 /// Markdown/Djot) leaves the document untouched and reports.
2781 ///
2782 /// Returns whether the edit landed — for a caller that has offsets of its
2783 /// own to place afterwards, which a rolled-back splice would leave pointing
2784 /// into text that never came to exist.
2785 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
2786 // twig records an undo step for every edit; when this one continues a
2787 // run of the same kind (typing, deleting), tell twig to fold it into the
2788 // step before it so the whole run undoes at once.
2789 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
2790 // Hand twig the pre-edit caret before the splice, so the undo step it
2791 // retires carries where the caret was standing.
2792 self.record_caret();
2793 match self.editor.edit_range(start, end, text) {
2794 Ok(change) => {
2795 if coalesce {
2796 let _ = self.editor.coalesce_last_undo();
2797 }
2798 self.last_edit_kind = Some(kind);
2799 self.refresh();
2800 self.caret = change.new.end;
2801 self.anchor = None;
2802 self.goal_col = None;
2803 self.clear_pending();
2804 self.dirty = self.source != self.clean_source;
2805 self.status = None;
2806 // And the post-edit caret, so a later redo restores it.
2807 self.record_caret();
2808 true
2809 }
2810 // The edit was rolled back, so twig's history did not move and
2811 // neither may ours: pushing here would leave a step with no edit
2812 // under it and shift every later undo onto the wrong caret.
2813 Err(e) => {
2814 self.status = Some(format!("edit: {e}"));
2815 false
2816 }
2817 }
2818 }
2819
2820 /// The re-spelling that would keep the mark-edge rule for the edit
2821 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
2822 /// against a delimiter and the plain splice is already right. Computed
2823 /// *before* the edit, while the run's spans and delimiters can still be read
2824 /// off the document; applied afterwards, and only if the mark really died —
2825 /// see [`repair_mark_edges`](Self::repair_mark_edges).
2826 ///
2827 /// Rich view only. Source view is for typing raw markup, where a space put
2828 /// against a `**` is exactly the character it looks like.
2829 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
2830 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
2831 return None;
2832 }
2833 // Every inline mark standing over the edit, outermost first, with the
2834 // content span that says where its delimiters are.
2835 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
2836 .editor
2837 .ancestors_at(start)
2838 .unwrap_or_default()
2839 .into_iter()
2840 .filter_map(|m| {
2841 let kind = inline_kind(&m.kind)?;
2842 let content = m.content_span.clone()?;
2843 Some((kind, m.span.clone(), content))
2844 })
2845 .collect();
2846 // The innermost run whose *content* holds the whole edit: the one whose
2847 // text is being changed, rather than one the edit merely sits under.
2848 let (kind, span, content) = chain
2849 .iter()
2850 .rev()
2851 .find(|(_, _, c)| c.start <= start && end <= c.end)?
2852 .clone();
2853 // What that content becomes. Whitespace at either end of it is what
2854 // would put out the mark.
2855 let body = format!(
2856 "{}{text}{}",
2857 &self.source[content.start..start],
2858 &self.source[end..content.end]
2859 );
2860 let (lead, trail) = if body.trim().is_empty() {
2861 // Nothing but whitespace left: there is no content to mark at all,
2862 // and the delimiters go with it rather than closing on a space.
2863 (body.len(), 0)
2864 } else {
2865 (
2866 body.len() - body.trim_start().len(),
2867 body.len() - body.trim_end().len(),
2868 )
2869 };
2870 // Nothing against a delimiter, and something still between them: the
2871 // plain edit stands. An emptied run is broken just as surely (`**b**`
2872 // with the `b` deleted is the literal `****`) and is re-spelt as the
2873 // nothing it now says.
2874 if lead == 0 && trail == 0 && !body.is_empty() {
2875 return None;
2876 }
2877 // Marks that open or close exactly where this one does — `***both***` is
2878 // two runs sharing an edge — spell their delimiters as one run of bytes,
2879 // so the whitespace has to clear all of them together.
2880 let (mut open_at, mut close_at) = (span.start, span.end);
2881 for _ in 0..chain.len() {
2882 match chain.iter().find(|(_, _, c)| c.start == open_at) {
2883 Some((_, s, _)) => open_at = s.start,
2884 None => break,
2885 }
2886 }
2887 for _ in 0..chain.len() {
2888 match chain.iter().find(|(_, _, c)| c.end == close_at) {
2889 Some((_, s, _)) => close_at = s.end,
2890 None => break,
2891 }
2892 }
2893 let open = &self.source[open_at..content.start];
2894 let close = &self.source[content.end..close_at];
2895 let core = &body[lead..body.len() - trail];
2896 let respelt = if core.is_empty() {
2897 body.clone()
2898 } else {
2899 format!(
2900 "{}{open}{core}{close}{}",
2901 &body[..lead],
2902 &body[body.len() - trail..]
2903 )
2904 };
2905 // The caret sits just past the inserted text within the new content —
2906 // which, when that lands in the whitespace, is now outside the delimiters.
2907 let pos = (start - content.start) + text.len();
2908 let caret = if core.is_empty() || pos <= lead {
2909 open_at + pos
2910 } else if pos >= lead + core.len() {
2911 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
2912 } else {
2913 open_at + lead + open.len() + (pos - lead)
2914 };
2915 Some(MarkEdgeFix {
2916 kind,
2917 probe: content.start,
2918 start: open_at,
2919 end: close_at + text.len() - (end - start),
2920 text: respelt,
2921 caret,
2922 // The marks in force here, resolved against any armed sticky delta —
2923 // what the writer is typing in, and so what has to still be true on
2924 // the far side of the delimiter the caret just stepped over.
2925 want: chain
2926 .iter()
2927 .filter(|(_, s, _)| start < s.end)
2928 .map(|(k, _, _)| *k)
2929 .collect::<InlineMarks>()
2930 .xor(self.pending_here()),
2931 })
2932 }
2933
2934 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
2935 /// did break the mark. Whether whitespace at a delimiter is fatal is the
2936 /// format's business, not leaf's: `**bold **` is no longer strong, while
2937 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
2938 /// don't care either. Asking the parser afterwards settles it for every kind
2939 /// and format at once, and costs a re-spelling only where one is due.
2940 ///
2941 /// The repair rides along with the edit that caused it — one undo step puts
2942 /// back what the writer typed, not a delimiter shuffle they never saw.
2943 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
2944 if fix.end > self.source.len() {
2945 return;
2946 }
2947 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
2948 return; // still a mark: these delimiters don't mind the whitespace
2949 }
2950 let resumed = self.last_edit_kind;
2951 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
2952 return;
2953 }
2954 let _ = self.editor.coalesce_last_undo();
2955 // The keystroke owns the undo step, so the run of typing it belongs to
2956 // keeps coalescing over the repair rather than breaking in two here.
2957 self.last_edit_kind = resumed;
2958 self.caret = fix.caret.min(self.source.len());
2959 self.anchor = None;
2960 self.goal_col = None;
2961 self.rearm(fix.want);
2962 self.clamp_caret();
2963 self.record_caret();
2964 }
2965
2966 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
2967 /// writer is typing in, carried across an edit that moved the caret out of
2968 /// the run holding them. Arms nothing when the caret already stands in
2969 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
2970 /// a clean delta here (see [`toggle`](Self::toggle)).
2971 fn rearm(&mut self, want: InlineMarks) {
2972 let here: InlineMarks = self
2973 .marks_at(self.caret)
2974 .into_iter()
2975 .map(|(k, _)| k)
2976 .collect();
2977 self.pending_marks = want.xor(here);
2978 self.pending_at = Some(self.caret);
2979 }
2980
2981 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
2982 /// which backslash-escapes any character that would otherwise open markup in
2983 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
2984 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
2985 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
2986 /// a collapsed point — a selection is deleted by the caller first, since
2987 /// `insert_literal` inserts rather than replaces.
2988 fn insert_literal_at(
2989 &mut self,
2990 at: usize,
2991 text: &str,
2992 kind: EditKind,
2993 force_coalesce: bool,
2994 ) -> bool {
2995 // `force_coalesce` folds this into the immediately preceding edit (the
2996 // selection-delete of an overwrite) so the pair is one undo step; else it
2997 // coalesces only when it continues a run of the same-kind typing.
2998 let coalesce =
2999 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3000 // The mark-edge rule holds for typed text however it is spelled — see
3001 // `splice`. Only an insert twig passed through unchanged can use it,
3002 // since a fix is measured in the bytes that actually land, and an escape
3003 // adds bytes this couldn't have counted.
3004 let fix = self.mark_edge_fix(at, at, text);
3005 self.record_caret();
3006 match self.editor.insert_literal(at, text) {
3007 Ok(change) => {
3008 if coalesce {
3009 let _ = self.editor.coalesce_last_undo();
3010 }
3011 self.last_edit_kind = Some(kind);
3012 self.refresh();
3013 self.caret = change.new.end;
3014 self.anchor = None;
3015 self.goal_col = None;
3016 self.clear_pending();
3017 self.dirty = self.source != self.clean_source;
3018 self.status = None;
3019 self.record_caret();
3020 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3021 self.repair_mark_edges(fix);
3022 }
3023 true
3024 }
3025 Err(e) => {
3026 self.status = Some(format!("edit: {e}"));
3027 false
3028 }
3029 }
3030 }
3031
3032 /// After a structural list edit (a new item, a nest/unnest), renumber the
3033 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3034 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3035 /// renumber as its own edit; fold it into the edit that triggered it so the
3036 /// two undo as one, and only when it actually changed the source (a no-op or
3037 /// a caret outside any ordered list must not coalesce the real edit into the
3038 /// step before it).
3039 fn renumber_here(&mut self) {
3040 self.renumber_at(self.caret);
3041 }
3042
3043 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3044 /// caret — for an edit that leaves the caret one past the item it just wrote,
3045 /// where twig resolves no list to renumber.
3046 fn renumber_at(&mut self, off: usize) {
3047 let before = self.source.clone();
3048 if self.editor.renumber_ordered_lists(off).is_err() {
3049 return; // not inside an ordered list — nothing to renumber
3050 }
3051 self.refresh();
3052 if self.source != before {
3053 let _ = self.editor.coalesce_last_undo();
3054 self.dirty = self.source != self.clean_source;
3055 self.clamp_caret();
3056 self.record_caret();
3057 }
3058 }
3059
3060 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3061 /// sub-item written directly beneath a text line reparses that text as a
3062 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
3063 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3064 /// bullets can't underline anything, so swap the dash for a `*`: the item
3065 /// stays an empty nested bullet, the parent stays prose, and the source
3066 /// round-trips instead of hiding a heading the user never asked for. Folded
3067 /// into the triggering edit's undo step, the way renumbering is.
3068 ///
3069 /// Gated on the collapse having actually happened (the swapped dash was
3070 /// swallowed into a `heading`), so a real setext heading the author wrote —
3071 /// or a `- x` with content, which can't underline anything — is never
3072 /// touched. This has to live in the *edit*, not the renderer: leaving the
3073 /// hazardous bytes on disk and only painting over them would ship a file
3074 /// every other CommonMark tool reads as a heading.
3075 ///
3076 /// This one keeps its own byte scan, and has to: the hazard is precisely
3077 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3078 /// which asks twig which lines open an item — reports nothing here. There is
3079 /// no node to ask about. It is also the last Markdown spelling leaf writes on
3080 /// purpose rather than for want of an answer; once twig spells continuations
3081 /// itself, avoiding the trap becomes twig's, and this goes.
3082 ///
3083 /// [`list_marker_on_line`]: Self::list_marker_on_line
3084 fn avoid_setext_collapse(&mut self) {
3085 let caret = self.caret.min(self.source.len());
3086 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3087 let bytes = self.source.as_bytes();
3088 let mut dash = line_start;
3089 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3090 dash += 1;
3091 }
3092 // A dash bullet is the only marker that doubles as a setext underline.
3093 if bytes.get(dash) != Some(&b'-') {
3094 return;
3095 }
3096 // Only an *empty* item is a bare underline; `- x` carries content and
3097 // can't fold the line above into a heading.
3098 let line_end = self.source[dash..]
3099 .find('\n')
3100 .map_or(self.source.len(), |i| dash + i);
3101 if !self.source[dash + 1..line_end].trim().is_empty() {
3102 return;
3103 }
3104 // The tell: that dash was swallowed into a `heading`. A properly nested
3105 // empty item sits under a `list_item`, with no heading in reach. Probe
3106 // the dash byte itself (well inside the heading), not the caret, whose
3107 // end-of-line offset can fall on the half-open span boundary.
3108 let collapsed = self
3109 .editor
3110 .ancestors_at(dash)
3111 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3112 .unwrap_or(false);
3113 if !collapsed {
3114 return;
3115 }
3116 let caret = self.caret;
3117 if self.splice(dash, dash + 1, "*", EditKind::Other) {
3118 // Same width, so the caret keeps its column; fold into the edit that
3119 // triggered this so Tab stays one undo step.
3120 let _ = self.editor.coalesce_last_undo();
3121 self.caret = caret.min(self.source.len());
3122 self.clamp_caret();
3123 self.record_caret();
3124 }
3125 }
3126
3127 fn snapshot(&self) -> CaretState {
3128 CaretState {
3129 caret: self.caret,
3130 anchor: self.anchor,
3131 }
3132 }
3133
3134 /// Hand twig the current caret and selection as the blob for the live
3135 /// document state. Called before an edit — so the step twig retires records
3136 /// where the caret was, and undo can restore it — and again once the op has
3137 /// placed the caret, so redo restores where the edit left it.
3138 ///
3139 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3140 /// caret through its own history, so coalescing falls out for free (folding
3141 /// two twig steps into one drops the intermediate blob, keeping the run's
3142 /// first) and the parallel stacks that had to march in lockstep — and could
3143 /// silently drift out of it — are gone.
3144 fn record_caret(&mut self) {
3145 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3146 }
3147
3148 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3149 /// the toggled region selected so a second press cleanly reverses it.
3150 pub fn toggle(&mut self, kind: InlineKind) {
3151 // Ahead of the no-selection branch below: arming a mark for text not yet
3152 // typed is a promise `insert` cannot keep in a format with no delimiters
3153 // to spell it with. Per *kind*, not per format — Markdown spells three
3154 // of the eight marks, djot all eight, HTML seven.
3155 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3156 return;
3157 }
3158 let Some((s, e)) = self.selection() else {
3159 // No selection: arm the mark for the next text typed here, the way a
3160 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3161 // in the flow of typing without ever selecting anything — the delta
3162 // is realised onto the freshly typed text by `insert`. A fresh caret
3163 // position starts the delta over from the marks actually in force.
3164 if self.pending_at != Some(self.caret) {
3165 self.pending_marks = InlineMarks::empty();
3166 self.pending_at = Some(self.caret);
3167 }
3168 self.pending_marks.flip(kind);
3169 self.status = None;
3170 return;
3171 };
3172 // Whitespace at the edge of a selection is not part of what was chosen —
3173 // a double-click takes the space after the word with it — and a mark
3174 // cannot close against one anyway: `**word **` is four literal asterisks
3175 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3176 let picked = &self.source[s..e];
3177 let (s, e) = (
3178 s + (picked.len() - picked.trim_start().len()),
3179 e - (picked.len() - picked.trim_end().len()),
3180 );
3181 if s >= e {
3182 self.status = Some(format!("{kind:?}: nothing selected to mark"));
3183 return;
3184 }
3185 // Styling a selection is a one-shot act, not a sticky mode.
3186 self.clear_pending();
3187 self.record_caret();
3188 match self.editor.toggle_inline(s, e, kind) {
3189 Ok(change) => {
3190 self.last_edit_kind = None; // structural edit is its own undo step
3191 self.refresh();
3192 self.anchor = Some(change.new.start);
3193 self.caret = change.new.end;
3194 self.dirty = self.source != self.clean_source;
3195 self.status = None;
3196 self.record_caret();
3197 }
3198 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3199 }
3200 }
3201
3202 /// Convert the block at the caret to a heading level or paragraph.
3203 pub fn set_block(&mut self, kind: BlockKind) {
3204 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3205 return;
3206 }
3207 self.record_caret();
3208 // A blank line has no node to convert, and twig opens a block there
3209 // rather than declining — so the caret's own offset is the right thing
3210 // to hand it when `block_offset_for_caret` finds nothing.
3211 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3212 match self.editor.set_block(offset, kind) {
3213 Ok(change) => {
3214 self.last_edit_kind = None;
3215 self.refresh();
3216 // Opening a block on a blank line writes a marker the caret
3217 // belongs *after*; converting an existing one moves nothing.
3218 self.caret = self.caret.max(change.new.end);
3219 self.clamp_caret();
3220 self.anchor = None;
3221 self.dirty = self.source != self.clean_source;
3222 self.status = None;
3223 self.record_caret();
3224 }
3225 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3226 }
3227 }
3228
3229 /// Whether `off` is inside a text block (paragraph, heading, code block…).
3230 fn has_block_at(&mut self, off: usize) -> bool {
3231 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3232 chain
3233 .iter()
3234 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3235 })
3236 }
3237
3238 /// The offset to hand twig's `set_block`: the caret when it is already inside
3239 /// a block, otherwise nudged onto the previous character (a caret at a line
3240 /// end sits at the doc level, outside the block). `None` when the caret is on
3241 /// a blank line — a new paragraph with no block node to convert.
3242 fn block_offset_for_caret(&mut self) -> Option<usize> {
3243 let caret = self.caret.min(self.source.len());
3244 if self.has_block_at(caret) {
3245 return Some(caret);
3246 }
3247 // Nudge to the previous character — but never across a newline: that would
3248 // target the previous block, and a blank line genuinely has no block.
3249 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3250 && ch != '\n'
3251 && self.has_block_at(i)
3252 {
3253 return Some(i);
3254 }
3255 None
3256 }
3257
3258 /// The heading level of the text block at the caret, or `None` when that
3259 /// block is not a heading.
3260 pub fn current_heading_level(&mut self) -> Option<u32> {
3261 let caret = self.caret;
3262 self.nodes()
3263 .into_iter()
3264 .filter(|n| n.kind == Kind::Heading)
3265 .find(|n| n.span.start <= caret && caret <= n.span.end)
3266 .and_then(|n| n.level)
3267 }
3268
3269 /// The inline marks in force at the caret (or over the selection) — what a
3270 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3271 /// inline counterpart. Cheap enough to call every frame: one twig
3272 /// `ancestors_at` query per caret (two with a selection), each walking root
3273 /// → deepest node at one offset. It never snapshots the tree the way
3274 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3275 /// the only allocation is twig's own small ancestor `Vec`.
3276 ///
3277 /// **A selection reports a mark only when the mark covers *all* of it.**
3278 /// That's what every real toolbar means by an active button — Bold lit over
3279 /// a half-bold selection would claim a press turns bold *off*, when
3280 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3281 /// Whole-coverage is asked as "is the same mark node standing over both the
3282 /// first and the last character?": inline nodes are contiguous, so one node
3283 /// covering both ends covers every byte between them. Two touching runs
3284 /// (`**a****b**`) are two nodes, and correctly light nothing.
3285 ///
3286 /// At a bare caret a mark is active when the caret stands inside the mark's
3287 /// span — `span.start <= caret < span.end`, delimiters included, which is
3288 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3289 /// opening `*` (2) through the last byte of the closing `**` (9) are all
3290 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3291 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3292 /// typing would actually land inside the marked run. The offset one past the
3293 /// mark (10) is the text after it and reports nothing, at the end of the
3294 /// buffer exactly as in the middle.
3295 pub fn active_inline_marks(&mut self) -> InlineMarks {
3296 let Some((start, end)) = self.selection() else {
3297 // The marks actually in force at the caret, flipped by any armed
3298 // sticky delta — so `⌘b` at a bare caret lights the Bold button
3299 // immediately, before a single character is typed.
3300 let base: InlineMarks = self
3301 .marks_at(self.caret)
3302 .into_iter()
3303 .map(|(k, _)| k)
3304 .collect();
3305 return base.xor(self.pending_here());
3306 };
3307 // The selection's *last character*, not its exclusive end: `end` is the
3308 // offset one past the selection, which for a selection ending exactly at
3309 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3310 // entirely bold, but offset 10 is the space after).
3311 let last = prev_boundary(&self.source, end);
3312 let head = self.marks_at(start);
3313 let tail = self.marks_at(last);
3314 head.into_iter()
3315 .filter(|m| tail.contains(m))
3316 .map(|(k, _)| k)
3317 .collect()
3318 }
3319
3320 /// The inline marks whose span covers `off`, each with the id of the node
3321 /// carrying it — the id is what lets a selection tell one mark node from
3322 /// another of the same kind.
3323 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3324 let off = off.min(self.source.len());
3325 self.editor
3326 .ancestors_at(off)
3327 .unwrap_or_default()
3328 .into_iter()
3329 // `span.end` is the offset one *past* the mark, so it isn't in it.
3330 // twig already resolves a boundary to whatever starts there — in
3331 // `**bold** x` offset 8 is the following text, not the strong — but
3332 // when nothing follows, the tie has nobody to break for and the
3333 // chain still ends at the mark. That would make the answer at the
3334 // last offset of the document depend on whether the file happens to
3335 // end in a newline; the rule is `span.start <= off < span.end`, and
3336 // it's the same rule at the end of a buffer as in the middle.
3337 .filter(|m| off < m.span.end)
3338 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3339 .collect()
3340 }
3341
3342 /// Toggle a heading at the caret: if the block is already this heading level,
3343 /// revert it to a paragraph; otherwise convert it to this heading level.
3344 /// This gives the heading commands the same toggle feel as bold/italic/code —
3345 /// re-applying a heading a line already has turns it back into body text.
3346 pub fn toggle_heading(&mut self, level: u32) {
3347 if self.current_heading_level() == Some(level) {
3348 self.set_block(BlockKind::Paragraph);
3349 } else {
3350 self.set_block(BlockKind::Heading(level));
3351 }
3352 }
3353
3354 /// Toggle a block quote around the selection, or around the block at the
3355 /// caret — the toolbar's Quote button.
3356 pub fn toggle_blockquote(&mut self) {
3357 self.toggle_container(BlockContainerKind::BlockQuote);
3358 }
3359
3360 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3361 /// over the block at the caret — one op with the kind as a flag, the way
3362 /// `toggle_heading` takes its level, so a frontend needs no twig type to
3363 /// name the two buttons.
3364 ///
3365 /// Pressing the *other* list's button while in a list converts in place
3366 /// rather than nesting, so the pair reads as one three-state control
3367 /// (bulleted / numbered / neither) rather than two independent wrappers.
3368 pub fn toggle_list(&mut self, ordered: bool) {
3369 self.toggle_container(if ordered {
3370 BlockContainerKind::OrderedList
3371 } else {
3372 BlockContainerKind::BulletList
3373 });
3374 }
3375
3376 // ── Task list items ──────────────────────────────────────────────────────
3377 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3378 // inline content of the item's first paragraph rather than part of its
3379 // marker, so adding or removing one must leave the item's continuation
3380 // indentation alone, and an item inside a quote is found past the quote
3381 // markers. leaf names the gesture and the offset; the spelling is twig's.
3382
3383 /// Whether the list item at the caret carries a checkbox, and which way it
3384 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3385 /// item or no item at all. What a toolbar reads to light its checkbox button.
3386 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3387 self.task_checked_at(self.caret)
3388 }
3389
3390 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3391 /// offset — what a frontend asks before deciding a click landed on a box.
3392 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3393 self.innermost_list_item(offset.min(self.source.len()))?
3394 .checked
3395 }
3396
3397 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3398 /// A no-op with a reported reason when the caret is in no task item — minting
3399 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3400 pub fn toggle_task_checked(&mut self) {
3401 self.toggle_task_at(self.caret);
3402 }
3403
3404 /// Tick or untick the task item covering `offset` — what a *click* on a
3405 /// rendered checkbox is. Separate from the caret form because a click carries
3406 /// its own offset and must not first move the caret there: ticking a box
3407 /// three paragraphs away should not take the cursor with it.
3408 pub fn toggle_task_at(&mut self, offset: usize) {
3409 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3410 return;
3411 }
3412 let offset = offset.min(self.source.len());
3413 self.record_caret();
3414 match self.editor.toggle_task_checked(offset) {
3415 Ok(_) => self.after_task_edit(),
3416 Err(e) => self.status = Some(format!("task: {e}")),
3417 }
3418 }
3419
3420 /// Give the list item at the caret a checkbox, or take its checkbox away —
3421 /// the gesture that converts between a plain bullet and a task. A new box
3422 /// arrives unticked.
3423 pub fn toggle_task_item(&mut self) {
3424 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3425 return;
3426 }
3427 let caret = self.caret.min(self.source.len());
3428 self.record_caret();
3429 match self.editor.toggle_task_item(caret) {
3430 Ok(_) => self.after_task_edit(),
3431 Err(e) => self.status = Some(format!("task: {e}")),
3432 }
3433 }
3434
3435 /// Settle after a task gesture. The caret rides its old byte offset and is
3436 /// clamped back in: a box is three or four bytes on the item's first line, so
3437 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3438 /// real stop either way.
3439 fn after_task_edit(&mut self) {
3440 self.last_edit_kind = None;
3441 self.refresh();
3442 self.anchor = None;
3443 self.dirty = self.source != self.clean_source;
3444 self.status = None;
3445 self.clamp_caret();
3446 self.record_caret();
3447 }
3448
3449 // ── Tables ───────────────────────────────────────────────────────────────
3450 // A table is a grid, and twig edits it as one — add/remove/move a row or
3451 // column, set a column's alignment — re-spelling the whole table in a single
3452 // splice. Every gesture is anchored at the caret's cell. leaf just names the
3453 // gesture and re-reads the result; the whole table's numbering, borders, and
3454 // delimiter are twig's to keep straight.
3455
3456 /// Whether the caret is inside a table — what a frontend asks to enable or
3457 /// disable its table controls.
3458 ///
3459 /// An HTML `<table>` still answers `true`: the caret really is in a table,
3460 /// and the reason the grid controls stay dark there is
3461 /// [`Capabilities::table`], which is a fact about the document's format
3462 /// rather than about the caret. A frontend needs both.
3463 pub fn caret_in_table(&mut self) -> bool {
3464 let caret = self.caret.min(self.source.len());
3465 self.editor
3466 .ancestors_at(caret)
3467 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3468 .unwrap_or(false)
3469 }
3470
3471 /// One grid op, guarded and settled — the shared body of the seven below.
3472 ///
3473 /// The guard is why this exists rather than seven copies of the same three
3474 /// lines, and it is the one guard leaf cannot delegate to twig. The table
3475 /// editor is the gesture family that consults no `Syntax` table (it spells a
3476 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3477 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3478 /// grid as a *pipe table* and reports success, swapping the element out for
3479 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3480 /// downstream could tell that from a successful edit — the splice is real,
3481 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3482 /// stopping at the door rather than detecting after the fact. See
3483 /// [`spells_pipe_tables`].
3484 fn table_op(
3485 &mut self,
3486 what: &str,
3487 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3488 ) {
3489 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3490 return;
3491 }
3492 self.record_caret();
3493 let at = self.caret;
3494 let r = op(&mut self.editor, at);
3495 self.apply_table(r, what);
3496 }
3497
3498 /// Insert an empty row below (`below`) or above the caret's row.
3499 pub fn table_insert_row(&mut self, below: bool) {
3500 self.table_op("table row", |e, at| e.table_insert_row(at, below));
3501 }
3502
3503 /// Delete the caret's row (not the header, not the last body row).
3504 pub fn table_delete_row(&mut self) {
3505 self.table_op("table row", |e, at| e.table_delete_row(at));
3506 }
3507
3508 /// Insert an empty column right (`right`) or left of the caret's column.
3509 pub fn table_insert_column(&mut self, right: bool) {
3510 self.table_op("table column", |e, at| e.table_insert_column(at, right));
3511 }
3512
3513 /// Delete the caret's column (unless it is the only one).
3514 pub fn table_delete_column(&mut self) {
3515 self.table_op("table column", |e, at| e.table_delete_column(at));
3516 }
3517
3518 /// Set the caret's column to `alignment`.
3519 pub fn table_set_alignment(&mut self, alignment: Alignment) {
3520 self.table_op("table alignment", |e, at| {
3521 e.table_set_alignment(at, alignment)
3522 });
3523 }
3524
3525 /// Move the caret's row one place down (`down`) or up, within the body rows.
3526 pub fn table_move_row(&mut self, down: bool) {
3527 self.table_op("table row", |e, at| e.table_move_row(at, down));
3528 }
3529
3530 /// Move the caret's column one place right (`right`) or left.
3531 pub fn table_move_column(&mut self, right: bool) {
3532 self.table_op("table column", |e, at| e.table_move_column(at, right));
3533 }
3534
3535 /// Settle the caret and document flags after a table op (or report its
3536 /// error). twig re-spells the whole table, so the caret rides its old byte
3537 /// offset and is clamped back into the rebuilt bytes — near enough to where
3538 /// it was, since the op preserves the cells' content and order around it.
3539 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3540 match result {
3541 Ok(()) => {
3542 self.last_edit_kind = None;
3543 self.refresh();
3544 self.anchor = None;
3545 self.clamp_caret();
3546 self.dirty = self.source != self.clean_source;
3547 self.status = None;
3548 self.record_caret();
3549 }
3550 Err(e) => self.status = Some(format!("{what}: {e}")),
3551 }
3552 }
3553
3554 /// One `toggle_block_container` over the block-level target.
3555 ///
3556 /// leaf says *where*; twig decides everything else — which blocks the range
3557 /// covers, whether that means wrapping, unwrapping, nesting or converting,
3558 /// and how this document's format spells the prefix. The rule that a
3559 /// container only comes off when the range covers every block it holds is
3560 /// what the re-anchoring below is built around.
3561 fn toggle_container(&mut self, kind: BlockContainerKind) {
3562 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3563 return;
3564 }
3565 let selected = self.selection();
3566 // A blank line holds no block, and twig opens an *empty* container on one
3567 // — since 3.2.0; it used to decline the range with `NotFound`, which is
3568 // why this used to lend it a scratch paragraph to wrap. Worth knowing
3569 // here because the line-for-line caret mapping below cannot describe it:
3570 // opening one under a paragraph writes the blank line the format needs
3571 // above the marker too, so the rewritten region has a line the old one
3572 // didn't, and "the same line, the same distance from its end" lands on
3573 // that new blank instead of in the container.
3574 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
3575 // Without a selection the target is the caret's own block, resolved the
3576 // way `set_block` resolves it — a caret at a line end sits at the doc
3577 // level and has to be nudged back onto the block it looks like it's in.
3578 // An empty range is enough: twig widens to the whole lines it touches.
3579 let (start, end) = match selected {
3580 Some(range) => range,
3581 None => {
3582 let off = self.block_offset_for_caret().unwrap_or(self.caret);
3583 (off, off)
3584 }
3585 };
3586 self.record_caret();
3587 match self.editor.toggle_block_container(start, end, kind) {
3588 Ok(change) => {
3589 // Read the caret's place out of the *pre-edit* source, before
3590 // `refresh` swaps that source out from under it.
3591 let place = (selected.is_none() && !opened_empty)
3592 .then(|| self.caret_line_tail(&change.old));
3593 self.last_edit_kind = None; // structural edit is its own undo step
3594 self.refresh();
3595 match place {
3596 // Both land the caret at the far end of what twig wrote, and
3597 // differ only in what they leave selected.
3598 //
3599 // From a selection: select what the container now holds, the
3600 // way `toggle` keeps its marked region selected — and for a
3601 // stronger reason than symmetry: a container comes *off* only
3602 // a range covering every block it holds, so a selection left
3603 // on its old bytes (now short by a prefix per line) would nest
3604 // on the second press instead of reversing the first.
3605 //
3606 // From a blank line: nothing to select, and the end of the
3607 // region is exactly past the bare `> ` / `- ` twig wrote —
3608 // the caret standing inside the container that was asked for.
3609 None => {
3610 self.anchor = (!opened_empty).then_some(change.new.start);
3611 self.caret = change.new.end;
3612 }
3613 Some(place) => {
3614 self.anchor = None;
3615 self.caret = self.line_tail_offset(&change.new, place);
3616 }
3617 }
3618 self.dirty = self.source != self.clean_source;
3619 self.status = None;
3620 self.clamp_caret();
3621 self.record_caret();
3622 }
3623 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3624 }
3625 }
3626
3627 /// The caret's place inside the region a container toggle is rewriting, in
3628 /// the only terms the rewrite preserves: which of the region's lines it sits
3629 /// on, and how many bytes of that line lie ahead of it.
3630 ///
3631 /// A container's markup goes in at column 0 and never touches what follows
3632 /// on the line, so that pair survives the edit exactly where a byte offset
3633 /// does not — a caret left on its old offset slides back by one prefix per
3634 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
3635 /// `> ` it just asked for.
3636 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
3637 let caret = self.caret.clamp(old.start, old.end);
3638 let line = self.source[old.start..caret].matches('\n').count();
3639 let end = self.source[caret..old.end]
3640 .find('\n')
3641 .map_or(old.end, |i| caret + i);
3642 (line, end - caret)
3643 }
3644
3645 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
3646 /// region: the offset `tail` bytes back from the end of the region's `line`.
3647 ///
3648 /// Both walks are clamped rather than trusted, because the one op that does
3649 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
3650 /// the items back apart with blank lines between them — and a caret landing
3651 /// on the nearest line of the right item beats one landing out of the region
3652 /// entirely.
3653 fn line_tail_offset(
3654 &self,
3655 new: &std::ops::Range<usize>,
3656 (line, tail): (usize, usize),
3657 ) -> usize {
3658 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3659 let mut start = 0;
3660 for _ in 0..line {
3661 match region[start..].find('\n') {
3662 Some(i) => start += i + 1,
3663 None => break,
3664 }
3665 }
3666 let end = region[start..]
3667 .find('\n')
3668 .map_or(region.len(), |i| start + i);
3669 new.start + end.saturating_sub(tail).max(start)
3670 }
3671
3672 /// Link the selection to `destination` — the toolbar's Link button. With no
3673 /// selection it acts at the caret, which re-points a link the caret is
3674 /// already standing in (twig replaces an existing link's destination and
3675 /// keeps its text) and otherwise spells a link that has no text of its own:
3676 /// an autolink (`<https://x.dev>`) where the destination is one, and
3677 /// `[destination](destination)` where it isn't.
3678 ///
3679 /// `destination` reaches twig raw. Escaping it is format knowledge and the
3680 /// two formats genuinely disagree — Markdown ends a destination at the first
3681 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
3682 /// itself — so the side holding the document is the side that gets to spell
3683 /// it. A destination twig can't carry at all (one with a newline) comes back
3684 /// as an error rather than a quietly rewritten URL.
3685 pub fn insert_link(&mut self, destination: &str) {
3686 if self.refuse_unsupported("link", Gesture::InsertLink) {
3687 return;
3688 }
3689 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3690 self.record_caret();
3691 match self.editor.insert_link(start, end, destination) {
3692 Ok(change) => {
3693 self.last_edit_kind = None;
3694 self.refresh();
3695 match self.link_text_span(change.new.start) {
3696 // A link with text of its own: select it, so typing replaces
3697 // a `[dest](dest)`'s stand-in label and a second press
3698 // re-points what the first one linked.
3699 Some(text) => {
3700 self.anchor = (text.start != text.end).then_some(text.start);
3701 self.caret = text.end;
3702 }
3703 // An autolink is finished the moment it's written — its text
3704 // *is* the URL. Leaving it selected would aim the next press
3705 // at the one shape twig still wraps instead of re-points.
3706 None => {
3707 self.anchor = None;
3708 self.caret = change.new.end;
3709 }
3710 }
3711 self.dirty = self.source != self.clean_source;
3712 self.status = None;
3713 self.clamp_caret();
3714 self.record_caret();
3715 }
3716 Err(e) => self.status = Some(format!("link: {e}")),
3717 }
3718 }
3719
3720 /// Insert a block-level image at the caret: ``. Any
3721 /// selection becomes the alt text (so "select a caption, insert image" labels
3722 /// it); with no selection, `alt` is used — empty for none. The caret lands
3723 /// just past the inserted image.
3724 ///
3725 /// Both halves go through twig (`insert_literal` for the alt text,
3726 /// `insert_image` for the image), so neither is spelled here. That used to be a
3727 /// `format!`, and it was wrong the first time an app inserted a real filename:
3728 /// Markdown ends a destination at the first space, so `` is
3729 /// not an image at all — and the fix is per-format, since moving into the
3730 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
3731 pub fn insert_image(&mut self, destination: &str, alt: &str) {
3732 if self.refuse_unsupported("image", Gesture::InsertImage) {
3733 return;
3734 }
3735 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3736 self.record_caret();
3737 // With no selection and an explicit `alt`, the alt text has to exist in the
3738 // document before it can be the image's — and it is raw caller input, so
3739 // it goes in through `insert_literal`, which escapes it for the format
3740 // rather than letting a `]` in someone's caption close the image early.
3741 let (start, end) = if start == end && !alt.is_empty() {
3742 match self.editor.insert_literal(start, alt) {
3743 Ok(change) => (change.new.start, change.new.end),
3744 Err(e) => {
3745 self.status = Some(format!("image: {e}"));
3746 return;
3747 }
3748 }
3749 } else {
3750 (start, end)
3751 };
3752 match self.editor.insert_image(start, end, destination) {
3753 Ok(change) => {
3754 self.last_edit_kind = None;
3755 self.refresh();
3756 // Just past the image, nothing selected — where a caret belongs
3757 // after inserting one.
3758 self.anchor = None;
3759 self.caret = change.new.end;
3760 self.dirty = self.source != self.clean_source;
3761 self.status = None;
3762 self.clamp_caret();
3763 self.record_caret();
3764 }
3765 Err(e) => self.status = Some(format!("image: {e}")),
3766 }
3767 }
3768
3769 /// Insert a block-level image, video, or audio at the caret. The image case
3770 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
3771 /// HTML elements, which is the only spelling Markdown and Djot have for them:
3772 ///
3773 /// ```text
3774 /// <video src="clip.mp4" controls>alt</video>
3775 /// <audio src="take.mp3" controls>alt</audio>
3776 /// ```
3777 ///
3778 /// HTML rather than a `::video{…}` directive deliberately. A directive means
3779 /// something only to an app that knows the vocabulary, so the document would
3780 /// read as literal punctuation everywhere else; `<video>` is what every other
3781 /// renderer already understands, and what leaf's own reader picks back up
3782 /// through `html_elements` promotion (see [`parse_extensions`]).
3783 ///
3784 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
3785 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
3786 /// `html_elements`. Before that only the multi-line form parsed as a block at
3787 /// all, and this wrote three lines to work around it.
3788 ///
3789 /// `controls` is always written: a player with no transport is a still frame
3790 /// the reader can't do anything with. Any selection becomes the element's
3791 /// fallback text, exactly as it becomes an image's alt.
3792 ///
3793 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
3794 /// applies, and bites harder here: a `"` in `destination` closes the
3795 /// attribute. A frontend taking these from a file picker is fine; one taking
3796 /// them from free text should keep them tame.
3797 ///
3798 /// [`MediaInfo`]: crate::MediaInfo
3799 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
3800 if kind == MediaKind::Image {
3801 return self.insert_image(destination, alt);
3802 }
3803 // Gated on the *image* gesture, not on one of its own — there isn't one,
3804 // since the bytes below are spelled here rather than by twig, and an HTML
3805 // document would in fact parse them. The button is one control with three
3806 // kinds behind it, and two of them working in a format where the third
3807 // cannot is a worse surface than three that agree — especially as
3808 // `insert_image` is the kind anyone reaches for first.
3809 if self.refuse_unsupported("media", Gesture::InsertImage) {
3810 return;
3811 }
3812 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
3813 let alt_text = self
3814 .selected_text()
3815 .map(str::to_string)
3816 .unwrap_or_else(|| alt.to_string());
3817 let tag = match kind {
3818 MediaKind::Audio => "audio",
3819 _ => "video",
3820 };
3821 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
3822 self.edit(start, end, &markup);
3823 }
3824
3825 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
3826 /// button. Spelling and placement are both twig's; leaf used to write `---`
3827 /// itself, which was the Markdown spelling in a djot document too.
3828 ///
3829 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
3830 /// mid-paragraph and lands it after the caret's whole block. To get a rule
3831 /// *at* the caret — the paragraph parted in two around it, which is what a
3832 /// rule button is understood to do — the paragraph is first divided with
3833 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
3834 /// the offset `split_block` returns puts the rule after the *second* half
3835 /// instead, which is a rule in the right document and the wrong place.
3836 ///
3837 /// Only a plain paragraph is split. Everywhere else the rule simply lands
3838 /// after the block, which is both twig's own answer and the better one:
3839 /// splitting a fenced code block would leave two fences with a rule between
3840 /// them, and splitting a list item would mint an item nobody asked for on the
3841 /// way to a rule that lands after the list regardless. A table and a setext
3842 /// heading refuse the split outright, so they take the same path by
3843 /// themselves.
3844 pub fn insert_thematic_break(&mut self) {
3845 if self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak) {
3846 return;
3847 }
3848 self.caret = self.skip_trailing_close_delims(self.caret);
3849 // A selection is replaced by the rule, so collapse it first and let the
3850 // split-and-rule below run from the caret it leaves behind.
3851 if let Some((s, e)) = self.selection() {
3852 self.splice(s, e, "", EditKind::Other);
3853 }
3854 self.anchor = None;
3855 self.record_caret();
3856 let at = self.caret;
3857 if self.caret_in_bare_paragraph() {
3858 // A failure here is not fatal: the rule still lands after the block,
3859 // which is exactly what this call was trying to improve on.
3860 let _ = self.editor.split_block(at);
3861 }
3862 match self.editor.insert_thematic_break(at) {
3863 Ok(change) => {
3864 self.last_edit_kind = None;
3865 self.refresh();
3866 self.anchor = None;
3867 self.caret = change.new.end;
3868 self.dirty = self.source != self.clean_source;
3869 self.status = None;
3870 self.clamp_caret();
3871 self.record_caret();
3872 }
3873 Err(e) => self.status = Some(format!("thematic break: {e}")),
3874 }
3875 }
3876
3877 /// Whether the caret sits in a paragraph and nothing else — no list item, no
3878 /// quote, no fence, no table. The one shape where parting the block around
3879 /// the caret is unambiguously what a rule button means; see
3880 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
3881 /// container is left to take the rule after itself.
3882 fn caret_in_bare_paragraph(&mut self) -> bool {
3883 let caret = self.caret.min(self.source.len());
3884 let Ok(chain) = self.editor.ancestors_at(caret) else {
3885 return false;
3886 };
3887 let mut in_para = false;
3888 for m in chain {
3889 match m.kind {
3890 Kind::Para => in_para = true,
3891 Kind::ListItem
3892 | Kind::TaskListItem
3893 | Kind::BlockQuote
3894 | Kind::CodeBlock
3895 | Kind::Table => return false,
3896 _ => {}
3897 }
3898 }
3899 in_para
3900 }
3901
3902 /// The destination of the link under the caret — what a Link prompt shows so
3903 /// ⌘K on an existing link edits its URL instead of asking for it again.
3904 /// `None` when the caret stands in no link.
3905 ///
3906 /// An autolink carries no separate destination: its text *is* the URL, so
3907 /// that's what comes back for one.
3908 pub fn link_destination_at_caret(&mut self) -> Option<String> {
3909 self.link_destination_at(self.caret)
3910 }
3911
3912 /// The destination of the link at `off`.
3913 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
3914 /// the caret isn't.
3915 ///
3916 /// The offset form exists for the same reason
3917 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
3918 /// the document somewhere else — a footnote's text in a popover, say — has
3919 /// rows and runs but no caret in them, and still needs to know which of those
3920 /// runs a reader can follow.
3921 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
3922 self.nodes()
3923 .into_iter()
3924 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
3925 .filter(|n| n.span.start <= off && off < n.span.end)
3926 .max_by_key(|n| n.span.start)
3927 .and_then(|n| n.destination.or(n.text))
3928 }
3929
3930 /// Where the locator `id` lands in this document — the `#v2` half of a
3931 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
3932 /// answers to it.
3933 ///
3934 /// The other end of a link, and the reason this exists: without it a
3935 /// destination has only file granularity, so following a citation into a
3936 /// chapter drops the reader at the top of it to hunt for the verse. Which is
3937 /// also why it is a *document* query rather than a caret one — the document
3938 /// being asked is usually not the one the reader is in.
3939 ///
3940 /// Three readings, tried in order, because the same `#some-heading` is
3941 /// written three ways across the formats leaf opens:
3942 ///
3943 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
3944 /// the auto-ids djot mints for its headings. The only exact answer, so it
3945 /// goes first — a document that says `{#v1}` has settled the question.
3946 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
3947 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
3948 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
3949 /// authored anywhere land on a djot heading.
3950 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
3951 /// none and `{#custom}` is literal text in a Markdown heading — so for
3952 /// the format most vaults are written in, the heading's own words are the
3953 /// only thing a fragment can name. This is the rule every Markdown
3954 /// renderer already follows, which is what makes `#a-heading` mean in
3955 /// diaryx what it means on the web.
3956 ///
3957 /// Ties go to the earliest match, then to the widest: a duplicated id is the
3958 /// document's mistake and the first one is the answer every anchor
3959 /// implementation gives, while preferring the wider span picks the section
3960 /// over the heading that opens it — more for a peek to show, same place to
3961 /// land.
3962 pub fn locate(&mut self, id: &str) -> Option<Landing> {
3963 let id = id.trim();
3964 if id.is_empty() {
3965 return None;
3966 }
3967 let nodes = self.nodes();
3968
3969 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
3970 // is picking, among nodes that start together, the one that ends last.
3971 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
3972 matches
3973 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
3974 .map(|n| Landing {
3975 start: n.span.start,
3976 end: n.span.end,
3977 })
3978 };
3979
3980 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
3981 return Some(landing);
3982 }
3983 let want = slug(id);
3984 if want.is_empty() {
3985 return None;
3986 }
3987 if let Some(landing) = pick(
3988 &mut nodes
3989 .iter()
3990 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
3991 ) {
3992 return Some(landing);
3993 }
3994
3995 // A heading by its words. Its span is one line, so the end comes from
3996 // where the *section* it opens gives out — the next heading that is not
3997 // under it, or the end of the document. A Markdown heading has no
3998 // section node to ask (twig only builds those for djot), and a peek that
3999 // showed the heading alone would answer "what does that say" with the
4000 // title of the thing it says.
4001 let heading = nodes
4002 .iter()
4003 .filter(|n| n.kind == Kind::Heading)
4004 .filter(|n| {
4005 n.content_span
4006 .clone()
4007 .and_then(|s| self.source.get(s))
4008 .is_some_and(|text| slug(text) == want)
4009 })
4010 .min_by_key(|n| n.span.start)?;
4011 let level = heading.level.unwrap_or(u32::MAX);
4012 let end = nodes
4013 .iter()
4014 .filter(|n| n.kind == Kind::Heading)
4015 .filter(|n| n.span.start > heading.span.start)
4016 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4017 .map(|n| n.span.start)
4018 .min()
4019 .unwrap_or(self.source.len());
4020 Some(Landing {
4021 start: heading.span.start,
4022 end,
4023 })
4024 }
4025
4026 /// Write a footnote at the caret — the toolbar's Footnote button, and the
4027 /// one gesture in the footnote story that *authors* rather than follows.
4028 ///
4029 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4030 /// the `[^1]:` definition at the end of the document. Half a footnote is not
4031 /// a footnote — a bare reference with nothing defining it renders as literal
4032 /// brackets — so a single button that wrote only the reference would leave
4033 /// the author to hand-spell the other half in a document that had just
4034 /// stopped showing them what the first half meant. One edit also means one
4035 /// undo takes both back.
4036 ///
4037 /// The definition's body is left empty and **the caret lands in it**, which
4038 /// is the whole point of pressing the button: nobody wants a reference to a
4039 /// note they have not written yet. Getting back to where they were writing
4040 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4041 /// the same return leg a reader following a reference already uses, so the
4042 /// author is left standing on the near end of a round trip that works.
4043 ///
4044 /// A selection collapses to its *end* rather than being replaced: a
4045 /// reference annotates the words before it, so "select the claim, add a
4046 /// footnote" should mark that claim, not consume it.
4047 pub fn insert_footnote(&mut self) {
4048 if self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4049 return;
4050 }
4051 let at = self.selection().map_or(self.caret, |(_, end)| end);
4052 self.anchor = None;
4053 self.caret = at;
4054 self.record_caret();
4055 let label = self.next_footnote_label();
4056 match self.editor.insert_footnote(at, &label) {
4057 Ok(change) => {
4058 self.last_edit_kind = None;
4059 self.refresh();
4060 self.anchor = None;
4061 // `change.new` runs from the reference to the end of the
4062 // document, so its start is the `[^1]` just written and
4063 // `footnote_at` resolves it to the note the same way a reader's
4064 // tap does — and to the note's *body*, which is already a caret
4065 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4066 // and has none), so this needs no snap on top. The fallback is
4067 // the reference's own offset: a format that spelled the pair some
4068 // way leaf can't read back should still leave the caret on the
4069 // edit rather than at the far end of a document it just grew.
4070 self.caret = self
4071 .footnote_at(change.new.start)
4072 .and_then(|note| note.offset)
4073 .unwrap_or(change.new.start);
4074 self.dirty = self.source != self.clean_source;
4075 self.status = None;
4076 self.clamp_caret();
4077 self.record_caret();
4078 }
4079 Err(e) => self.status = Some(format!("footnote: {e}")),
4080 }
4081 }
4082
4083 /// The label to give a footnote the author has not named: the lowest counting
4084 /// number no footnote in the document is already wearing.
4085 ///
4086 /// twig takes the label rather than minting one, because it holds no opinion
4087 /// about what a document's footnotes should be called — and it is right not
4088 /// to. Numbering them is what every author of a numbered note expects, and
4089 /// re-using a taken number would silently point the new reference at somebody
4090 /// else's note (twig reuses an existing definition rather than appending a
4091 /// second one, which is the right rule for citing a note twice on purpose and
4092 /// exactly the wrong accident to have by default).
4093 ///
4094 /// *References* are counted alongside definitions, not just definitions: a
4095 /// document carrying a dangling `[^2]` has a 2 that means something to
4096 /// whoever wrote it, and minting a definition for it here would answer a
4097 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4098 /// count entirely — they take no number, so they block none.
4099 fn next_footnote_label(&mut self) -> String {
4100 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4101 .into_iter()
4102 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4103 .filter_map(|label| label.parse().ok())
4104 .collect();
4105 taken.extend(
4106 self.nodes()
4107 .into_iter()
4108 .filter(|n| n.kind == Kind::FootnoteReference)
4109 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4110 .filter_map(|label| label.parse::<u32>().ok()),
4111 );
4112 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4113 }
4114
4115 /// The footnote reference under the caret, resolved to the note it names.
4116 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4117 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4118 self.footnote_at(self.caret)
4119 }
4120
4121 /// The footnote reference at `off`, resolved to the note it names — what a
4122 /// frontend shows when a reader activates a `[^1]`.
4123 ///
4124 /// A reference is not a link node, so
4125 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4126 /// (and should not) answer for one: a link names a destination to leave for,
4127 /// a reference names a note that is already in this document. Following one
4128 /// is a move within the page, which is why this hands back an `offset`
4129 /// rather than something to open.
4130 ///
4131 /// Offset-based rather than caret-only because the gesture that wants this
4132 /// most is the one that must not move the caret: a pointer hovering a `[1]`
4133 /// asks what note it names without disturbing where the reader was typing.
4134 /// The caret is just the offset a click already placed —
4135 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4136 ///
4137 /// `None` when `off` stands in no reference. A reference whose note the
4138 /// document never defines is *not* `None` — it answers with the label it
4139 /// looked for and no text, which is what lets a frontend say so instead of
4140 /// silently doing nothing.
4141 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4142 // Innermost-wins by latest start, the rule its link sibling uses.
4143 let span = self
4144 .nodes()
4145 .into_iter()
4146 .filter(|n| n.kind == Kind::FootnoteReference)
4147 .filter(|n| n.span.start <= off && off < n.span.end)
4148 .max_by_key(|n| n.span.start)?
4149 .span;
4150 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4151
4152 // The note itself. Definitions are roots beside `doc` rather than
4153 // children of it, so they're asked for directly — see
4154 // `wysiwyg::footnote_definitions`.
4155 let note = wysiwyg::footnote_definitions(&mut self.editor)
4156 .into_iter()
4157 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4158 let Some(note) = note else {
4159 return Some(FootnoteRef {
4160 label,
4161 text: None,
4162 offset: None,
4163 end: None,
4164 });
4165 };
4166 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4167 Some(FootnoteRef {
4168 label,
4169 text: body
4170 .clone()
4171 .and_then(|b| self.source.get(b))
4172 .map(str::to_string),
4173 // The body's start, not the definition's — see `FootnoteRef::offset`.
4174 offset: body.clone().map(|b| b.start),
4175 end: body.map(|b| b.end),
4176 })
4177 }
4178
4179 /// The footnote *definition* the caret stands in, and where the reference
4180 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4181 /// at the caret's offset.
4182 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4183 self.footnote_definition_at(self.caret)
4184 }
4185
4186 /// The footnote definition spanning `off`, and where the reference that
4187 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4188 ///
4189 /// The mirror image, deliberately: the same gesture that takes a reader from
4190 /// `[1]` down to the note takes them from the note back up to `[1]`, so
4191 /// following a footnote is a round trip rather than a fall. It needs no
4192 /// memory of how the reader arrived — the document says where the reference
4193 /// is — which is what makes it work for a reader who scrolled to the notes
4194 /// themselves, and what keeps it right after an edit moves either end.
4195 ///
4196 /// `None` when `off` stands in no definition. A definition nothing cites is
4197 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4198 /// its label and no offset, so a frontend can say "nothing refers to this"
4199 /// rather than offer a jump that goes nowhere.
4200 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4201 // Definitions are roots beside `doc`, so `nodes()` — which walks the
4202 // document body — never reports one. They're asked for directly, the way
4203 // `footnote_at` asks for the note it resolves to.
4204 //
4205 // Closed at the end, unlike the half-open test its neighbours use. A
4206 // definition's span stops at its last content byte — the newline ending
4207 // the line is outside it — so `span.end` is the caret stop at the end of
4208 // the note's own row, not the first byte of anything after. Excluding it
4209 // meant the one caret an author is guaranteed to have, the one left
4210 // sitting at the end of the note they just typed, was in no definition at
4211 // all: writing a note and then asking to go back to its reference
4212 // answered nothing. Two definitions in a row still can't both match —
4213 // there is a blank line between them — and `max_by_key` decides anyway.
4214 let note = wysiwyg::footnote_definitions(&mut self.editor)
4215 .into_iter()
4216 .filter(|m| m.span.start <= off && off <= m.span.end)
4217 .max_by_key(|m| m.span.start)?;
4218 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4219
4220 // The earliest reference carrying this label. `min` rather than a `find`,
4221 // because `nodes()` reports a flattened walk whose order is twig's
4222 // business, not document order. Bound first: the walk needs `&mut self`
4223 // and reading the labels back out needs `&self.source`.
4224 let nodes = self.nodes();
4225 let offset = nodes
4226 .into_iter()
4227 .filter(|n| n.kind == Kind::FootnoteReference)
4228 .filter(|n| {
4229 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4230 })
4231 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4232 .map(|n| n.span.start + 2)
4233 .min();
4234 Some(FootnoteDef { label, offset })
4235 }
4236
4237 /// The destination of the image under the caret — what an image prompt shows
4238 /// so editing an existing image starts from its current URL instead of blank,
4239 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4240 /// `None` when the caret stands in no image. A caret resting just after a
4241 /// block image (its trailing stop) is still "in" it — the half-open span test
4242 /// excludes that offset, which is the intended precision: past the image is
4243 /// past it.
4244 pub fn image_destination_at_caret(&mut self) -> Option<String> {
4245 let off = self.caret;
4246 self.nodes()
4247 .into_iter()
4248 .filter(|n| n.kind == Kind::Image)
4249 .filter(|n| n.span.start <= off && off < n.span.end)
4250 .max_by_key(|n| n.span.start)
4251 .and_then(|n| n.destination)
4252 }
4253
4254 /// The language of the fenced code block the caret stands in — what a
4255 /// language prompt shows so editing it starts from the current value rather
4256 /// than blank. `None` when the caret is in no code block, or in one whose
4257 /// fence carries no language (or an indented block, which has no fence).
4258 pub fn code_language_at_caret(&mut self) -> Option<String> {
4259 let start = self.code_block_start_at_caret()?;
4260 wysiwyg::code_language(&self.source, start)
4261 }
4262
4263 /// Whether the caret stands in a fenced code block — the one a language
4264 /// prompt could edit. A frontend gates its "set language" affordance on this
4265 /// (an indented block, which can't carry a language, reports `false`).
4266 pub fn caret_in_fenced_code(&mut self) -> bool {
4267 self.code_block_start_at_caret()
4268 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4269 }
4270
4271 /// Set (or clear, with `""`) the language of the fenced code block the caret
4272 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4273 /// block, and a reported error for a language the format's fence cannot
4274 /// carry.
4275 ///
4276 /// twig rewrites the info string, so the fence's own width — measured
4277 /// against a body neither side touches — is kept, and a language holding a
4278 /// space, a line end or the fence character is refused rather than written
4279 /// out to reparse as something else. Leaf used to splice over the info span
4280 /// itself and `trim()` the input, which handled the one bad case it had
4281 /// thought of.
4282 pub fn set_code_language(&mut self, lang: &str) {
4283 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4284 return;
4285 }
4286 if self.code_block_start_at_caret().is_none() {
4287 return;
4288 }
4289 let lang = lang.trim();
4290 // `None` clears the info string; `Some("")` asks for an empty one. Both
4291 // write a bare fence, and the prompt's empty value means "clear".
4292 let want = (!lang.is_empty()).then_some(lang);
4293 self.record_caret();
4294 match self.editor.set_code_language(self.caret, want) {
4295 Ok(_) => {
4296 self.last_edit_kind = None;
4297 self.refresh();
4298 self.anchor = None;
4299 self.dirty = self.source != self.clean_source;
4300 self.status = None;
4301 self.clamp_caret();
4302 self.record_caret();
4303 }
4304 Err(e) => self.status = Some(format!("code language: {e}")),
4305 }
4306 }
4307
4308 /// The `span.start` of the code block covering the caret — the anchor
4309 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4310 /// in none.
4311 fn code_block_start_at_caret(&mut self) -> Option<usize> {
4312 let off = self.caret;
4313 self.nodes()
4314 .into_iter()
4315 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4316 .max_by_key(|n| n.span.start)
4317 .map(|n| n.span.start)
4318 }
4319
4320 /// The source range of the text inside the link covering `off` — what sits
4321 /// between its `[` and `]`. `None` when twig reports no link there.
4322 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4323 self.nodes()
4324 .into_iter()
4325 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4326 // the other's `span.start`; the link that starts latest at or before
4327 // `off` is the one `off` is actually in.
4328 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4329 .max_by_key(|n| n.span.start)
4330 .and_then(|n| n.content_span)
4331 }
4332
4333 // ── undo / redo ───────────────────────────────────────────────────────────
4334 // twig owns the history of *bytes* (it owns the buffer) and now carries the
4335 // caret through it too: `record_caret` stashes each state's caret in twig's
4336 // opaque per-step blob, and undo/redo hand it back with the source they
4337 // restore. So leaf keeps no history of its own — no parallel stacks to march
4338 // in lockstep and silently drift out of it.
4339
4340 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4341 /// where they were when that step began.
4342 pub fn undo(&mut self) {
4343 match self.editor.undo() {
4344 Ok(Some(change)) => self.after_history(change),
4345 Ok(None) => self.status = Some("nothing to undo".into()),
4346 Err(e) => self.status = Some(format!("undo: {e}")),
4347 }
4348 }
4349
4350 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4351 /// selection back where that step originally left them.
4352 pub fn redo(&mut self) {
4353 match self.editor.redo() {
4354 Ok(Some(change)) => self.after_history(change),
4355 Ok(None) => self.status = Some("nothing to redo".into()),
4356 Err(e) => self.status = Some(format!("redo: {e}")),
4357 }
4358 }
4359
4360 /// Refresh the cached source and put the caret back where the step being
4361 /// undone/redone had it, clearing any active run.
4362 ///
4363 /// The caret comes from twig's blob for the restored state (what
4364 /// `record_caret` stored). `change` is only the fallback for a state with no
4365 /// blob — a caret at the end of the restored text, which is where this always
4366 /// landed before the blobs were kept. It is the edit site, not where the user
4367 /// was standing, so it's a floor and not the behaviour: undoing should hand
4368 /// back the document *and* the place you were working, which for an edit made
4369 /// anywhere but under the caret are two different places.
4370 fn after_history(&mut self, change: Change) {
4371 self.refresh();
4372 match self
4373 .editor
4374 .caret_blob()
4375 .ok()
4376 .and_then(|b| CaretState::from_blob(&b))
4377 {
4378 Some(state) => {
4379 self.caret = state.caret.min(self.source.len());
4380 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4381 }
4382 None => {
4383 self.caret = change.new.end.min(self.source.len());
4384 self.anchor = None;
4385 }
4386 }
4387 self.goal_col = None;
4388 self.last_edit_kind = None;
4389 self.dirty = self.source != self.clean_source;
4390 self.status = None;
4391 self.clamp_caret();
4392 }
4393
4394 // ── the file ──────────────────────────────────────────────────────────────
4395
4396 #[cfg(feature = "fs")]
4397 pub fn save(&mut self) {
4398 if self.is_untitled() {
4399 // No path to write and no name to invent: ⌘S on an untitled document
4400 // is a Save As, and only a frontend has a picker to ask with. Say so
4401 // rather than failing at the filesystem with an empty path.
4402 self.status = Some("untitled — save as…".into());
4403 return;
4404 }
4405 let path = self.path.clone();
4406 if self.write(&path) {
4407 self.mark_saved();
4408 }
4409 }
4410
4411 /// Save As: write the document to `path` and *move* it there — `self.path`
4412 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4413 /// what Save As means; a copy would leave the user editing a document whose
4414 /// name is no longer where their keystrokes go.
4415 ///
4416 /// The move only happens if the bytes actually landed. A failed write leaves
4417 /// the path, `dirty`, and the disk watermark exactly as they were, with the
4418 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4419 /// must never come away believing it was saved.
4420 ///
4421 /// An existing `path` is overwritten, and the caller is the one that knows
4422 /// whether to ask first: a Save As picker has already run that prompt, and a
4423 /// second confirmation from down here would be the same question twice.
4424 ///
4425 /// `format` does **not** follow the new extension. The buffer is parsed as
4426 /// the format it was opened with, and re-reading it as another one is a
4427 /// conversion — a different, lossy operation that would throw away the undo
4428 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4429 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4430 /// until it's reopened.
4431 #[cfg(feature = "fs")]
4432 pub fn save_as(&mut self, path: PathBuf) {
4433 if !self.write(&path) {
4434 return;
4435 }
4436 self.path = path;
4437 self.mark_saved();
4438 }
4439
4440 /// Put `source` on disk at `path`, reporting whether it got there. The one
4441 /// place leaf writes a document, so a save and a Save As can't disagree
4442 /// about what a failure looks like.
4443 #[cfg(feature = "fs")]
4444 fn write(&mut self, path: &Path) -> bool {
4445 match std::fs::write(path, self.source.as_bytes()) {
4446 Ok(()) => true,
4447 Err(e) => {
4448 self.status = Some(format!("save failed: {e}"));
4449 false
4450 }
4451 }
4452 }
4453
4454 /// Re-base the document's saved watermark to the current bytes: clears
4455 /// `dirty`, records `source` as the new clean state (so undoing back to here
4456 /// clears the flag again), and re-stamps the on-disk hash.
4457 ///
4458 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4459 /// the hook a **filesystem-free host** calls itself once it has persisted
4460 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4461 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4462 /// are already where that host wants them, and this just tells the model they
4463 /// are safe.
4464 pub fn mark_saved(&mut self) {
4465 self.clean_source = self.source.clone();
4466 self.dirty = false;
4467 // The bytes on disk are now ours, so this is the new watermark: without
4468 // re-stamping it, every save would report its own work as an external
4469 // change forever after.
4470 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4471 self.status = Some(format!("saved {}", self.file_name()));
4472 }
4473
4474 /// What the file looks like now against the bytes leaf last read or wrote.
4475 ///
4476 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4477 /// so this is a filesystem round-trip, not a per-frame question — ask it
4478 /// when a window regains focus, on a timer, or before a save.
4479 ///
4480 /// This *only* reports the file. Whether the document also has unsaved edits
4481 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4482 /// [`DiskState::Changed`] means a save overwrites someone's work and a
4483 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4484 /// it has no way to ask — so it hands a frontend both halves and lets it put
4485 /// the question to the person who can answer it.
4486 #[cfg(feature = "fs")]
4487 pub fn disk_state(&self) -> DiskState {
4488 let Some(want) = self.disk_hash else {
4489 return DiskState::Untitled;
4490 };
4491 match std::fs::read(&self.path) {
4492 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4493 Ok(_) => DiskState::Changed,
4494 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4495 Err(_) => DiskState::Unreadable,
4496 }
4497 }
4498
4499 /// Re-read the file and replace the document with what's there — the other
4500 /// answer to a [`DiskState::Changed`].
4501 ///
4502 /// **Discards unsaved changes and the undo history, unconditionally.** It
4503 /// doesn't check `dirty` first: a frontend that wants to protect unsaved
4504 /// work asks (`dirty` + [`Doc::disk_state`]) *before* calling this, and one
4505 /// reloading a clean document shouldn't have to argue with a guard. The
4506 /// history goes because twig's undo stack belongs to the buffer, and these
4507 /// are different bytes — replaying a step recorded against the old ones onto
4508 /// them would corrupt the document, and nothing here can honestly rebase it.
4509 ///
4510 /// The caret keeps its byte offset, clamped to the new length; the selection
4511 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4512 /// file changed, so it can't know where the caret "still" is. Clamping keeps
4513 /// it where the user left it in the common case (a change further down the
4514 /// file, or none in the text they're sitting in), and never puts it
4515 /// somewhere invalid. A selection has two such offsets and no such excuse —
4516 /// silently reinterpreting one over changed bytes would arm the *next*
4517 /// keystroke to delete something the user never selected.
4518 ///
4519 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4520 /// document alone with a status.
4521 #[cfg(feature = "fs")]
4522 pub fn reload(&mut self) {
4523 if self.is_untitled() {
4524 self.status = Some("no file to reload".into());
4525 return;
4526 }
4527 let bytes = match std::fs::read(&self.path) {
4528 Ok(b) => b,
4529 Err(e) => {
4530 self.status = Some(format!("reload failed: {e}"));
4531 return;
4532 }
4533 };
4534 let Ok(source) = String::from_utf8(bytes) else {
4535 self.status = Some("reload failed: file is not UTF-8".into());
4536 return;
4537 };
4538 // Reparse rather than splice the difference in: leaf doesn't know what
4539 // changed, and `format` is the format this document is, not what the
4540 // (unchanged) name now says — see `save_as`.
4541 let editor = match new_editor(source.as_bytes(), self.format) {
4542 Ok(ed) => ed,
4543 Err(e) => {
4544 self.status = Some(format!("reload failed: {e}"));
4545 return;
4546 }
4547 };
4548 self.editor = editor;
4549 self.disk_hash = Some(hash_bytes(source.as_bytes()));
4550 self.clean_source = source.clone();
4551 self.source = source;
4552 // Reload replaces the text without going through `refresh`, so it has to
4553 // move the revision itself or every frontend would keep painting the old
4554 // file from cache.
4555 self.revision += 1;
4556 self.caret = self.caret.min(self.source.len());
4557 self.anchor = None;
4558 self.goal_col = None;
4559 self.last_edit_kind = None;
4560 self.dirty = false;
4561 self.status = Some(format!("reloaded {}", self.file_name()));
4562 self.clamp_caret();
4563 }
4564
4565 /// Re-read the source from twig after it has changed the document. The one
4566 /// funnel every edit, undo, and redo comes through — so it's where the
4567 /// revision moves, and anything cached against the text dies here.
4568 fn refresh(&mut self) {
4569 if let Ok(s) = self.editor.source_str() {
4570 self.source = s;
4571 }
4572 self.revision += 1;
4573 self.clamp_caret();
4574 }
4575
4576 // ── caret movement ─────────────────────────────────────────────────────────
4577 // `extend` grows the selection (Shift+motion): it pins the anchor on the
4578 // first extended step and moves only the caret; an un-extended motion drops
4579 // the selection.
4580
4581 /// Place the caret at byte `offset` (clamped to a char boundary), extending
4582 /// the selection when `extend` is set. The public form of `move_to`, for a
4583 /// frontend that hit-tests pixels straight to a source offset.
4584 pub fn place_caret(&mut self, offset: usize, extend: bool) {
4585 self.goal_col = None;
4586 let before = self.caret;
4587 // A pixel hit-test can land between the visible caret stops — in the
4588 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
4589 // Snap to the nearest real stop so the caret can't come to rest where it
4590 // would draw in one place and type in another. The `(row, col)` click
4591 // path (`click`) already snaps this way through `offset_of_pos`; the
4592 // source view reaches every byte, so it snaps to nothing.
4593 let target = match self.view {
4594 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
4595 // The source view reaches every byte, so there is no stop to snap
4596 // to — but "every byte" still means every *character* boundary. A
4597 // caret resting inside a multi-byte character draws nowhere real
4598 // and panics the next time anything slices there.
4599 View::Source => {
4600 let mut o = offset.min(self.source.len());
4601 while o > 0 && !self.source.is_char_boundary(o) {
4602 o -= 1;
4603 }
4604 o
4605 }
4606 };
4607 self.move_to(target, extend);
4608 self.clamp_caret();
4609 self.debug_assert_on_a_stop(before);
4610 }
4611
4612 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
4613 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
4614 /// grab the metadata) while the source view still selects the literal whole.
4615 pub fn select_all(&mut self) {
4616 self.anchor = Some(self.caret_floor());
4617 self.caret = self.source.len();
4618 self.goal_col = None;
4619 self.last_edit_kind = None;
4620 self.status = None;
4621 }
4622
4623 /// Select the word (or whitespace / punctuation run) at `offset` — the
4624 /// double-click gesture. Anchors on the run's start with the caret at its
4625 /// end so a following Shift-motion extends from the far edge.
4626 pub fn select_word_at(&mut self, offset: usize) {
4627 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
4628 self.anchor = Some(s);
4629 self.caret = e;
4630 self.goal_col = None;
4631 self.last_edit_kind = None;
4632 self.status = None;
4633 self.clamp_caret();
4634 }
4635
4636 /// Select the whole enclosing text block (paragraph, heading, list item's
4637 /// text…) at `offset` — the triple-click gesture. Reads the range straight
4638 /// from the AST (twig's `content_span`), so it selects the entire *logical*
4639 /// paragraph even when that paragraph soft-wraps across several visual rows —
4640 /// where a visual-row-based select breaks down, because one source offset at
4641 /// a wrap boundary belongs to two rows at once.
4642 pub fn select_block_at(&mut self, offset: usize) {
4643 let off = offset.min(self.source.len());
4644 let range = self
4645 .editor
4646 .ancestors_at(off)
4647 .ok()
4648 .and_then(|chain| {
4649 // Ancestors run root → deepest; the deepest node that is neither
4650 // an inline span nor a multi-block container is the text block
4651 // the caret sits in (a paragraph, a heading, a code block…).
4652 chain
4653 .into_iter()
4654 .rev()
4655 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4656 .map(|m| m.content_span.unwrap_or(m.span))
4657 })
4658 .unwrap_or_else(|| source_line_range(&self.source, off));
4659 self.anchor = Some(range.start.min(self.source.len()));
4660 self.caret = range.end.min(self.source.len());
4661 self.goal_col = None;
4662 self.last_edit_kind = None;
4663 self.status = None;
4664 self.clamp_caret();
4665 }
4666
4667 /// The lowest source offset the caret may occupy in the active view. In
4668 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
4669 /// the first rendered offset; the source view reaches everything, so it's 0.
4670 fn caret_floor(&self) -> usize {
4671 match self.view {
4672 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
4673 View::Source => 0,
4674 }
4675 }
4676
4677 /// Land in a table cell with its whole content selected — the anchor at the
4678 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
4679 /// like tabbing into a form field: the text comes up selected, so typing
4680 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
4681 /// end`) collapses to a plain caret home (an empty selection is no selection).
4682 fn select_cell(&mut self, start: usize, end: usize) {
4683 let floor = self.caret_floor();
4684 self.anchor = Some(start.min(self.source.len()).max(floor));
4685 self.caret = end.min(self.source.len()).max(floor);
4686 self.goal_col = None;
4687 self.status = None;
4688 self.last_edit_kind = None;
4689 self.clear_pending();
4690 }
4691
4692 fn move_to(&mut self, offset: usize, extend: bool) {
4693 if extend {
4694 if self.anchor.is_none() {
4695 self.anchor = Some(self.caret);
4696 }
4697 } else {
4698 self.anchor = None;
4699 }
4700 self.caret = offset.min(self.source.len()).max(self.caret_floor());
4701 self.status = None;
4702 // A caret move ends the current typing/deletion run, so the next edit
4703 // starts a fresh undo group rather than coalescing across the gap.
4704 self.last_edit_kind = None;
4705 // Moving away disarms any sticky mark — "start bold" applies only where
4706 // it was asked for, not wherever the caret next lands.
4707 self.clear_pending();
4708 }
4709
4710 // In the source view, motion walks source bytes / source lines. In the
4711 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
4712 // what steps the caret cleanly over hidden delimiters.
4713
4714 pub fn move_left(&mut self, extend: bool) {
4715 self.goal_col = None;
4716 if !extend && let Some((s, _e)) = self.selection() {
4717 self.move_to(s, false);
4718 return;
4719 }
4720 let target = match self.view {
4721 View::Source => {
4722 if self.caret > 0 {
4723 prev_boundary(&self.source, self.caret)
4724 } else {
4725 0
4726 }
4727 }
4728 // Walks caret *stops*, not columns: decoration (a table border, a
4729 // cell's padding) is stepped over in one press, and a hidden
4730 // delimiter never holds the caret up.
4731 View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
4732 };
4733 let before = self.caret;
4734 self.move_to(target, extend);
4735 self.debug_assert_on_a_stop(before);
4736 }
4737
4738 pub fn move_right(&mut self, extend: bool) {
4739 self.goal_col = None;
4740 if !extend && let Some((_s, e)) = self.selection() {
4741 self.move_to(e, false);
4742 return;
4743 }
4744 let target = match self.view {
4745 View::Source => {
4746 if self.caret < self.source.len() {
4747 next_boundary(&self.source, self.caret)
4748 } else {
4749 self.caret
4750 }
4751 }
4752 View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
4753 };
4754 let before = self.caret;
4755 self.move_to(target, extend);
4756 self.debug_assert_on_a_stop(before);
4757 }
4758
4759 /// Move to the start of the previous word (⌥← / Ctrl+←).
4760 pub fn move_word_left(&mut self, extend: bool) {
4761 self.goal_col = None;
4762 let before = self.caret;
4763 let target = self.word_left_from(self.caret);
4764 self.move_to(target, extend);
4765 self.debug_assert_on_a_stop(before);
4766 }
4767
4768 /// Move to the end of the next word (⌥→ / Ctrl+→).
4769 pub fn move_word_right(&mut self, extend: bool) {
4770 self.goal_col = None;
4771 let before = self.caret;
4772 let target = self.word_right_from(self.caret);
4773 self.move_to(target, extend);
4774 self.debug_assert_on_a_stop(before);
4775 }
4776
4777 // Word boundaries are found in the space the *view* is in. The source view
4778 // walks the source, because there the source is what's rendered. WYSIWYG
4779 // walks the rendered text instead: `**` is invisible to the user, so it has
4780 // to be invisible to word motion too — a caret parked inside one draws in
4781 // the column after `bold` and types two bytes earlier, and a word-delete
4782 // that stops there shreds the markup into `a ** c`.
4783
4784 /// The word boundary to the left of `off` in the active view's space.
4785 fn word_left_from(&self, off: usize) -> usize {
4786 match self.view {
4787 View::Source => prev_word(&self.source, off),
4788 View::Wysiwyg => self.glyph_word_left(off),
4789 }
4790 }
4791
4792 /// The word boundary to the right of `off` in the active view's space.
4793 fn word_right_from(&self, off: usize) -> usize {
4794 match self.view {
4795 View::Source => next_word(&self.source, off),
4796 View::Wysiwyg => self.glyph_word_right(off),
4797 }
4798 }
4799
4800 /// The character class of the glyph drawn at stop `off`.
4801 ///
4802 /// Read from the source, because a stop points at the source byte its glyph
4803 /// came from — the source *is* where the rendered character is written. What
4804 /// makes the walk glyph space rather than source space is that it only ever
4805 /// visits stops, and the hidden bytes between them have none.
4806 fn class_at(&self, off: usize) -> Class {
4807 self.source
4808 .get(off..)
4809 .and_then(|s| s.chars().next())
4810 .map_or(Class::Space, classify)
4811 }
4812
4813 /// [`next_word`] in glyph space: skip any leading separators, then consume
4814 /// the following word run, with the stop table standing in for the source's
4815 /// characters.
4816 fn glyph_word_right(&self, from: usize) -> usize {
4817 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
4818 return from;
4819 };
4820 let mut in_word = false;
4821 loop {
4822 match self.class_at(off) {
4823 Class::Word => in_word = true,
4824 _ if in_word => return off,
4825 _ => {}
4826 }
4827 match self.vmap.stop_after(off) {
4828 Some(next) => off = next,
4829 None => return off,
4830 }
4831 }
4832 }
4833
4834 /// [`prev_word`] in glyph space: skip separators walking left, then consume
4835 /// the preceding word run.
4836 fn glyph_word_left(&self, from: usize) -> usize {
4837 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
4838 return from;
4839 };
4840 let mut in_word = false;
4841 while let Some(prev) = self.vmap.stop_before(off) {
4842 match self.class_at(prev) {
4843 Class::Word => in_word = true,
4844 _ if in_word => return off,
4845 _ => {}
4846 }
4847 off = prev;
4848 }
4849 off
4850 }
4851
4852 /// After a motion that walks the visual map, the caret must be *on* the map.
4853 /// A stop is the only offset where the caret draws and edits in the same
4854 /// place, and it's the invariant both a caret parked inside an emoji and one
4855 /// parked inside a `**` were quietly breaking.
4856 ///
4857 /// Only when the caret actually moved: a walk with nowhere to go leaves it
4858 /// where it was, which is wherever the floor or a frontend put it rather
4859 /// than somewhere this motion chose.
4860 fn debug_assert_on_a_stop(&self, before: usize) {
4861 debug_assert!(
4862 self.view != View::Wysiwyg
4863 || self.vmap.num_rows() == 0
4864 || self.caret == before
4865 || self.vmap.is_stop(self.caret),
4866 "motion left the caret at {}, which is not a caret stop: it would draw in \
4867 one place and type in another",
4868 self.caret
4869 );
4870 }
4871
4872 // Up and Down run off the ends of the document rather than stopping dead at
4873 // them: Up from the first row lands at the document's start, Down from the
4874 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
4875 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
4876 // reaching the end of the text is what a reader means by it.
4877 //
4878 // The views used to disagree here by accident rather than by decision: the
4879 // source view fell into the edge behaviour through `row_col_to_offset`
4880 // clamping an out-of-range row to the end of the string, while WYSIWYG had
4881 // no row below to walk to and did nothing at all. They share the rule now,
4882 // each in its own space — the source view reaches every byte, WYSIWYG only
4883 // the offsets it draws.
4884
4885 pub fn move_up(&mut self, extend: bool) {
4886 let (row, col) = self.caret_pos();
4887 let goal = self.goal_col.unwrap_or(col);
4888 let target = match self.view {
4889 View::Source => match row.checked_sub(1) {
4890 Some(r) => row_col_to_offset(&self.source, r, goal),
4891 None => self.reachable_start(),
4892 },
4893 // A table's border rules are drawn but hold no caret, so Up steps
4894 // over them to the row that does.
4895 View::Wysiwyg => match self.vmap.navigable_above(row) {
4896 Some(r) => self.row_target(r, goal),
4897 None => self.reachable_start(),
4898 },
4899 };
4900 self.step_vertical(target, goal, extend);
4901 }
4902
4903 pub fn move_down(&mut self, extend: bool) {
4904 let (row, col) = self.caret_pos();
4905 let goal = self.goal_col.unwrap_or(col);
4906 let target = match self.view {
4907 View::Source => match self.source_row_below(row) {
4908 Some(r) => row_col_to_offset(&self.source, r, goal),
4909 None => self.reachable_end(),
4910 },
4911 View::Wysiwyg => match self.vmap.navigable_below(row) {
4912 Some(r) => self.row_target(r, goal),
4913 None => self.reachable_end(),
4914 },
4915 };
4916 self.step_vertical(target, goal, extend);
4917 }
4918
4919 /// Land a vertical motion at `target`, latching the `goal` column it aimed
4920 /// with so the rest of the run keeps aiming there.
4921 ///
4922 /// A motion with nowhere to go changes *nothing*, the goal column included:
4923 /// the latch used to run before the early return at the top of the document,
4924 /// so an Up that did nothing still armed a column, and the next Down aimed
4925 /// at one the caret had never been in.
4926 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
4927 let before = self.caret;
4928 if target == before {
4929 return;
4930 }
4931 self.goal_col = Some(goal);
4932 self.move_to(target, extend);
4933 self.debug_assert_on_a_stop(before);
4934 }
4935
4936 /// The source line below `row`, or `None` when `row` is the last one. Lines
4937 /// are counted by newline, so a trailing one leaves a real, empty last line
4938 /// for the caret to sit on — the document ends below it, not on it.
4939 fn source_row_below(&self, row: usize) -> Option<usize> {
4940 let last = self.source.bytes().filter(|&b| b == b'\n').count();
4941 (row < last).then_some(row + 1)
4942 }
4943
4944 /// Where a vertical motion aiming at the `goal` column lands on visual row
4945 /// `r`: the column clamped to the row, mapped to its offset, then held
4946 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
4947 /// column belongs to the row below, and a gutter's column 0 points at the
4948 /// block rather than at this row.
4949 fn row_target(&self, r: usize, goal: usize) -> usize {
4950 let (start, end) = self.row_bounds(r);
4951 self.vmap
4952 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
4953 .clamp(start, end)
4954 }
4955
4956 /// The first and last offsets the caret can reach in the active view.
4957 ///
4958 /// Not the same span in both: the source view shows every byte, so it can
4959 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
4960 /// sits below the first stop, and a document's trailing newline is drawn
4961 /// nowhere and so sits past the last.
4962 fn reachable_start(&self) -> usize {
4963 match self.view {
4964 View::Source => 0,
4965 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
4966 }
4967 }
4968
4969 fn reachable_end(&self) -> usize {
4970 match self.view {
4971 View::Source => self.source.len(),
4972 View::Wysiwyg => self
4973 .vmap
4974 .stop_at_or_before(self.source.len())
4975 .unwrap_or(self.caret),
4976 }
4977 }
4978
4979 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
4980 /// including the space a soft wrap ate off its end, which is drawn on this
4981 /// row however much the offset past it belongs to the next one.
4982 fn row_span(&self, r: usize) -> (usize, usize) {
4983 let start = self
4984 .vmap
4985 .row_start(r)
4986 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
4987 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
4988 (start.min(end), end)
4989 }
4990
4991 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
4992 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
4993 /// this row's last position is the one before it — the offset before the
4994 /// space the wrap ate, where the caret draws just past the row's last word
4995 /// and types there too.
4996 ///
4997 /// Aiming at the shared offset instead is what stalled End: it is the row's
4998 /// last *column*, so End pressed on the row reached it and then read back as
4999 /// the row below's start, where a second press ran on to that row's end and
5000 /// the next to the one after — End walking down the paragraph a row a press.
5001 fn row_bounds(&self, r: usize) -> (usize, usize) {
5002 let (start, end) = self.row_span(r);
5003 let wraps = self
5004 .vmap
5005 .navigable_below(r)
5006 .and_then(|b| self.vmap.row_start(b))
5007 .is_some_and(|off| off == end);
5008 match wraps {
5009 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5010 false => (start, end),
5011 }
5012 }
5013
5014 /// The `[start, end]` of the line Home and End aim at: the visual row in
5015 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5016 ///
5017 /// A soft-wrapped row is a line here, because it is one to the eye and the
5018 /// eye is what these keys are aimed by — a reader pressing End means the end
5019 /// of the line they can see. (`select_block_at` wants the opposite and reads
5020 /// the AST for it: a triple-click grabs the whole paragraph, however many
5021 /// rows it folds into.)
5022 fn line_bounds(&self) -> (usize, usize) {
5023 let (row, _) = self.caret_pos();
5024 match self.view {
5025 View::Source => {
5026 let start = line_start(&self.source, row);
5027 (start, line_end_from(&self.source, start))
5028 }
5029 View::Wysiwyg => self.row_bounds(row),
5030 }
5031 }
5032
5033 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5034 /// *drawn* — what a kill takes.
5035 ///
5036 /// The two part only at a soft wrap, over the space the wrap ate: the caret
5037 /// can't stand after it (that offset opens the row below, and End stopping
5038 /// there would walk), but it is on this row, and a kill that spared it would
5039 /// leave a double space behind where the row's text had been. Deleting it
5040 /// joins nothing — a wrap is drawn, not written.
5041 fn line_span(&self) -> (usize, usize) {
5042 let (row, _) = self.caret_pos();
5043 match self.view {
5044 View::Source => self.line_bounds(),
5045 View::Wysiwyg => self.row_span(row),
5046 }
5047 }
5048
5049 /// The first offset in `[start, end]` holding something other than
5050 /// whitespace, or `end` when the line holds nothing else — where Home aims.
5051 ///
5052 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5053 /// so a hidden delimiter is never taken for the line's first character (nor
5054 /// landed on), and the source view steps the source it is showing.
5055 fn first_non_space(&self, start: usize, end: usize) -> usize {
5056 let mut off = start;
5057 while off < end {
5058 if self.class_at(off) != Class::Space {
5059 return off;
5060 }
5061 off = match self.view {
5062 View::Source => next_boundary(&self.source, off),
5063 View::Wysiwyg => match self.vmap.stop_after(off) {
5064 Some(next) => next,
5065 None => return end,
5066 },
5067 };
5068 }
5069 end
5070 }
5071
5072 /// Home: to the first character on the line, or to column 0 when the caret
5073 /// is already on it — the two-press toggle every editor spells this way.
5074 /// The indentation is somewhere the caret has to be able to reach and almost
5075 /// never where a reader is headed, so it costs the second press.
5076 pub fn move_home(&mut self, extend: bool) {
5077 self.goal_col = None;
5078 let (start, end) = self.line_bounds();
5079 let text = self.first_non_space(start, end);
5080 let target = if self.caret == text { start } else { text };
5081 let before = self.caret;
5082 self.move_to(target, extend);
5083 self.debug_assert_on_a_stop(before);
5084 }
5085
5086 /// End: to the end of the line.
5087 pub fn move_end(&mut self, extend: bool) {
5088 self.goal_col = None;
5089 let (_, end) = self.line_bounds();
5090 let before = self.caret;
5091 self.move_to(end, extend);
5092 self.debug_assert_on_a_stop(before);
5093 }
5094
5095 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5096 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5097 /// when the caret isn't in a table, or is already in the last/first cell — the
5098 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5099 /// meaning everywhere else.
5100 pub fn cell_hop(&mut self, forward: bool) -> bool {
5101 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5102 return false;
5103 };
5104 // Flatten to document (row-major) order and step one cell either way.
5105 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5106 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5107 let next = if forward {
5108 i.checked_add(1)
5109 } else {
5110 i.checked_sub(1)
5111 };
5112 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5113 return false; // at the table's edge; leave Tab to the frontend
5114 };
5115 self.select_cell(start, end);
5116 true
5117 }
5118
5119 /// Move the caret to the cell directly above (`down == false`) or below in
5120 /// the same column, landing with the cell's whole content selected (see
5121 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5122 /// when the caret isn't in a table), so the frontend can fall through — the
5123 /// vertical counterpart of [`cell_hop`].
5124 ///
5125 /// A ragged row that is short a column clamps to its last cell, so Down never
5126 /// falls out of the table over a gap the row above happened to have.
5127 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5128 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5129 return false;
5130 };
5131 let target = match down {
5132 true => r + 1,
5133 false if r == 0 => return false,
5134 false => r - 1,
5135 };
5136 let Some(row) = grid.get(target) else {
5137 return false;
5138 };
5139 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5140 return false;
5141 };
5142 self.select_cell(start, end);
5143 true
5144 }
5145
5146 /// The table containing `off` as a row-major grid of `(start, end)` cell
5147 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5148 /// isn't in a table. Read straight off the visual map's laid-out grid, so
5149 /// every cell (an empty one included, whose derived home twig gives no
5150 /// `content_span` for) is present and in the order Tab walks them.
5151 // Grid, row, column — three returns that only ever travel together, and a
5152 // named type for the pair of them would be read at one call site.
5153 #[allow(clippy::type_complexity)]
5154 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5155 for t in &self.vmap.tables {
5156 let mut pos = None;
5157 let grid: Vec<Vec<(usize, usize)>> = t
5158 .grid
5159 .iter()
5160 .enumerate()
5161 .map(|(r, row)| {
5162 row.cells
5163 .iter()
5164 .enumerate()
5165 .map(|(c, cell)| {
5166 if pos.is_none() && off >= cell.start && off <= cell.end {
5167 pos = Some((r, c));
5168 }
5169 (cell.start, cell.end)
5170 })
5171 .collect()
5172 })
5173 .collect();
5174 if let Some((r, c)) = pos {
5175 return Some((grid, r, c));
5176 }
5177 }
5178 None
5179 }
5180
5181 // ── table key policy ──────────────────────────────────────────────────────
5182 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5183 // as one policy every frontend shares, rather than each re-deriving it. Each
5184 // reports whether it acted *as a table key*; a `false` hands the key back to
5185 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5186 // everywhere else.
5187
5188 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5189 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5190 /// back and simply stays put at the very first cell. `false` when the caret
5191 /// isn't in a table.
5192 pub fn cell_tab(&mut self, forward: bool) -> bool {
5193 if !self.caret_in_table() {
5194 return false;
5195 }
5196 if self.cell_hop(forward) {
5197 return true;
5198 }
5199 // Off the last cell: grow the table by a row and step into its first
5200 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5201 if forward {
5202 self.append_row_and_enter(0);
5203 }
5204 true
5205 }
5206
5207 /// Return inside a table: drop to the cell below in the same column,
5208 /// appending a new row when the caret is already in the last one. `false`
5209 /// when the caret isn't in a table, so the frontend inserts a newline.
5210 pub fn cell_return(&mut self) -> bool {
5211 if !self.caret_in_table() {
5212 return false;
5213 }
5214 if self.cell_move_vertical(true) {
5215 return true;
5216 }
5217 // Already on the last row: grow one below and drop into the same column.
5218 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5219 self.append_row_and_enter(col);
5220 true
5221 }
5222
5223 /// Append a row below the caret's (last) row and land in `col` of it. The
5224 /// caret is in the last row, so twig's "insert below" makes the fresh row the
5225 /// table's new last — but twig re-spells the whole table, moving every byte,
5226 /// so the destination is read back from the rebuilt grid by the table's
5227 /// position (stable across a row insert), not from the pre-edit caret.
5228 fn append_row_and_enter(&mut self, col: usize) {
5229 let table = self.caret_table_index();
5230 self.table_insert_row(true);
5231 self.rebuild_map();
5232 let Some((start, end)) = table
5233 .and_then(|ti| self.vmap.tables.get(ti))
5234 .and_then(|t| t.grid.last())
5235 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5236 .map(|cell| (cell.start, cell.end))
5237 else {
5238 return;
5239 };
5240 self.select_cell(start, end);
5241 }
5242
5243 /// The index, among the document's tables, of the one the caret sits in —
5244 /// `None` when it's in none. Used to re-find a table after an edit re-spells
5245 /// it (a row insert leaves the table order unchanged).
5246 fn caret_table_index(&self) -> Option<usize> {
5247 let off = self.caret;
5248 self.vmap.tables.iter().position(|t| {
5249 t.grid
5250 .iter()
5251 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5252 })
5253 }
5254
5255 /// Shift+Return inside a table: insert a hard line break *within* the current
5256 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5257 /// table, so the frontend inserts an ordinary line break.
5258 ///
5259 /// A table row is a single source line, so the newline-spelled hard break
5260 /// can't live in a cell. twig spells the in-cell break the format's way
5261 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5262 /// break round-trips as structure the renderer reads back as a line — not the
5263 /// opaque raw HTML the old raw-splice left behind.
5264 ///
5265 /// Djot has no idiomatic in-cell break, so twig refuses it
5266 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5267 /// would render as the literal text `<br>`. The gesture is still *consumed*
5268 /// there — returning `false` would let the frontend insert a real newline,
5269 /// which splits the one-line row — it just leaves the cell unchanged and says
5270 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5271 ///
5272 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5273 /// have to be read together: djot is not the only `false`, and naming it in
5274 /// the message was already a guess that HTML — which spells the break as its
5275 /// own `<br>` — would have made wrong.
5276 pub fn cell_line_break(&mut self) -> bool {
5277 if !self.caret_in_table() {
5278 return false;
5279 }
5280 self.record_caret();
5281 match self.editor.insert_line_break(self.caret) {
5282 Ok(change) => {
5283 self.last_edit_kind = None;
5284 self.refresh();
5285 self.caret = change.new.end;
5286 self.anchor = None;
5287 self.goal_col = None;
5288 self.clamp_caret();
5289 self.dirty = self.source != self.clean_source;
5290 self.status = None;
5291 self.record_caret();
5292 }
5293 Err(twig::Error::UnsupportedFormat) => {
5294 self.status = Some(format!(
5295 "in-cell line breaks aren't supported in {}",
5296 self.format_name()
5297 ));
5298 }
5299 Err(_) => {}
5300 }
5301 true
5302 }
5303
5304 /// Rebuild the visual map at the width the last build used. A structural edit
5305 /// bumps the revision and swaps the source in, but leaves the *map* stale;
5306 /// when a single gesture edits and then moves over the result (Tab appending
5307 /// a row, then stepping into it), the move needs the map to already show the
5308 /// edit rather than waiting for the frontend's next frame.
5309 fn rebuild_map(&mut self) {
5310 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5311 self.build_map(wrap);
5312 }
5313
5314 /// Move the caret to the very start of the document (⌘↑ on macOS,
5315 /// Ctrl+Home on Windows/Linux).
5316 pub fn move_doc_start(&mut self, extend: bool) {
5317 self.goal_col = None;
5318 self.move_to(0, extend);
5319 }
5320
5321 /// Move the caret to the very end of the document (⌘↓ on macOS,
5322 /// Ctrl+End on Windows/Linux).
5323 pub fn move_doc_end(&mut self, extend: bool) {
5324 self.goal_col = None;
5325 let end = self.source.len();
5326 self.move_to(end, extend);
5327 }
5328
5329 /// Point the caret at the body cell `(row, col)` the mouse landed on —
5330 /// `col` being a cell of the terminal grid, which is what a display column
5331 /// is. A click on the far cell of a wide character lands at that
5332 /// character's start; the mapping's own doc-comments carry the rule.
5333 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5334 self.goal_col = None;
5335 let target = match self.view {
5336 View::Source => row_col_to_offset(&self.source, row, col),
5337 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5338 };
5339 let before = self.caret;
5340 self.move_to(target, extend);
5341 self.debug_assert_on_a_stop(before);
5342 }
5343
5344 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5345 /// screen if it has moved since the last frame, and never scroll past the
5346 /// last of `rows`.
5347 ///
5348 /// Only if it has *moved* — that's the whole point. Revealing the caret on
5349 /// every frame ties the viewport to it, and a scroll wheel that fights the
5350 /// caret for the viewport loses: the view snaps back the instant it tries to
5351 /// pass the caret's row, so the document can't be scrolled beyond what's
5352 /// already on screen. A caret move is the frontend's cue to follow; a scroll
5353 /// with the caret sitting still is the reader's cue to leave it alone.
5354 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5355 if self.drawn_caret != Some(self.caret) {
5356 if caret_row < self.scroll {
5357 self.scroll = caret_row;
5358 } else if height > 0 && caret_row >= self.scroll + height {
5359 self.scroll = caret_row + 1 - height;
5360 }
5361 self.drawn_caret = Some(self.caret);
5362 }
5363 self.scroll = self.scroll.min(rows.saturating_sub(1));
5364 }
5365
5366 /// The caret's screen position `(row, col)` in the active view's grid, with
5367 /// `col` a display column: the cell to draw the caret in, which on a line of
5368 /// `你好` or emoji is not the count of characters before it.
5369 pub fn caret_pos(&self) -> (usize, usize) {
5370 match self.view {
5371 View::Source => offset_to_row_col(&self.source, self.caret),
5372 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5373 }
5374 }
5375
5376 fn clamp_caret(&mut self) {
5377 if self.caret > self.source.len() {
5378 self.caret = self.source.len();
5379 }
5380 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5381 // any selection anchor) to the first rendered offset.
5382 let floor = self.caret_floor();
5383 if self.caret < floor {
5384 self.caret = floor;
5385 }
5386 if let Some(a) = self.anchor
5387 && a < floor
5388 {
5389 self.anchor = Some(floor);
5390 }
5391 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5392 self.caret -= 1;
5393 }
5394 }
5395}
5396
5397// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5398
5399// Left/right motion and backspace/delete step by *grapheme cluster*, not
5400// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5401// marks moves and deletes as the single character a user sees. Grapheme
5402// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5403
5404/// How an insert of `text` groups for undo: a single typed character folds into
5405/// the run of typing around it, while a newline or a multi-character insert is a
5406/// step of its own.
5407fn typed_edit_kind(text: &str) -> EditKind {
5408 if text.chars().take(2).count() == 1 && text != "\n" {
5409 EditKind::Insert
5410 } else {
5411 EditKind::Other
5412 }
5413}
5414
5415fn prev_boundary(s: &str, i: usize) -> usize {
5416 let mut cursor = GraphemeCursor::new(i, s.len(), true);
5417 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5418}
5419
5420fn next_boundary(s: &str, i: usize) -> usize {
5421 let mut cursor = GraphemeCursor::new(i, s.len(), true);
5422 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5423}
5424
5425// ── word boundaries ──────────────────────────────────────────────────────────
5426// The shared primitive behind word-wise motion, word deletion, and
5427// double-click-to-select-a-word. A "word" is a maximal run of one character
5428// class; whitespace and punctuation are their own classes, so motion skips
5429// cleanly between them the way native text fields do.
5430
5431#[derive(PartialEq, Eq, Clone, Copy)]
5432enum Class {
5433 Word,
5434 Space,
5435 Other,
5436}
5437
5438/// The source range of an inline node's own visible text — the part of it a
5439/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5440/// `None` for a node with no interior to empty (a `str`, a break).
5441///
5442/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5443/// one delimiter in from the span — the same place the renderer maps it to. A
5444/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5445/// against the source rather than trusted: a range guessed wrong here is text
5446/// deleted wrong.
5447fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5448 if let Some(span) = n.content_span.clone() {
5449 return Some(span);
5450 }
5451 match n.kind.as_str() {
5452 "verbatim" | "inline_math" => {
5453 let text = n.text.as_ref()?;
5454 let start = n.span.start + 1;
5455 let range = start..start + text.len();
5456 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
5457 }
5458 _ => None,
5459 }
5460}
5461
5462/// The `id` a node declares, or `None` for one that declares none — the
5463/// attribute djot writes for a `{#v1}` and mints for a heading.
5464///
5465/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
5466/// names nothing, so it reads as absent rather than as the empty string.
5467fn declared_id(n: &FlatNode) -> Option<&str> {
5468 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
5469}
5470
5471/// A heading's words reduced to the form a link fragment spells them in:
5472/// lowercase, runs of anything else collapsed to a single `-`, with none left
5473/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
5474///
5475/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
5476/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
5477/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
5478/// any other language is still a heading someone will link to. Underscores
5479/// survive for the same reason they do on the web: they are word characters
5480/// wherever identifiers are written.
5481fn slug(text: &str) -> String {
5482 let mut out = String::new();
5483 let mut pending = false;
5484 for c in text.chars() {
5485 if c.is_alphanumeric() || c == '_' {
5486 if pending && !out.is_empty() {
5487 out.push('-');
5488 }
5489 pending = false;
5490 out.extend(c.to_lowercase());
5491 } else {
5492 pending = true;
5493 }
5494 }
5495 out
5496}
5497
5498fn is_block_container(kind: &Kind) -> bool {
5499 matches!(
5500 kind,
5501 Kind::Doc
5502 | Kind::Section
5503 | Kind::BlockQuote
5504 | Kind::BulletList
5505 | Kind::OrderedList
5506 | Kind::TaskList
5507 | Kind::ListItem
5508 | Kind::TaskListItem
5509 // Every `container` — a directive in any of its three forms, or a
5510 // promoted HTML element. A *text* directive is really inline, so
5511 // claiming it here is a small overreach, and the deliberate one this
5512 // function's kind-only peer `is_inline_kind` documents: the pair is
5513 // consulted together, and answering "block container" for something
5514 // inline is what keeps an ancestor walk from stopping short of the
5515 // paragraph that actually holds it.
5516 | Kind::Container
5517 )
5518}
5519
5520/// The `[start, end)` byte range of the source line containing `off` (newline
5521/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
5522/// line between paragraphs).
5523fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
5524 let off = off.min(s.len());
5525 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
5526 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
5527 start..end
5528}
5529
5530/// How many leading bytes an outdent takes off `line`: a whole indent level
5531/// where the line has one, and whatever it has where it has less.
5532///
5533/// A leading tab counts as a level on its own. It's indentation some other
5534/// editor wrote, and one tab is one level everywhere it came from — measuring it
5535/// in spaces it doesn't contain would leave it untouchable.
5536fn outdent_width(line: &str, unit: usize) -> usize {
5537 if line.starts_with('\t') {
5538 return 1;
5539 }
5540 line.bytes().take(unit).take_while(|b| *b == b' ').count()
5541}
5542
5543/// A list marker found at the head of a line, together with everything before it
5544/// that a sibling line has to repeat.
5545///
5546/// The three offsets differ only inside a block quote, where `> - b` opens with
5547/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
5548/// `line_start == marker_start`, and `text` is the plain `" - "`.
5549#[derive(Clone, Debug)]
5550struct ListMarker {
5551 /// The line's first byte.
5552 line_start: usize,
5553 /// Where the marker proper begins, past any quote prefix. The offset to hand
5554 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
5555 marker_start: usize,
5556 /// `line_start` through the marker's trailing space — quote prefix, indent
5557 /// and bullet together, which is what the next item's line opens with.
5558 text: String,
5559}
5560
5561impl ListMarker {
5562 /// Where the item's content starts — one past the marker's trailing space.
5563 fn content_start(&self) -> usize {
5564 self.line_start + self.text.len()
5565 }
5566}
5567
5568fn classify(c: char) -> Class {
5569 if c == '_' || c.is_alphanumeric() {
5570 Class::Word
5571 } else if c.is_whitespace() {
5572 Class::Space
5573 } else {
5574 Class::Other
5575 }
5576}
5577
5578/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
5579/// skip any leading separators, then consume the following word run.
5580fn next_word(s: &str, i: usize) -> usize {
5581 let mut off = i;
5582 let mut in_word = false;
5583 for c in s[i..].chars() {
5584 if classify(c) == Class::Word {
5585 in_word = true;
5586 } else if in_word {
5587 break;
5588 }
5589 off += c.len_utf8();
5590 }
5591 off
5592}
5593
5594/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
5595/// skip separators walking left, then consume the preceding word run.
5596fn prev_word(s: &str, i: usize) -> usize {
5597 let mut off = i;
5598 let mut in_word = false;
5599 for c in s[..i].chars().rev() {
5600 if classify(c) == Class::Word {
5601 in_word = true;
5602 } else if in_word {
5603 break;
5604 }
5605 off -= c.len_utf8();
5606 }
5607 off
5608}
5609
5610/// The `[start, end)` run of same-class characters surrounding `off` — the
5611/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
5612/// the run ending there is used.
5613fn word_range_at(s: &str, off: usize) -> (usize, usize) {
5614 if s.is_empty() {
5615 return (0, 0);
5616 }
5617 let off = off.min(s.len());
5618 let reference = if off < s.len() {
5619 s[off..].chars().next()
5620 } else {
5621 s[..off].chars().next_back()
5622 };
5623 let Some(rc) = reference else {
5624 return (off, off);
5625 };
5626 let class = classify(rc);
5627
5628 let mut start = off;
5629 for c in s[..start].chars().rev() {
5630 if classify(c) == class {
5631 start -= c.len_utf8();
5632 } else {
5633 break;
5634 }
5635 }
5636 let mut end = off;
5637 for c in s[end..].chars() {
5638 if classify(c) == class {
5639 end += c.len_utf8();
5640 } else {
5641 break;
5642 }
5643 }
5644 (start, end)
5645}
5646
5647/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
5648/// the line's start — terminal cells, not characters, so the column names the
5649/// cell the caret is drawn in even on a line of `你好` or emoji.
5650fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
5651 let off = off.min(s.len());
5652 let mut row = 0;
5653 let mut line_start = 0;
5654 for (i, &b) in s.as_bytes().iter().enumerate() {
5655 if i >= off {
5656 break;
5657 }
5658 if b == b'\n' {
5659 row += 1;
5660 line_start = i + 1;
5661 }
5662 }
5663 (row, wysiwyg::text_width(&s[line_start..off]))
5664}
5665
5666/// The byte offset at display column `col` of `row` (clamped to that line's
5667/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
5668///
5669/// A column landing *inside* a character — the second cell of `你`, or any cell
5670/// but the first of an emoji — resolves to that character's start, which is the
5671/// column the caret would have been drawn at to begin with. So both cells of a
5672/// wide character mean the character, and every offset survives the round trip
5673/// out to a column and back. The walk steps by grapheme cluster for the same
5674/// reason the caret does: a cluster is the character, and the cells belong to it
5675/// rather than to the codepoints spelling it.
5676fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
5677 let start = line_start(s, row);
5678 let end = line_end_from(s, start);
5679 let mut off = start;
5680 let mut at = 0; // the display column `off` sits at
5681 while off < end {
5682 let next = next_boundary(s, off).min(end);
5683 let cells = wysiwyg::text_width(&s[off..next]);
5684 if at + cells > col {
5685 break; // `col` is one of this cluster's own cells
5686 }
5687 at += cells;
5688 off = next;
5689 }
5690 off
5691}
5692
5693fn line_start(s: &str, row: usize) -> usize {
5694 if row == 0 {
5695 return 0;
5696 }
5697 let mut r = 0;
5698 for (i, &b) in s.as_bytes().iter().enumerate() {
5699 if b == b'\n' {
5700 r += 1;
5701 if r == row {
5702 return i + 1;
5703 }
5704 }
5705 }
5706 s.len()
5707}
5708
5709fn line_end_from(s: &str, start: usize) -> usize {
5710 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
5711}
5712
5713/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
5714/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
5715/// twig applies writing the mark out, so the toolbar can light the same button
5716/// that made the node.
5717///
5718/// `None` for every other kind, including the inline nodes that aren't marks at
5719/// all (`str`, `link`, `image`, the math and break kinds): they're things a
5720/// caret stands in, not formatting a button toggles.
5721fn inline_kind(kind: &Kind) -> Option<InlineKind> {
5722 Some(match kind {
5723 Kind::Strong => InlineKind::Strong,
5724 Kind::Emph => InlineKind::Emph,
5725 Kind::Verbatim => InlineKind::Verbatim,
5726 Kind::Mark => InlineKind::Mark,
5727 Kind::Superscript => InlineKind::Superscript,
5728 Kind::Subscript => InlineKind::Subscript,
5729 Kind::Insert => InlineKind::Insert,
5730 Kind::Delete => InlineKind::Delete,
5731 _ => return None,
5732 })
5733}
5734
5735/// A watermark for a file's contents (see `Doc::disk_hash`).
5736///
5737/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
5738/// watermark is compared only against one taken by the same process moments
5739/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
5740/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
5741fn hash_bytes(bytes: &[u8]) -> u64 {
5742 use std::hash::{Hash, Hasher};
5743 let mut h = std::collections::hash_map::DefaultHasher::new();
5744 bytes.hash(&mut h);
5745 h.finish()
5746}
5747
5748#[cfg(feature = "fs")]
5749fn detect_format(path: &Path) -> Result<Format> {
5750 let ext = path
5751 .extension()
5752 .and_then(|e| e.to_str())
5753 .unwrap_or("")
5754 .to_ascii_lowercase();
5755 Ok(match ext.as_str() {
5756 "dj" | "djot" => Format::Djot,
5757 "md" | "markdown" => Format::Markdown,
5758 "xml" => Format::Xml,
5759 "html" | "htm" => Format::Html,
5760 other => return Err(anyhow!("unknown document extension: .{other}")),
5761 })
5762}
5763
5764#[cfg(test)]
5765mod tests {
5766 use super::*;
5767
5768 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
5769 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
5770 /// without one is a view no user is ever in.
5771 fn doc_in(view: View, name: &str, body: &str) -> Doc {
5772 // The fixture name doubles as the temp file's, so two tests picking the
5773 // same one raced under the parallel runner and read each other's body —
5774 // a green suite proving the wrong thing. The counter makes that
5775 // unreachable rather than asking every future caller to notice.
5776 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5777 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5778 let mut p = std::env::temp_dir();
5779 p.push(format!("leaf_test_{name}_{seq}.md"));
5780 std::fs::write(&p, body).unwrap();
5781 let mut d = Doc::open(p).unwrap();
5782 d.view = view;
5783 if view == View::Wysiwyg {
5784 d.build_visual(80);
5785 }
5786 d
5787 }
5788
5789 // Source-view document for the source-behaviour tests. `Doc::open` now
5790 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
5791 // `wysiwyg_doc` builds the rich-text variant on top of this.
5792 fn doc_with(name: &str, body: &str) -> Doc {
5793 doc_in(View::Source, name, body)
5794 }
5795
5796 /// Every visual row's drawn text — what the reader actually sees, which is
5797 /// the only thing the reveal preference is supposed to change.
5798 fn drawn_rows(d: &Doc) -> Vec<String> {
5799 d.vmap
5800 .rows
5801 .iter()
5802 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5803 .collect()
5804 }
5805
5806 /// Put the caret at the first byte of `needle` and rebuild, so the row under
5807 /// it becomes the revealed line.
5808 fn caret_at(d: &mut Doc, needle: &str) {
5809 d.caret = d.source.find(needle).expect("needle in source");
5810 d.build_visual(80);
5811 }
5812
5813 #[test]
5814 fn blockquote_after_a_list_is_not_bulleted() {
5815 // twig nests a following top-level block quote under the `bullet_list`
5816 // (a direct child, not a `list_item`). The map must render it de-nested —
5817 // `│ quote`, never `• │ quote` — with a blank separator, like any block
5818 // that follows a list. Regression for the "combined list + blockquote" bug.
5819 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
5820 d.build_visual(80);
5821 let rows: Vec<String> = d
5822 .vmap
5823 .rows
5824 .iter()
5825 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5826 .collect();
5827 assert!(
5828 rows.iter().any(|r| r == "│ quote"),
5829 "block quote should render on its own gutter, got rows: {rows:?}"
5830 );
5831 assert!(
5832 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
5833 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
5834 );
5835 }
5836
5837 // ── the map is built at most once per (revision, wrap) ───────────────────
5838 //
5839 // A frontend repaints for reasons that have nothing to do with the text — a
5840 // blinking caret, a scroll — and rebuilding the map is O(document). These
5841 // pin *that the cache fires*, which a passing suite can't tell you: a cache
5842 // that never hits is invisible to every other test in this file.
5843 //
5844 // The probe is to wreck the built map and ask for it again. A rebuild
5845 // repairs it; a cache hit hands the wreckage straight back. Nothing else
5846 // can distinguish the two from outside.
5847
5848 #[test]
5849 fn a_rebuild_with_nothing_changed_reuses_the_map() {
5850 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
5851 d.build_visual(80);
5852 assert!(!d.vmap.rows.is_empty());
5853 d.vmap.rows.clear(); // wreck it
5854 d.build_visual(80);
5855 assert!(
5856 d.vmap.rows.is_empty(),
5857 "the map was rebuilt though nothing changed — the cache never fired"
5858 );
5859 }
5860
5861 #[test]
5862 fn an_edit_rebuilds_the_map() {
5863 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
5864 d.build_visual(80);
5865 let before = d.revision();
5866 d.vmap.rows.clear();
5867 d.insert("x");
5868 d.build_visual(80);
5869 assert!(d.revision() > before, "an edit must move the revision");
5870 assert!(
5871 !d.vmap.rows.is_empty(),
5872 "an edited document must not paint from a stale map"
5873 );
5874 }
5875
5876 #[test]
5877 fn a_width_change_rebuilds_the_map() {
5878 // The map is a function of the wrap width too, so a resize is a miss
5879 // even though the text is untouched.
5880 let mut d = doc_in(
5881 View::Wysiwyg,
5882 "cache_width",
5883 "one two three four five six\n",
5884 );
5885 d.build_visual(80);
5886 d.vmap.rows.clear();
5887 d.build_visual(12);
5888 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
5889 // And the unwrapped map is its own key, not the same as any width.
5890 d.vmap.rows.clear();
5891 d.build_visual_unwrapped();
5892 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
5893 }
5894
5895 #[test]
5896 fn a_motion_does_not_rebuild_the_map() {
5897 // The whole point: moving the caret changes nothing the map is built
5898 // from. If a motion bumped the revision, every arrow key would cost a
5899 // full rebuild and the cache would be worthless.
5900 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
5901 d.build_visual(80);
5902 let rev = d.revision();
5903 d.move_right(false);
5904 d.move_right(true);
5905 d.move_down(false);
5906 assert_eq!(d.revision(), rev, "a motion must not move the revision");
5907 d.vmap.rows.clear();
5908 d.build_visual(80);
5909 assert!(
5910 d.vmap.rows.is_empty(),
5911 "a motion should not rebuild the map"
5912 );
5913 }
5914
5915 #[test]
5916 fn saving_does_not_rebuild_the_map() {
5917 // Saving changes `dirty`, not the text.
5918 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
5919 d.insert("x");
5920 d.build_visual(80);
5921 let rev = d.revision();
5922 d.save();
5923 assert_eq!(d.revision(), rev, "a save must not move the revision");
5924 assert!(!d.dirty, "the save should have cleaned the document");
5925 }
5926
5927 #[test]
5928 fn a_reload_rebuilds_the_map() {
5929 // Reload replaces the text without going through `refresh`, so it has to
5930 // move the revision itself — else the editor paints the old file.
5931 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
5932 d.build_visual(80);
5933 let rev = d.revision();
5934 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
5935 d.reload();
5936 assert!(d.revision() > rev, "a reload must move the revision");
5937 d.build_visual(80);
5938 let text: String = d
5939 .vmap
5940 .rows
5941 .iter()
5942 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5943 .collect();
5944 assert!(
5945 text.contains("wholly new"),
5946 "the reloaded text should be on screen, got {text:?}"
5947 );
5948 }
5949
5950 // ── golden-case harness ──────────────────────────────────────────────────
5951 // The pattern the whole parity suite can reuse: write a fixture with the
5952 // caret marked by `|`, run one action, and compare the rendered result —
5953 // also caret-marked — against the expected string. One readable line per
5954 // behavior, and it exercises the exact `Doc` ops both frontends call.
5955
5956 /// Split a `|`-marked fixture into `(source, caret_offset)`.
5957 fn parse_caret(marked: &str) -> (String, usize) {
5958 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
5959 (marked.replacen('|', "", 1), caret)
5960 }
5961
5962 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
5963 /// selection) so a result reads like the fixtures.
5964 fn render_caret(d: &Doc) -> String {
5965 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
5966 // so the caret always renders inside its own selection.
5967 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
5968 if let Some((s, e)) = d.selection() {
5969 marks.push((s, 0, '['));
5970 marks.push((e, 2, ']'));
5971 }
5972 // Insert right-to-left: descending offset, then descending rank.
5973 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
5974 let mut out = d.source.clone();
5975 for (at, _, ch) in marks {
5976 out.insert(at, ch);
5977 }
5978 out
5979 }
5980
5981 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
5982 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
5983 golden_in(View::Source, name, marked, action)
5984 }
5985
5986 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
5987 /// the same fixture has to read the same way in both.
5988 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
5989 let (src, caret) = parse_caret(marked);
5990 let mut d = doc_in(view, name, &src);
5991 d.caret = caret;
5992 action(&mut d);
5993 render_caret(&d)
5994 }
5995
5996 #[test]
5997 fn word_motion_walks_word_by_word() {
5998 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
5999 assert_eq!(
6000 g("hello wor|ld", |d| d.move_word_left(false)),
6001 "hello |world"
6002 );
6003 assert_eq!(
6004 g("hello| world", |d| d.move_word_left(false)),
6005 "|hello world"
6006 );
6007 assert_eq!(
6008 g("hel|lo world", |d| d.move_word_right(false)),
6009 "hello| world"
6010 );
6011 assert_eq!(
6012 g("hello| world", |d| d.move_word_right(false)),
6013 "hello world|"
6014 );
6015 // Punctuation is its own class, so motion stops at the boundary.
6016 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6017 }
6018
6019 #[test]
6020 fn word_motion_extends_the_selection_when_asked() {
6021 assert_eq!(
6022 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6023 "hello [world|]"
6024 );
6025 }
6026
6027 #[test]
6028 fn delete_word_removes_a_whole_word() {
6029 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6030 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6031 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6032 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6033 }
6034
6035 // ── Home / End ───────────────────────────────────────────────────────────
6036
6037 #[test]
6038 fn home_toggles_between_the_line_s_text_and_its_margin() {
6039 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6040 // indent to the markup it spells everywhere it means one, so the fixture
6041 // with whitespace left to walk is a code block, which is verbatim.
6042 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6043 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
6044 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
6045 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
6046 // A line with no indentation has one place to go, so the toggle is a
6047 // no-op rather than a trip to nowhere.
6048 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6049 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6050
6051 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
6052 let indent = d.source.find(" indented").unwrap();
6053 d.caret = indent + 6; // inside "indented"
6054 d.move_home(false);
6055 assert_eq!(
6056 d.caret,
6057 indent + 4,
6058 "wysiwyg: Home aims at the code line's text"
6059 );
6060 d.move_home(false);
6061 assert_eq!(
6062 d.caret, indent,
6063 "wysiwyg: the second press takes the indent"
6064 );
6065 d.move_home(false);
6066 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6067 }
6068
6069 #[test]
6070 fn end_takes_the_line_the_view_is_showing() {
6071 // The line differs by view for the same document, and that is the point:
6072 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6073 // as a space on one row and the source view as two lines.
6074 let mut d = doc_with("end_src", "one two\nthree\n");
6075 d.caret = 1;
6076 d.move_end(false);
6077 assert_eq!(d.caret, 7, "source: the end of the source line");
6078
6079 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6080 d.caret = 1;
6081 d.move_end(false);
6082 assert_eq!(
6083 d.caret, 13,
6084 "wysiwyg: the end of the row, soft break and all"
6085 );
6086 }
6087
6088 #[test]
6089 fn home_and_end_extend_the_selection_when_asked() {
6090 for (view, tag) in VIEWS {
6091 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6092 d.caret = 6;
6093 d.move_end(true);
6094 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6095 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6096 d.caret = 6;
6097 d.move_home(true);
6098 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6099 }
6100 }
6101
6102 // ── kill to the line's start / end ───────────────────────────────────────
6103
6104 #[test]
6105 fn kill_to_the_line_start_and_end_in_both_views() {
6106 for (view, tag) in VIEWS {
6107 // The gap that reads as a paragraph break in each view: the source
6108 // view's lines are the renderer's rows only where the source says so.
6109 let gap = if view == View::Source { "\n" } else { "\n\n" };
6110 let mut d = doc_in(
6111 view,
6112 &format!("kill_end_{tag}"),
6113 &format!("one two{gap}three\n"),
6114 );
6115 d.caret = 3;
6116 d.delete_to_line_end();
6117 assert_eq!(
6118 d.source,
6119 format!("one{gap}three\n"),
6120 "{tag}: ^K to the line's end"
6121 );
6122 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6123
6124 let mut d = doc_in(
6125 view,
6126 &format!("kill_start_{tag}"),
6127 &format!("one two{gap}three\n"),
6128 );
6129 d.caret = 7; // the end of the first line
6130 d.delete_to_line_start();
6131 assert_eq!(
6132 d.source,
6133 format!("{gap}three\n"),
6134 "{tag}: ⌘⌫ to the line's start"
6135 );
6136 assert_eq!(d.caret, 0, "{tag}");
6137 }
6138 }
6139
6140 #[test]
6141 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6142 // The decision: at the boundary both kills do nothing, rather than
6143 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6144 // at a soft wrap as often as at a newline, where there is nothing
6145 // written to delete — and a source newline is only half of the blank
6146 // line between two paragraphs, so taking it leaves a soft break rather
6147 // than the join it looks like. Backspace and Delete are the keys for it.
6148 for (view, tag) in VIEWS {
6149 let gap = if view == View::Source { "\n" } else { "\n\n" };
6150 let src = format!("one{gap}three\n");
6151 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6152 d.caret = 3; // the end of "one"
6153 d.delete_to_line_end();
6154 assert_eq!(
6155 d.source, src,
6156 "{tag}: ^K at the line's end joined it to the next"
6157 );
6158
6159 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6160 d.caret = 3 + gap.len(); // the start of "three"
6161 d.delete_to_line_start();
6162 assert_eq!(
6163 d.source, src,
6164 "{tag}: ⌘⌫ at the line's start joined it to the last"
6165 );
6166 }
6167 }
6168
6169 #[test]
6170 fn a_kill_takes_the_selection_when_there_is_one() {
6171 // What every other delete here does with one, so these two as well.
6172 for (view, tag) in VIEWS {
6173 for (name, kill) in [
6174 (
6175 "end",
6176 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6177 ),
6178 ("start", |d: &mut Doc| d.delete_to_line_start()),
6179 ] {
6180 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
6181 d.anchor = Some(4);
6182 d.caret = 7; // "two"
6183 kill(&mut d);
6184 assert_eq!(
6185 d.source, "one three\n",
6186 "{tag}: {name} ignored the selection"
6187 );
6188 assert_eq!(d.selection(), None, "{tag}: {name}");
6189 }
6190 }
6191 }
6192
6193 #[test]
6194 fn a_kill_takes_the_markup_it_empties_with_it() {
6195 // The same hazard a word-delete has: a WYSIWYG range covers what the
6196 // user can see, which for `**bold**` is the word and never the
6197 // delimiters, so a kill that stopped at the text would leave `a ****` —
6198 // markup wrapped around nothing.
6199 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
6200 d.caret = d.source.find("bold").unwrap();
6201 d.delete_to_line_end();
6202 assert_eq!(d.source, "a \n");
6203 }
6204
6205 #[test]
6206 fn a_kill_is_undone_in_one_step() {
6207 for (view, tag) in VIEWS {
6208 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6209 d.caret = 3;
6210 d.delete_to_line_end();
6211 assert_eq!(d.source, "one\n", "{tag}");
6212 d.undo();
6213 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6214 }
6215 }
6216
6217 #[test]
6218 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6219 // Regression: triple-click used move_home/move_end over visual rows, so
6220 // it only worked on a paragraph's first row (a wrap-boundary offset maps
6221 // to the earlier row). select_block_at reads the AST, so every offset in
6222 // the paragraph selects the whole thing.
6223 let body = "one two three four five six seven eight\n";
6224 let mut d = doc_with("sel_block", body);
6225 d.view = View::Wysiwyg;
6226 d.build_visual(12); // force the paragraph to wrap into several rows
6227 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6228 let para = (0, "one two three four five six seven eight".len());
6229 for off in [0usize, 8, 19, 28, 38] {
6230 d.caret = 0;
6231 d.anchor = None;
6232 d.select_block_at(off);
6233 assert_eq!(
6234 d.selection(),
6235 Some(para),
6236 "offset {off} should select the paragraph"
6237 );
6238 }
6239 }
6240
6241 #[test]
6242 fn select_block_uses_content_span_for_a_heading() {
6243 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6244 d.select_block_at(4); // inside "Title"
6245 // content_span excludes the "# " marker.
6246 assert_eq!(d.selected_text(), Some("Title"));
6247 d.select_block_at(10); // inside "body"
6248 assert_eq!(d.selected_text(), Some("body"));
6249 }
6250
6251 #[test]
6252 fn select_all_spans_the_document() {
6253 let mut d = doc_with("sel_all", "abc\n\ndef\n");
6254 d.select_all();
6255 assert_eq!(d.selection(), Some((0, d.source.len())));
6256 }
6257
6258 #[test]
6259 fn select_word_at_picks_the_surrounding_word() {
6260 let mut d = doc_with("sel_word", "hello world\n");
6261 d.select_word_at(8); // inside "world"
6262 assert_eq!(d.selection(), Some((6, 11)));
6263 // Double-clicking at end-of-word still grabs the word to its left.
6264 d.select_word_at(5); // the space between the words
6265 assert_eq!(d.selection(), Some((5, 6)));
6266 }
6267
6268 #[test]
6269 fn word_helpers_respect_utf8_boundaries() {
6270 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6271 assert_eq!(
6272 golden("utf8", "|café ok", |d| d.move_word_right(false)),
6273 "café| ok"
6274 );
6275 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6276 }
6277
6278 #[test]
6279 fn typing_inserts_at_the_caret_and_advances_it() {
6280 let mut d = doc_with("type", "hello\n");
6281 d.insert("Hi ");
6282 assert_eq!(d.source, "Hi hello\n");
6283 assert_eq!(d.caret, 3);
6284 assert!(d.dirty);
6285 }
6286
6287 #[test]
6288 fn backspace_deletes_the_char_before_the_caret() {
6289 let mut d = doc_with("bs", "hello\n");
6290 d.caret = 3; // after "hel"
6291 d.backspace();
6292 assert_eq!(d.source, "helo\n");
6293 assert_eq!(d.caret, 2);
6294 }
6295
6296 #[test]
6297 fn typing_replaces_the_selection() {
6298 let mut d = doc_with("replace", "a word b\n");
6299 d.anchor = Some(2);
6300 d.caret = 6; // "word" selected
6301 d.insert("X");
6302 assert_eq!(d.source, "a X b\n");
6303 assert_eq!(d.caret, 3);
6304 assert_eq!(d.anchor, None);
6305 }
6306
6307 #[test]
6308 fn toggle_bold_wraps_then_unwraps_the_selection() {
6309 let mut d = doc_with("bold", "a word b\n");
6310 d.anchor = Some(2);
6311 d.caret = 6;
6312 d.toggle(InlineKind::Strong);
6313 assert_eq!(d.source, "a **word** b\n");
6314 // The toggled region stays selected, so a second toggle reverses it.
6315 d.toggle(InlineKind::Strong);
6316 assert_eq!(d.source, "a word b\n");
6317 }
6318
6319 #[test]
6320 fn toggle_code_wraps_then_unwraps_the_selection() {
6321 let mut d = doc_with("code_rt", "a word b\n");
6322 d.anchor = Some(2);
6323 d.caret = 6;
6324 d.toggle(InlineKind::Verbatim);
6325 assert_eq!(d.source, "a `word` b\n");
6326 d.toggle(InlineKind::Verbatim);
6327 assert_eq!(d.source, "a word b\n");
6328 }
6329
6330 #[test]
6331 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6332 // ⌘b at a bare caret, then type: the text comes out bold with no
6333 // selection ever made — the word-processor "start bold here" gesture.
6334 let mut d = doc_with("sticky_wrap", "xy\n");
6335 d.caret = 1; // between x and y
6336 d.toggle(InlineKind::Strong);
6337 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6338 d.insert("A");
6339 assert_eq!(d.source, "x**A**y\n");
6340 }
6341
6342 #[test]
6343 fn sticky_bold_lights_the_toolbar_before_any_typing() {
6344 // The button must light the instant ⌘b is pressed, or the mode is
6345 // invisible until the first character lands.
6346 let mut d = doc_with("sticky_light", "xy\n");
6347 d.caret = 1;
6348 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6349 d.toggle(InlineKind::Strong);
6350 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6351 }
6352
6353 #[test]
6354 fn sticky_bold_toggled_off_types_normally_again() {
6355 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6356 // in the flow of typing, the exact sequence the user described.
6357 let mut d = doc_with("sticky_off", "\n");
6358 d.caret = 0;
6359 d.toggle(InlineKind::Strong);
6360 d.insert("a");
6361 d.insert("b"); // continues inside the run, no re-arming
6362 assert_eq!(d.source, "**ab**\n");
6363 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6364 d.insert("c");
6365 assert_eq!(d.source, "**ab**c\n");
6366 }
6367
6368 #[test]
6369 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6370 // Once a mark is realised the caret sits inside the run, so plain typing
6371 // extends it rather than starting a second, adjacent bold span.
6372 let mut d = doc_with("sticky_cont", "\n");
6373 d.caret = 0;
6374 d.toggle(InlineKind::Emph);
6375 d.insert("h");
6376 d.insert("i");
6377 assert_eq!(d.source, "*hi*\n");
6378 }
6379
6380 #[test]
6381 fn moving_the_caret_disarms_a_sticky_mark() {
6382 // Arming a mark and then moving away must not style text elsewhere.
6383 let mut d = doc_with("sticky_disarm", "xy\n");
6384 d.caret = 0;
6385 d.toggle(InlineKind::Strong);
6386 d.move_right(false); // caret 0 → 1, disarms
6387 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6388 d.insert("A");
6389 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6390 }
6391
6392 #[test]
6393 fn stacked_sticky_marks_apply_together() {
6394 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6395 let mut d = doc_with("sticky_stack", "\n");
6396 d.caret = 0;
6397 d.toggle(InlineKind::Strong);
6398 d.toggle(InlineKind::Emph);
6399 d.insert("x");
6400 // Land the caret on the styled character and confirm both marks are live.
6401 d.anchor = Some(d.source.find('x').unwrap());
6402 d.caret = d.anchor.unwrap() + 1;
6403 let marks = d.active_inline_marks();
6404 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6405 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6406 }
6407
6408 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6409
6410 #[test]
6411 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6412 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6413 // The space inside the run made `**bold **`, which is *not* bold — four
6414 // literal asterisks — so the rich view drew them, correctly and
6415 // uselessly, until the next character happened to close the run again.
6416 let mut d = wysiwyg_doc("edge_typing", "a \n");
6417 d.caret = 2;
6418 d.toggle(InlineKind::Strong);
6419 for c in "bold".chars() {
6420 d.insert(&c.to_string());
6421 }
6422 assert_eq!(d.source, "a **bold**\n");
6423 d.insert(" ");
6424 assert_eq!(
6425 d.source, "a **bold** \n",
6426 "the space belongs outside the run"
6427 );
6428 assert!(
6429 d.active_inline_marks().contains(InlineKind::Strong),
6430 "bold is still what's being typed, so the button stays lit"
6431 );
6432 // What the writer is looking at while all this happens: their words.
6433 d.build_visual(80);
6434 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6435 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6436 for c in "hey".chars() {
6437 d.insert(&c.to_string());
6438 }
6439 assert_eq!(
6440 d.source, "a **bold hey**\n",
6441 "one bold phrase, not two runs"
6442 );
6443 }
6444
6445 #[test]
6446 fn typing_past_a_space_can_still_leave_the_bold_behind() {
6447 // The other half: the marks stay armed across the space, so ⌘b turns
6448 // them off again there and the next word is plain — the run isn't
6449 // rejoined by a caret that was told not to.
6450 let mut d = wysiwyg_doc("edge_shed", "\n");
6451 d.caret = 0;
6452 d.toggle(InlineKind::Strong);
6453 for c in "bold ".chars() {
6454 d.insert(&c.to_string());
6455 }
6456 assert_eq!(d.source, "**bold** \n");
6457 d.toggle(InlineKind::Strong);
6458 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6459 d.insert("x");
6460 assert_eq!(d.source, "**bold** x\n");
6461 }
6462
6463 #[test]
6464 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
6465 // ⌘b and then a space before any word: the space is not marked (nothing
6466 // is), and the word after it is.
6467 let mut d = wysiwyg_doc("edge_space_first", "a\n");
6468 d.caret = 1;
6469 d.toggle(InlineKind::Strong);
6470 d.insert(" ");
6471 assert_eq!(d.source, "a \n");
6472 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6473 d.insert("b");
6474 assert_eq!(d.source, "a **b**\n");
6475 }
6476
6477 #[test]
6478 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
6479 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
6480 d.caret = 8; // the caret's home at the end of the run's text
6481 d.insert(" ");
6482 assert_eq!(
6483 d.source, "x **bold** \n",
6484 "the space lands past the delimiters"
6485 );
6486 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
6487
6488 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
6489 d.caret = 4; // in front of the "b"
6490 d.insert(" ");
6491 assert_eq!(d.source, "x **bold** y\n");
6492 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
6493 }
6494
6495 #[test]
6496 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
6497 // Backspace over the last letter of a bold phrase.
6498 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
6499 d.caret = 10; // past the "h"
6500 d.backspace();
6501 assert_eq!(d.source, "a **bold** \n");
6502 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
6503 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6504 d.insert("x");
6505 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
6506 }
6507
6508 #[test]
6509 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
6510 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
6511 // mark, which is only text. The marks live on in the caret instead.
6512 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
6513 d.caret = 5;
6514 d.backspace();
6515 assert_eq!(d.source, "a c\n");
6516 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6517 d.insert("x");
6518 assert_eq!(d.source, "a **x** c\n");
6519 }
6520
6521 #[test]
6522 fn typing_over_a_whole_bold_word_keeps_it_bold() {
6523 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
6524 d.anchor = Some(4);
6525 d.caret = 8; // the word, not its delimiters
6526 d.insert("x");
6527 assert_eq!(d.source, "a **x** c\n");
6528 }
6529
6530 #[test]
6531 fn a_code_span_keeps_the_space_it_is_given() {
6532 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
6533 // is still verbatim, so nothing is re-spelt. The repair asks the parser
6534 // rather than a table of kinds, and this is the answer it gets.
6535 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
6536 d.caret = 7;
6537 d.insert(" ");
6538 assert_eq!(d.source, "a `code ` c\n");
6539 }
6540
6541 #[test]
6542 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
6543 // A run's closing delimiter has a caret home on each side of it, one
6544 // column apart on screen — and a plain ← off the space after a bold word
6545 // lands on the outer one. The character drawn behind the caret there is
6546 // still the last letter of the phrase, so that is what Backspace takes;
6547 // the byte behind it is a `*` nobody can see.
6548 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
6549 d.caret = 9;
6550 d.move_left(false);
6551 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
6552 d.backspace();
6553 assert_eq!(
6554 d.source, "**bol** x\n",
6555 "a letter of the phrase, not its `*`"
6556 );
6557 assert_eq!(d.caret, 5);
6558
6559 // And the mirror in front of the opening delimiter, where Delete's
6560 // character is the first letter of the run.
6561 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
6562 d.caret = 1;
6563 d.delete_forward();
6564 assert_eq!(d.source, "x**old**\n");
6565 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
6566 }
6567
6568 #[test]
6569 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
6570 // The byte beside the caret at either edge of a bold word is a `*` the
6571 // rich view draws nothing for. Taking it is not the character delete the
6572 // key was pressed for — it unspells the run and puts a literal asterisk
6573 // on screen (`a *bold** c`). The visible character is the one that goes.
6574 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
6575 d.caret = 4; // in front of the "b"
6576 d.backspace();
6577 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
6578
6579 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
6580 d.caret = 8; // past the "d"
6581 d.delete_forward();
6582 assert_eq!(d.source, "a **bold**c\n");
6583 assert_eq!(d.caret, 8, "and the caret stays inside the run");
6584 d.insert("x");
6585 assert_eq!(d.source, "a **boldx**c\n");
6586
6587 // A code span's backticks are hidden the same way, so they are covered
6588 // by the same rule and not by a list of kinds.
6589 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
6590 d.caret = 3;
6591 d.backspace();
6592 assert_eq!(d.source, "a`code` c\n");
6593 }
6594
6595 #[test]
6596 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
6597 // The asterisks are on the screen there and the caret can stand between
6598 // them, so a delete takes exactly the byte it is aimed at.
6599 let mut d = doc_with("edge_open_src", "a **bold** c\n");
6600 d.caret = 4;
6601 d.backspace();
6602 assert_eq!(d.source, "a *bold** c\n");
6603
6604 let mut d = doc_with("edge_close_src", "a **bold** c\n");
6605 d.caret = 8;
6606 d.delete_forward();
6607 assert_eq!(d.source, "a **bold* c\n");
6608 }
6609
6610 #[test]
6611 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
6612 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
6613 // The space had stepped outside the run (the mark-edge rule), taking the
6614 // caret with it, so the delete put it back down on the far side of the
6615 // closing `**` — one place on screen, and the wrong side of it. Typing
6616 // came out plain and the toolbar went dark, with nothing to see.
6617 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
6618 d.caret = 0;
6619 d.toggle(InlineKind::Strong);
6620 for c in "bold".chars() {
6621 d.insert(&c.to_string());
6622 }
6623 d.insert(" ");
6624 assert_eq!(d.source, "**bold** \n");
6625 d.backspace();
6626 assert_eq!(
6627 d.source, "**bold**\n",
6628 "the space goes, the delimiters stay"
6629 );
6630 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
6631 assert!(
6632 d.active_inline_marks().contains(InlineKind::Strong),
6633 "so the button is still lit"
6634 );
6635 d.insert("x");
6636 assert_eq!(
6637 d.source, "**boldx**\n",
6638 "and the next character is still bold"
6639 );
6640 }
6641
6642 #[test]
6643 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
6644 // What the stranded caret did next: the byte behind it was the closing
6645 // `*`, so a second press took that instead of a letter — `**bold*`, the
6646 // styling gone and an asterisk on the screen where the word had been.
6647 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
6648 d.caret = 0;
6649 d.toggle(InlineKind::Strong);
6650 for c in "bold ".chars() {
6651 d.insert(&c.to_string());
6652 }
6653 assert_eq!(d.source, "**bold** \n");
6654 d.backspace();
6655 d.backspace();
6656 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
6657 assert_eq!(d.caret, 5);
6658 }
6659
6660 #[test]
6661 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
6662 // `***both***` closes two runs with one stack of asterisks: the caret has
6663 // to walk in through all of them, or it lands between the emph and the
6664 // strong and types half-marked.
6665 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
6666 d.caret = 11;
6667 d.backspace();
6668 assert_eq!(d.source, "***both***\n");
6669 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
6670 d.insert("x");
6671 assert_eq!(d.source, "***bothx***\n");
6672 }
6673
6674 #[test]
6675 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
6676 // The settle only moves a caret a run actually closed over. Ordinary
6677 // deletes — inside a run, or in plain prose — are untouched.
6678 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
6679 d.caret = 8;
6680 d.backspace();
6681 assert_eq!(d.source, "a **bol** c\n");
6682 assert_eq!(d.caret, 7);
6683
6684 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
6685 d.caret = 5;
6686 d.backspace();
6687 assert_eq!(d.source, "plai\n");
6688 assert_eq!(d.caret, 4);
6689 }
6690
6691 #[test]
6692 fn the_source_view_leaves_a_delete_where_it_landed() {
6693 // The delimiters are on the screen there, so the offset past them is a
6694 // place the caret can be seen to be — nothing to settle.
6695 let mut d = doc_with("edge_bksp_src", "**bold** \n");
6696 d.caret = 9;
6697 d.backspace();
6698 assert_eq!(d.source, "**bold**\n");
6699 assert_eq!(d.caret, 8);
6700 }
6701
6702 #[test]
6703 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
6704 // `***both***` closes two runs with one stack of asterisks; a space that
6705 // clears only the inner one lands against the outer's and breaks that
6706 // instead.
6707 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
6708 d.caret = 9;
6709 d.insert(" ");
6710 assert_eq!(d.source, "a ***both*** \n");
6711 assert_eq!(d.caret, 13);
6712 d.insert("x");
6713 assert_eq!(d.source, "a ***both x***\n");
6714 }
6715
6716 #[test]
6717 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
6718 // The delimiter shuffle is not an edit the writer made, so it is not a
6719 // step they have to undo past.
6720 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
6721 d.caret = 8;
6722 d.insert(" ");
6723 assert_eq!(d.source, "a **bold** \n");
6724 d.undo();
6725 assert_eq!(d.source, "a **bold**\n");
6726 }
6727
6728 #[test]
6729 fn the_source_view_types_the_space_where_it_was_asked_to() {
6730 // The rule is a rich-view courtesy. In the source view the delimiters are
6731 // on the screen and the user is editing the bytes they can see.
6732 let mut d = doc_with("edge_src", "a **bold** c\n");
6733 d.caret = 8;
6734 d.insert(" ");
6735 assert_eq!(d.source, "a **bold ** c\n");
6736 }
6737
6738 #[test]
6739 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
6740 // Double-clicking a word takes the space after it; bolding that must not
6741 // spell `**word **`, which is not bold at all.
6742 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
6743 d.anchor = Some(2);
6744 d.caret = 7; // "word "
6745 d.toggle(InlineKind::Strong);
6746 assert_eq!(d.source, "a **word** b\n");
6747 // And a selection of nothing but whitespace has no word to mark.
6748 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
6749 d.anchor = Some(6);
6750 d.caret = 7;
6751 d.toggle(InlineKind::Strong);
6752 assert_eq!(d.source, "a word b\n");
6753 assert!(d.status.is_some());
6754 }
6755
6756 #[test]
6757 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
6758 let mut d = doc_with("head_set", "hello\n");
6759 d.caret = 2; // caret inside the paragraph, no selection
6760 d.set_block(BlockKind::Heading(1));
6761 assert_eq!(d.source, "# hello\n");
6762 }
6763
6764 #[test]
6765 fn set_block_heading_works_in_wysiwyg_view() {
6766 // The app defaults to WYSIWYG; the caret is a source offset either way.
6767 let mut d = wysiwyg_doc("head_wys", "hello\n");
6768 d.caret = 2;
6769 d.set_block(BlockKind::Heading(1));
6770 assert_eq!(d.source, "# hello\n");
6771 }
6772
6773 #[test]
6774 fn toggle_heading_applies_switches_and_reverts() {
6775 let mut d = doc_with("head_toggle", "hello\n");
6776 d.caret = 2;
6777 d.toggle_heading(1);
6778 assert_eq!(d.source, "# hello\n"); // paragraph → H1
6779 d.toggle_heading(2);
6780 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
6781 d.toggle_heading(2);
6782 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
6783 }
6784
6785 #[test]
6786 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
6787 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
6788 // the blank line but the caret rendered on the *next* line, because the
6789 // separator was a non-navigable decoration row. In Preserve flow that
6790 // blank line is a real caret home — the caret must resolve onto it, and
6791 // typing there makes the soft break that continues the paragraph.
6792 let src = "line one:\nsecond line\n";
6793 let mut d = wysiwyg_doc("pre_enter_lineend", src);
6794 d.set_line_flow(LineFlow::Preserve);
6795 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
6796 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
6797 d.newline();
6798 d.build_visual_unwrapped();
6799 assert_eq!(d.source, "line one:\n\nsecond line\n");
6800 assert_eq!(
6801 d.caret, 10,
6802 "caret sits on the new blank line, not the next line"
6803 );
6804 // The blank line is row 1, and the caret resolves onto it — not row 2.
6805 assert_eq!(
6806 d.vmap.pos_of_offset(10),
6807 (1, 0),
6808 "caret renders on the blank row"
6809 );
6810 assert!(
6811 !d.vmap.rows[1].decoration,
6812 "the blank line is navigable in Preserve"
6813 );
6814 // Typing there makes a soft break: one paragraph, three lines.
6815 d.insert("new clause,");
6816 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
6817 }
6818
6819 #[test]
6820 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
6821 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
6822 // that keeps it one paragraph — where Fold would open a second paragraph.
6823 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
6824 d.set_line_flow(LineFlow::Preserve);
6825 d.caret = 3;
6826 d.newline();
6827 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
6828
6829 // End-of-paragraph: Enter then typing continues the same paragraph on a
6830 // new line (a soft break), not a fresh paragraph.
6831 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
6832 d.set_line_flow(LineFlow::Preserve);
6833 d.caret = 3;
6834 d.newline();
6835 d.insert("def");
6836 assert_eq!(
6837 d.source, "abc\ndef\n",
6838 "end-of-line Enter + typing is a soft break"
6839 );
6840 }
6841
6842 #[test]
6843 fn preserve_double_enter_still_makes_a_paragraph() {
6844 // Two Enters in a row promote to a real paragraph break: the second lands
6845 // on the blank line the first opened and takes the empty-line branch.
6846 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
6847 d.set_line_flow(LineFlow::Preserve);
6848 d.caret = 3;
6849 d.newline();
6850 d.newline();
6851 d.insert("def");
6852 assert_eq!(
6853 d.source, "abc\n\ndef\n",
6854 "double Enter is a paragraph break"
6855 );
6856 }
6857
6858 #[test]
6859 fn preserve_backspace_joins_across_a_soft_break() {
6860 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
6861 // soft break it deletes the single newline and joins the two lines.
6862 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
6863 d.set_line_flow(LineFlow::Preserve);
6864 d.build_visual(80);
6865 d.caret = 4; // start of "def", just past the soft break
6866 d.backspace();
6867 assert_eq!(
6868 d.source, "abcdef\n",
6869 "Backspace joins across the soft break"
6870 );
6871 assert_eq!(d.caret, 3, "caret lands where the lines meet");
6872 }
6873
6874 #[test]
6875 fn fold_enter_still_starts_a_new_paragraph() {
6876 // The default flow is unchanged: a lone `\n` would render as an invisible
6877 // space, so Enter keeps opening the paragraph break that actually shows.
6878 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
6879 d.caret = 3;
6880 d.newline();
6881 assert_eq!(
6882 d.source, "abc\n\ndef\n",
6883 "Fold mid-line Enter is a paragraph break"
6884 );
6885 }
6886
6887 #[test]
6888 fn wysiwyg_one_enter_starts_a_new_paragraph() {
6889 // Regression: one Enter left the caret between the two newlines, so typing
6890 // made a soft break (one paragraph) and you needed a second Enter.
6891 let mut d = wysiwyg_doc("wys_enter", "abc\n");
6892 d.caret = 3;
6893 d.newline();
6894 d.insert("def");
6895 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
6896 }
6897
6898 #[test]
6899 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
6900 // Regression: Enter at the caret's natural End-of-line resting place
6901 // after a bold run with nothing following it (on screen: right after
6902 // "bold", before the hidden closing "**") spliced the paragraph break
6903 // at that very byte offset — which sits *before* the closing "**" in
6904 // the source, since the delimiter is hidden and emits no glyph of its
6905 // own for `push_row`'s "end of row" fallback to count. That severed the
6906 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
6907 // "**" alone on the new line instead of leaving "**bold**" intact with
6908 // a fresh empty paragraph after it.
6909 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
6910 d.move_end(false); // the WYSIWYG End key, from caret 0
6911 assert_eq!(
6912 d.caret, 6,
6913 "caret rests right after \"bold\", before the hidden \"**\""
6914 );
6915 d.newline();
6916 assert!(
6917 d.source.starts_with("**bold**"),
6918 "the closing ** must stay attached to \"bold\": got {:?}",
6919 d.source
6920 );
6921 assert_eq!(
6922 d.source, "**bold**\n\n\n",
6923 "a fresh empty paragraph follows the still-intact bold run"
6924 );
6925 }
6926
6927 #[test]
6928 fn source_view_enter_is_a_single_newline() {
6929 let mut d = doc_with("src_enter", "abc\n");
6930 d.caret = 3;
6931 d.newline();
6932 assert_eq!(d.source, "abc\n\n");
6933 }
6934
6935 #[test]
6936 fn heading_applies_at_the_end_of_a_paragraph() {
6937 // The caret at a line end sits at the doc level; set_block must still find
6938 // the block on that line.
6939 let mut d = doc_with("head_end", "abc\n");
6940 d.caret = 3; // end of "abc"
6941 d.toggle_heading(1);
6942 assert_eq!(d.source, "# abc\n");
6943 }
6944
6945 #[test]
6946 fn heading_on_an_empty_new_paragraph_creates_one() {
6947 let mut d = wysiwyg_doc("head_empty", "abc\n");
6948 d.caret = 3;
6949 d.newline(); // caret now on a fresh, empty paragraph
6950 d.toggle_heading(1);
6951 d.insert("Title");
6952 assert!(d.source.contains("# Title"), "got {:?}", d.source);
6953 }
6954
6955 #[test]
6956 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
6957 // The reported bug, end to end: click a blank line with another one under
6958 // it, press H1, type. The text landed in the heading and the caret's
6959 // offset was right (the source view drew it there), but the rich view
6960 // drew it two rows lower, on the trailing blank line — the empty `# `
6961 // heading had left every row below it short by the marker's two bytes,
6962 // and the blank line ended up claiming the heading's own end offset.
6963 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
6964 d.build_visual_unwrapped();
6965 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
6966 d.toggle_heading(1);
6967 for c in "title".chars() {
6968 d.insert(&c.to_string());
6969 d.build_visual_unwrapped(); // as a frontend does, one frame per key
6970 }
6971 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
6972 assert_eq!(
6973 d.caret_pos(),
6974 (4, 5),
6975 "the caret draws at the end of the heading"
6976 );
6977 }
6978
6979 #[test]
6980 fn clicking_an_empty_heading_types_after_its_marker() {
6981 // The same anchor from the other side: the empty heading's row is its own
6982 // caret home, so a click on it must land past the hidden `# `. Landing in
6983 // front of the hashes made the first keystroke un-heading the line.
6984 let mut d = wysiwyg_doc("head_click", "# \n");
6985 d.build_visual_unwrapped();
6986 d.caret = d.vmap.offset_of_pos(0, 0);
6987 d.insert("x");
6988 assert_eq!(d.source, "# x\n");
6989 }
6990
6991 #[test]
6992 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
6993 let mut d = wysiwyg_doc("head_enter", "# Title\n");
6994 d.caret = 7; // end of the heading
6995 d.newline();
6996 d.insert("body");
6997 assert_eq!(d.source, "# Title\n\nbody\n");
6998 }
6999
7000 #[test]
7001 fn wysiwyg_enter_continues_a_bullet_list() {
7002 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7003 d.caret = 6; // end of "item"
7004 d.newline();
7005 d.insert("two");
7006 assert_eq!(d.source, "- item\n- two\n");
7007 }
7008
7009 #[test]
7010 fn wysiwyg_enter_increments_an_ordered_list() {
7011 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7012 d.caret = 6; // end of "one"
7013 d.newline();
7014 d.insert("two");
7015 assert_eq!(d.source, "1. one\n2. two\n");
7016 }
7017
7018 #[test]
7019 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7020 // Regression for the "extra newline" left between a list and the paragraph
7021 // below it. Enter, Enter leaves the list on a fresh empty paragraph
7022 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7023 // Backspace should then take the caret cleanly back to the end of the list
7024 // item, `- item\n\nnext`, not delete a single newline and strand it on the
7025 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7026 // no caret can land on. The map is rebuilt between keystrokes exactly as a
7027 // frontend does, since Backspace reads the stop table to place the delete.
7028 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7029 d.caret = 6; // end of "item"
7030 d.newline();
7031 d.build_visual(80);
7032 d.newline(); // leave the list onto a fresh empty paragraph
7033 d.build_visual(80);
7034 assert_eq!(
7035 d.source, "- item\n\n\n\nnext\n",
7036 "double-Enter opens the empty paragraph"
7037 );
7038 d.backspace();
7039 assert_eq!(
7040 d.source, "- item\n\nnext\n",
7041 "one Backspace collapses the whole gap"
7042 );
7043 assert_eq!(
7044 d.caret, 6,
7045 "and lands the caret back at the end of the list item"
7046 );
7047 }
7048
7049 #[test]
7050 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7051 // The stop-wise delete must not over-reach when there is no block boundary
7052 // to cross: two blank lines in a row are one caret stop apart, so pressing
7053 // Enter on an empty line and then Backspace removes exactly the one newline
7054 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7055 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7056 d.caret = 5; // the empty paragraph the first Enter already opened
7057 d.build_visual(80);
7058 d.newline();
7059 d.build_visual(80);
7060 assert_eq!(
7061 d.source, "abc\n\n\n\n",
7062 "Enter on the blank line adds one newline"
7063 );
7064 d.backspace();
7065 assert_eq!(
7066 d.source, "abc\n\n\n",
7067 "Backspace takes back exactly that one newline"
7068 );
7069 }
7070
7071 #[test]
7072 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7073 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7074 d.caret = 6; // end of the empty "- " item
7075 d.newline();
7076 d.insert("p");
7077 assert_eq!(d.source, "- a\n\np\n");
7078 }
7079
7080 #[test]
7081 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7082 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7083 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7084 // not take the list-exit path (which would splice the `- ` away as if
7085 // leaving an empty item); the AST guard sends it to a normal break and
7086 // leaves the underline intact.
7087 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7088 assert!(
7089 d.nodes().iter().any(|n| n.kind == Kind::Heading),
7090 "precondition: twig parses this as a heading, not a list",
7091 );
7092 d.caret = 7; // on the `- ` underline line
7093 d.newline();
7094 assert!(
7095 d.source.contains("- "),
7096 "the setext underline survives, not spliced away as a list item: {:?}",
7097 d.source,
7098 );
7099 }
7100
7101 #[test]
7102 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7103 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7104 d.caret = 7; // end of "abc" inside the fence
7105 d.newline();
7106 d.insert("def");
7107 assert_eq!(d.source, "```\nabc\ndef\n```\n");
7108 }
7109
7110 #[test]
7111 fn wysiwyg_enter_continues_a_block_quote() {
7112 // Enter opens a new *paragraph* inside the quote, not a second line of
7113 // the same one. `> quote\n> more` is a soft break, which under
7114 // `LineFlow::Fold` renders as a space — the keystroke would look like it
7115 // did nothing. The quoted blank line is what makes the break visible, and
7116 // it's the same thing Enter does in running prose.
7117 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7118 d.caret = 7; // end of "quote"
7119 d.newline();
7120 d.insert("more");
7121 assert_eq!(d.source, "> quote\n>\n> more\n");
7122 // Still one quote, now holding two paragraphs — not a quote and a stray
7123 // line that fell out of it.
7124 let quotes = d
7125 .nodes()
7126 .iter()
7127 .filter(|n| n.kind == Kind::BlockQuote)
7128 .count();
7129 assert_eq!(quotes, 1);
7130 }
7131
7132 #[test]
7133 fn set_block_makes_a_heading_at_the_caret() {
7134 let mut d = doc_with("head", "Title\n\nbody\n");
7135 d.caret = 0;
7136 d.set_block(BlockKind::Heading(2));
7137 assert_eq!(d.source, "## Title\n\nbody\n");
7138 d.set_block(BlockKind::Paragraph);
7139 assert_eq!(d.source, "Title\n\nbody\n");
7140 }
7141
7142 // ── block containers (quote / list) ──────────────────────────────────────
7143
7144 #[test]
7145 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7146 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7147 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7148 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7149 // A caret at a line end sits at the doc level; the block is still found.
7150 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7151 }
7152
7153 #[test]
7154 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7155 // Every source line of the paragraph gets its own `> `, so a caret left
7156 // on its old byte offset falls one prefix per line above it too far
7157 // back — inside the markup it just asked for rather than in its word.
7158 assert_eq!(
7159 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7160 "> aaa\n> b|bb\n> ccc\n"
7161 );
7162 }
7163
7164 #[test]
7165 fn toggle_blockquote_works_in_wysiwyg_view() {
7166 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7167 assert_eq!(
7168 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
7169 "> hel|lo\n"
7170 );
7171 assert_eq!(
7172 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
7173 "hel|lo\n"
7174 );
7175 }
7176
7177 #[test]
7178 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
7179 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
7180 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7181 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7182 // The *other* kind converts in place instead of nesting, which is what
7183 // makes the two buttons one three-state control.
7184 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7185 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7186 // Its own kind, over the only item the list holds, takes it off.
7187 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
7188 }
7189
7190 #[test]
7191 fn toggle_list_works_in_wysiwyg_view() {
7192 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7193 assert_eq!(
7194 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
7195 "1. hel|lo\n"
7196 );
7197 assert_eq!(
7198 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
7199 "- hel|lo\n"
7200 );
7201 assert_eq!(
7202 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
7203 "hel|lo\n"
7204 );
7205 }
7206
7207 #[test]
7208 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
7209 // The selection has to grow with the markup: twig takes a container off
7210 // only a range covering every block it holds, so the second press can
7211 // reverse the first only if the result is what's selected.
7212 let mut d = doc_with("list_sel", "abc\n\ndef\n");
7213 d.select_all();
7214 d.toggle_list(true);
7215 assert_eq!(d.source, "1. abc\n\n2. def\n");
7216 assert_eq!(d.selection(), Some((0, d.source.len())));
7217 d.toggle_list(true);
7218 assert_eq!(d.source, "abc\n\ndef\n");
7219 }
7220
7221 #[test]
7222 fn toggle_blockquote_nests_a_partly_covered_quote() {
7223 // twig's rule: covering only some of a container's blocks nests, because
7224 // taking the quote off would drag its uncovered siblings out with it.
7225 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
7226 d.caret = 2; // in the first quoted paragraph only
7227 d.toggle_blockquote();
7228 assert_eq!(d.source, "> > a\n>\n> b\n");
7229 }
7230
7231 #[test]
7232 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
7233 // A blank line used to be no block for twig to wrap —
7234 // `toggle_block_container` answered `NotFound` — so Quote and the list
7235 // buttons did nothing on the very line the H1 button works on, and leaf
7236 // lent twig a scratch paragraph to wrap and took it back out again.
7237 // twig 3.2.0 opens an empty container there itself, so what is left here
7238 // is where the caret lands: inside the marker that was just written.
7239 let mut d = doc_with("quote_blank", "\nabc\n");
7240 d.caret = 0;
7241 d.toggle_blockquote();
7242 assert_eq!(d.source, "> \nabc\n");
7243 assert_eq!(
7244 d.caret, 2,
7245 "the caret belongs inside the quote it just opened"
7246 );
7247 assert!(d.status.is_none(), "{:?}", d.status);
7248 assert!(d.dirty);
7249
7250 // And the paragraph below is still its own block: an empty container one
7251 // soft break from `abc` would take that paragraph into the quote with it.
7252 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
7253 d.caret = 0;
7254 d.toggle_blockquote();
7255 d.build_visual(80);
7256 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
7257
7258 // The same from the other side: a blank line directly under a paragraph
7259 // earns the blank line an empty block needs, rather than being read as a
7260 // soft break inside that paragraph.
7261 let mut d = doc_with("list_blank_below", "abc\n");
7262 d.caret = 4;
7263 d.toggle_list(false);
7264 assert_eq!(d.source, "abc\n\n- ");
7265 assert_eq!(d.caret, 7);
7266 }
7267
7268 #[test]
7269 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
7270 // The gesture the rendering fix is for. `newline` inside a quote already
7271 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
7272 // spelling — but the two marker lines it adds belonged to no node until
7273 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
7274 // just made drew as plain prose under the quote.
7275 let mut d = wysiwyg_doc("quote_enter", "> a\n");
7276 d.caret = 3; // past `a`, at the end of the quoted line
7277 d.newline();
7278 assert_eq!(d.source, "> a\n>\n> \n");
7279 d.build_visual(80);
7280 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
7281 // And the caret is on the new line, not stranded on the old one.
7282 assert_eq!(d.caret, 8);
7283 }
7284
7285 #[test]
7286 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
7287 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
7288 // now it is twig's single edit. Either way one ⌘z has to put the blank
7289 // line back rather than undoing into a half-built document.
7290 for open in [
7291 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7292 &|d: &mut Doc| d.toggle_list(false),
7293 &|d: &mut Doc| d.toggle_list(true),
7294 ] {
7295 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7296 d.caret = 3;
7297 open(&mut d);
7298 assert_ne!(d.source, "a\n\n\n\nb\n");
7299 d.undo();
7300 assert_eq!(d.source, "a\n\n\n\nb\n");
7301 }
7302 }
7303
7304 #[test]
7305 fn a_container_toggle_is_one_undo_step() {
7306 let mut d = doc_with("quote_undo", "hello\n");
7307 d.caret = 3;
7308 d.insert("X"); // a typing run the structural edit must not fold into
7309 d.toggle_blockquote();
7310 assert_eq!(d.source, "> helXlo\n");
7311 d.undo();
7312 assert_eq!(d.source, "helXlo\n");
7313 }
7314
7315 // ── links ────────────────────────────────────────────────────────────────
7316
7317 #[test]
7318 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7319 let mut d = doc_with("link_sel", "word here\n");
7320 d.anchor = Some(0);
7321 d.caret = 4;
7322 d.insert_link("http://x.dev");
7323 assert_eq!(d.source, "[word](http://x.dev) here\n");
7324 // The text, not the destination — so a second press re-points the link
7325 // the first one made rather than nesting one inside it.
7326 assert_eq!(d.selected_text(), Some("word"));
7327 d.insert_link("http://y.dev");
7328 assert_eq!(d.source, "[word](http://y.dev) here\n");
7329 assert_eq!(d.selected_text(), Some("word"));
7330 }
7331
7332 #[test]
7333 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7334 let mut d = doc_with("img_caret", "before after\n");
7335 d.caret = 7; // between "before " and "after"
7336 d.insert_image("cat.png", "a cat");
7337 assert_eq!(d.source, "before after\n");
7338 // The caret sits just past the inserted image, nothing selected.
7339 assert_eq!(d.selection(), None);
7340 assert_eq!(d.caret, 7 + "".len());
7341 }
7342
7343 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7344 /// destination at the first space, so the `format!` this used to be wrote
7345 /// something that was not an image at all — and the reader saw the markup as
7346 /// text. twig owns the spelling now, and moves it into the angle form.
7347 #[test]
7348 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7349 let mut d = doc_with("img_space", "x\n");
7350 d.caret = 0;
7351 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7352 assert_eq!(
7353 d.source,
7354 "x\n"
7355 );
7356 // And it reads back as an image pointing at the unescaped path — the angle
7357 // brackets are spelling, not part of the destination.
7358 d.caret = 2;
7359 assert_eq!(
7360 d.image_destination_at_caret(),
7361 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7362 );
7363 }
7364
7365 /// A `)` in a caption or a filename must not close the image early.
7366 #[test]
7367 fn insert_image_escapes_a_paren_in_either_half() {
7368 let mut d = doc_with("img_paren", "x\n");
7369 d.caret = 0;
7370 d.insert_image("a)b.png", "");
7371 assert_eq!(d.source, "b.png)x\n");
7372 d.caret = 2;
7373 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7374 }
7375
7376 #[test]
7377 fn insert_image_uses_the_selection_as_alt_text() {
7378 let mut d = doc_with("img_sel", "caption here\n");
7379 d.anchor = Some(0);
7380 d.caret = 7; // "caption"
7381 d.insert_image("p.png", "ignored fallback");
7382 assert_eq!(d.source, " here\n");
7383 }
7384
7385 #[test]
7386 fn insert_image_with_no_alt_leaves_empty_brackets() {
7387 let mut d = doc_with("img_noalt", "\n");
7388 d.caret = 0;
7389 d.insert_image("logo.svg", "");
7390 assert_eq!(d.source, "\n");
7391 }
7392
7393 #[test]
7394 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7395 // The round trip is the point: it's no use writing markup the reader
7396 // can't pick up again. This is the pair that only holds from twig 2.5.1
7397 // on — before it, the one-line form went in fine and came back as a
7398 // paragraph of raw tags, publishing no media at all.
7399 let mut d = doc_with("vid_rt", "\n");
7400 d.caret = 0;
7401 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7402 assert_eq!(
7403 d.source,
7404 "<video src=\"clip.mp4\" controls>a clip</video>\n"
7405 );
7406
7407 d.build_visual(80);
7408 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7409 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7410 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7411 assert_eq!(d.vmap.media[0].alt, "a clip");
7412 }
7413
7414 #[test]
7415 fn insert_media_spells_audio_with_its_own_tag() {
7416 let mut d = doc_with("aud_rt", "\n");
7417 d.caret = 0;
7418 d.insert_media(MediaKind::Audio, "take.mp3", "");
7419 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7420 d.build_visual(80);
7421 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7422 }
7423
7424 #[test]
7425 fn insert_media_uses_the_selection_as_fallback_text() {
7426 // The same courtesy `insert_image` does with alt: select a caption,
7427 // insert, and the caption labels the thing rather than being replaced.
7428 let mut d = doc_with("vid_sel", "the talk here\n");
7429 d.anchor = Some(0);
7430 d.caret = 8; // "the talk"
7431 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7432 assert_eq!(
7433 d.source,
7434 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
7435 );
7436 }
7437
7438 #[test]
7439 fn insert_media_with_an_image_kind_is_just_insert_image() {
7440 let mut d = doc_with("img_via_media", "\n");
7441 d.caret = 0;
7442 d.insert_media(MediaKind::Image, "logo.svg", "x");
7443 assert_eq!(d.source, "\n");
7444 }
7445
7446 // ── thematic breaks ─────────────────────────────────────────────────────
7447
7448 /// The node the source parses as at `caret` — what confirms an inserted
7449 /// `---` actually reads back as a rule, not stray text or a setext heading.
7450 ///
7451 /// The *narrowest* node covering the offset. Every ancestor covers it too,
7452 /// and since twig 2.8 that includes the `doc` root, which now carries a real
7453 /// span (it reported none before, so taking the first match used to land on
7454 /// the block by luck and now always answers `"doc"`).
7455 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
7456 d.nodes()
7457 .into_iter()
7458 .filter(|n| n.span.start <= caret && caret < n.span.end)
7459 .min_by_key(|n| n.span.end - n.span.start)
7460 .map(|n| n.kind)
7461 }
7462
7463 #[test]
7464 fn a_task_box_toggles_at_the_caret_and_reads_back() {
7465 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
7466 d.caret = 8; // inside "todo"
7467 assert_eq!(d.task_checked_at_caret(), Some(false));
7468 d.toggle_task_checked();
7469 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
7470 assert_eq!(d.task_checked_at_caret(), Some(true));
7471 d.toggle_task_checked();
7472 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
7473 }
7474
7475 #[test]
7476 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
7477 // The whole reason `toggle_task_at` exists apart from the caret form:
7478 // ticking a box elsewhere must not move the cursor out of what's being
7479 // typed.
7480 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
7481 d.caret = 8; // inside "first"
7482 let second = d.source.find("second").unwrap();
7483 d.toggle_task_at(second);
7484 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
7485 assert_eq!(d.caret, 8, "the caret stayed in the first item");
7486 }
7487
7488 #[test]
7489 fn a_plain_item_gains_and_loses_a_box() {
7490 let mut d = doc_with("task_mint", "- plain\n");
7491 d.caret = 4;
7492 assert_eq!(d.task_checked_at_caret(), None);
7493 d.toggle_task_item();
7494 assert_eq!(d.source, "- [ ] plain\n");
7495 assert_eq!(
7496 d.task_checked_at_caret(),
7497 Some(false),
7498 "a new box arrives unticked"
7499 );
7500 d.toggle_task_item();
7501 assert_eq!(d.source, "- plain\n");
7502 }
7503
7504 #[test]
7505 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
7506 // `set checked` must not silently convert a bullet into a task — that is
7507 // `toggle_task_item`'s job, and twig refuses it here.
7508 let mut d = doc_with("task_none", "- plain\n");
7509 d.caret = 4;
7510 d.toggle_task_checked();
7511 assert_eq!(d.source, "- plain\n", "nothing written");
7512 assert!(
7513 d.status.is_some(),
7514 "the refusal should reach the status line"
7515 );
7516 }
7517
7518 #[test]
7519 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
7520 let mut d = doc_with("task_quote", "> - [ ] nested\n");
7521 d.caret = d.source.find("nested").unwrap();
7522 assert_eq!(d.task_checked_at_caret(), Some(false));
7523 d.toggle_task_checked();
7524 assert_eq!(d.source, "> - [x] nested\n");
7525 }
7526
7527 #[test]
7528 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
7529 // A rule is a block, so twig's `insert_thematic_break` alone lands it
7530 // after the whole paragraph. `split_block` parts the paragraph first and
7531 // the rule is aimed at the *first* half, which is what a rule button is
7532 // understood to do — and what leaf spelled by hand until twig grew both
7533 // halves of the gesture.
7534 let mut d = doc_with("hr_mid", "before after\n");
7535 d.caret = 7; // between "before " and "after"
7536 d.insert_thematic_break();
7537 assert_eq!(d.source, "before \n\n---\n\nafter\n");
7538 assert_eq!(d.selection(), None);
7539 assert_eq!(
7540 kind_at(&mut d, "before \n\n".len()),
7541 Some(Kind::ThematicBreak)
7542 );
7543 }
7544
7545 #[test]
7546 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
7547 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
7548 // and leaf wrote the first into both until twig started spelling it.
7549 let mut md = doc_with("hr_md", "para\n");
7550 md.caret = 2;
7551 md.insert_thematic_break();
7552 assert_eq!(md.source, "pa\n\n---\n\nra\n");
7553
7554 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
7555 dj.caret = 2;
7556 dj.insert_thematic_break();
7557 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
7558 }
7559
7560 #[test]
7561 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
7562 // The same bytes are two documents. In Markdown ` - b` is a nested item
7563 // and the next one belongs beside it, at its indent. In Djot a list
7564 // marker can't interrupt a paragraph, so those bytes are literal text in
7565 // item `a` and there is only one item — writing ` - ` under it would add
7566 // no item at all, just more text, and the new sibling has to go to
7567 // column zero. Both spellings come out of the *enclosing item's* line.
7568 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
7569 md.caret = "- a\n - b".len();
7570 md.newline();
7571 assert_eq!(md.source, "- a\n - b\n - \n");
7572 assert_eq!(list_items(&mut md), 3);
7573
7574 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
7575 dj.view = View::Wysiwyg;
7576 dj.build_visual(80);
7577 dj.caret = "- a\n - b".len();
7578 dj.newline();
7579 assert_eq!(dj.source, "- a\n - b\n- \n");
7580 assert_eq!(list_items(&mut dj), 2);
7581
7582 // Where Djot's nesting is real — opened by a blank line — the indent is
7583 // reproduced there too, and the two formats agree again.
7584 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
7585 dj.view = View::Wysiwyg;
7586 dj.build_visual(80);
7587 dj.caret = "- a\n\n - b".len();
7588 dj.newline();
7589 assert_eq!(dj.source, "- a\n\n - b\n - \n");
7590 assert_eq!(list_items(&mut dj), 3);
7591 }
7592
7593 #[test]
7594 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
7595 // Tab replaces the line's whole prefix with the one twig spells, so the
7596 // quote markers, the parent's indent and an ordered marker's extra
7597 // column are all its answer rather than leaf's arithmetic.
7598 for (name, body, caret, want) in [
7599 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
7600 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
7601 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
7602 // A checkbox is markup the item's own text wraps past, but a nested
7603 // list may only open at the *list* marker's column — four in from
7604 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
7605 // parses as one item, not two.
7606 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
7607 (
7608 "quoted task",
7609 "> - [ ] a\n> - [ ] b\n",
7610 18,
7611 "> - [ ] a\n> - [ ] b\n",
7612 ),
7613 ] {
7614 let mut doc = wysiwyg_doc(name, body);
7615 doc.caret = caret;
7616 doc.indent();
7617 assert_eq!(doc.source, want, "{name}");
7618 // The nesting is real, not just indented text.
7619 assert_eq!(list_items(&mut doc), 2, "{name}");
7620 }
7621 }
7622
7623 #[test]
7624 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
7625 // The same bytes, the two formats disagreeing, and a gesture that used
7626 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
7627 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
7628 // so those bytes are literal text inside item `a` — there is nothing to
7629 // outdent, and treating them as a marker turned one item into two, a
7630 // structural edit from a keystroke that should delete one character.
7631 //
7632 // twig's `line_prefix` is what tells them apart: it reports the marker
7633 // on the Markdown line and nothing on the Djot one, which is a
7634 // continuation. No byte scan can reach that answer.
7635 let src = "- a\n - b\n";
7636 let at = "- a\n - ".len();
7637
7638 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
7639 md.view = View::Wysiwyg;
7640 md.build_visual(80);
7641 md.caret = at;
7642 md.backspace();
7643 assert_eq!(md.source, "- a\n- b\n");
7644 assert_eq!(list_items(&mut md), 2);
7645
7646 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
7647 dj.view = View::Wysiwyg;
7648 dj.build_visual(80);
7649 dj.caret = at;
7650 dj.backspace();
7651 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
7652 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
7653 }
7654
7655 #[test]
7656 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
7657 // Leaf used to spell the next item from the marker bytes it scanned, and
7658 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
7659 // and dropped out of the checklist. twig reproduces the whole
7660 // continuation, and a fresh item is always unticked however the one above
7661 // it stands.
7662 for (name, body, want) in [
7663 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
7664 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
7665 ] {
7666 let mut doc = wysiwyg_doc(name, body);
7667 doc.caret = body.trim_end_matches('\n').len();
7668 doc.newline();
7669 assert_eq!(doc.source, want, "{name}");
7670 // Both items are checklist items — the new one is a box, not the
7671 // plain bullet the old marker scan left behind — and it is unticked
7672 // whichever way the one above it faces.
7673 let boxes: Vec<Option<bool>> = doc
7674 .nodes()
7675 .iter()
7676 .filter(|n| n.kind == Kind::TaskListItem)
7677 .map(|n| n.checked)
7678 .collect();
7679 assert_eq!(boxes.len(), 2, "{name}");
7680 assert_eq!(boxes[1], Some(false), "{name}");
7681 }
7682 }
7683
7684 #[test]
7685 fn a_split_takes_the_space_the_caret_was_in_front_of() {
7686 // Splicing a break at the caret strands the space the words were parted
7687 // at on the head of the second block, where it reads as an indent nobody
7688 // typed. twig's split consumes it.
7689 for (name, body, caret, want) in [
7690 ("para", "one two\n", 3, "one\n\ntwo\n"),
7691 ("item", "- one two\n", 5, "- one\n- two\n"),
7692 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
7693 // A heading takes leaf's own path, which has to match.
7694 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
7695 ] {
7696 let mut doc = wysiwyg_doc(name, body);
7697 doc.caret = caret;
7698 doc.newline();
7699 assert_eq!(doc.source, want, "{name}");
7700 }
7701 }
7702
7703 #[test]
7704 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
7705 // The one place leaf keeps its own break: `split_block` repeats the `#`,
7706 // and Enter after a title is how the body under it is asked for.
7707 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
7708 doc.caret = "# Title".len();
7709 doc.newline();
7710 doc.insert("body");
7711 assert_eq!(doc.source, "# Title\n\nbody\n");
7712 assert_eq!(
7713 doc.nodes()
7714 .iter()
7715 .filter(|n| n.kind == Kind::Heading)
7716 .count(),
7717 1
7718 );
7719 }
7720
7721 #[test]
7722 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
7723 // A quoted item's marker doesn't open its line, so a scan that starts at
7724 // column zero finds a `>` where it wanted a bullet, calls the line "not a
7725 // list" and hands Enter to the plain-quote branch — which writes `> ` and
7726 // drops the list. The next item has to carry the whole prefix.
7727 for (name, body, want) in [
7728 ("flat", "> - a\n", "> - a\n> - \n"),
7729 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
7730 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
7731 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
7732 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
7733 ] {
7734 let mut doc = wysiwyg_doc(name, body);
7735 doc.caret = body.trim_end_matches('\n').len();
7736 doc.newline();
7737 assert_eq!(doc.source, want, "{name}");
7738 // The marker isn't just spelled right, it parses as an item.
7739 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
7740 }
7741 }
7742
7743 #[test]
7744 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
7745 // Double-Enter exits the list. Unquoted that means a blank line, but a
7746 // *bare* blank line would end the quote too and drop the caret out of it,
7747 // so the separator keeps its `>` and the caret's line keeps its `> `.
7748 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
7749 doc.caret = "> - a\n> - ".len();
7750 doc.newline();
7751 assert_eq!(doc.source, "> - a\n>\n> \n");
7752 assert_eq!(list_items(&mut doc), 1);
7753 // What "still in the quote" means for the next keystroke: the caret sits
7754 // behind the prefix, and what's typed there lands inside the quote as a
7755 // paragraph of its own — not as more of item `a`.
7756 doc.insert("x");
7757 assert_eq!(doc.source, "> - a\n>\n> x\n");
7758 assert!(
7759 doc.editor
7760 .ancestors_at(doc.caret - 1)
7761 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
7762 );
7763 }
7764
7765 #[test]
7766 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
7767 // The marker is hidden block markup, so Backspace over it is structural —
7768 // but only the marker is the list's. Splicing from the line start would
7769 // take the `>` with it and silently unquote the line.
7770 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
7771 doc.caret = "> - ".len();
7772 doc.backspace();
7773 assert_eq!(doc.source, "> a\n");
7774 assert_eq!(list_items(&mut doc), 0);
7775
7776 // A nested one outdents instead, moving the bullet within the quote
7777 // rather than moving the quote.
7778 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
7779 doc.caret = "> - a\n> - ".len();
7780 doc.backspace();
7781 assert_eq!(doc.source, "> - a\n> - b\n");
7782 assert_eq!(list_items(&mut doc), 2);
7783 }
7784
7785 #[test]
7786 fn only_a_bare_paragraph_is_parted_around_the_caret() {
7787 // The split is deliberately narrow. Parting a fenced block would leave
7788 // two fences with a rule between them, and parting a list item would
7789 // mint an item nobody asked for on the way to a rule that lands after
7790 // the list either way — so both keep the whole block intact and take the
7791 // rule after it. A caret in a quote is likewise left alone.
7792 for (name, body, caret, want) in [
7793 (
7794 "code",
7795 "```\nfn x() {}\n```\n",
7796 8,
7797 "```\nfn x() {}\n```\n\n---\n",
7798 ),
7799 ("list", "- one two\n", 6, "- one two\n\n---\n"),
7800 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
7801 ] {
7802 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
7803 d.caret = caret;
7804 d.insert_thematic_break();
7805 assert_eq!(d.source, want, "{name}: the block should stay whole");
7806 }
7807 }
7808
7809 #[test]
7810 fn insert_thematic_break_replaces_the_selection() {
7811 // Now that the rule lands *at* the caret again, replacing the selection
7812 // is coherent once more: the text goes, and the rule takes its place.
7813 // The space the deletion left leading the second half is consumed by the
7814 // split rather than opening the new paragraph with it.
7815 let mut d = doc_with("hr_sel", "one two three\n");
7816 d.anchor = Some(4);
7817 d.caret = 7; // "two"
7818 d.insert_thematic_break();
7819 assert_eq!(d.source, "one \n\n---\n\nthree\n");
7820 assert_eq!(d.selection(), None);
7821 }
7822
7823 #[test]
7824 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
7825 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
7826 // because writing `---` into one is code, not a rule — twig now walks out
7827 // to the block that owns the caret's line, so there is nothing to refuse.
7828 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
7829 code.caret = 5; // inside the fenced code
7830 code.insert_thematic_break();
7831 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
7832 assert_eq!(code.status, None, "no refusal to report any more");
7833
7834 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
7835 table.caret = 3; // in the header row
7836 table.insert_thematic_break();
7837 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
7838 }
7839
7840 #[test]
7841 fn insert_thematic_break_in_a_list_item_ends_the_list() {
7842 // The un-indented rule cannot continue the list, so it closes the list
7843 // and lands at the top level rather than nested inside it.
7844 let mut d = doc_with("hr_list", "- one\n- two\n");
7845 d.caret = "- one\n- tw".len(); // mid "two"
7846 d.insert_thematic_break();
7847 d.build_visual(80);
7848 let rule_at = d.source.find("---").unwrap();
7849 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
7850 assert!(
7851 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
7852 && n.span.start <= rule_at
7853 && rule_at < n.span.end),
7854 "the rule must not be nested inside the list"
7855 );
7856 }
7857
7858 #[test]
7859 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
7860 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
7861 // which is the document the gesture was actually asked for.
7862 let mut d = doc_with("hr_quote", "> hello\n");
7863 d.caret = 4; // inside the quoted text
7864 d.insert_thematic_break();
7865 assert_eq!(d.source, "> hello\n>\n> ---\n");
7866 d.build_visual(80);
7867 let rule_at = d.source.find("---").unwrap();
7868 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
7869 assert!(
7870 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
7871 && n.span.start <= rule_at
7872 && rule_at < n.span.end),
7873 "the rule belongs to the quote it was asked for"
7874 );
7875 }
7876
7877 // ── typing against a block picture ────────────────────────────────────────
7878
7879 /// A rendered-view document with the caret parked on one of the picture's two
7880 /// stops, and the map already built — the state a frontend is in between
7881 /// drawing a frame and the next keystroke.
7882 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
7883 let mut d = doc_in(View::Wysiwyg, name, src);
7884 d.build_visual_unwrapped();
7885 let start = src.find("".len(),
7889 };
7890 d
7891 }
7892
7893 /// The block media the map publishes, after rebuilding it — "is this still a
7894 /// picture, or has it become a line of text with an image in it?"
7895 fn media_count(d: &mut Doc) -> usize {
7896 d.build_visual_unwrapped();
7897 d.vmap.media.len()
7898 }
7899
7900 #[test]
7901 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
7902 // The accident this prevents: tap the blank page under a photo (which
7903 // lands on the picture's trailing stop), type, and `xy` is a
7904 // paragraph with an *inline* image — the photo stops being drawn.
7905 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
7906 d.insert("xy");
7907 assert_eq!(d.source, "hi\n\n\n\nxy\n");
7908 assert_eq!(media_count(&mut d), 1, "still a picture");
7909 }
7910
7911 #[test]
7912 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
7913 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
7914 d.insert("xy");
7915 assert_eq!(d.source, "hi\n\nxy\n\n\n");
7916 assert_eq!(media_count(&mut d), 1);
7917 }
7918
7919 #[test]
7920 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
7921 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
7922 d.insert("x");
7923 assert_eq!(d.source, "x\n\n\n");
7924 assert_eq!(media_count(&mut d), 1);
7925 }
7926
7927 #[test]
7928 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
7929 // The opened paragraph is part of the keystroke, not an edit the writer
7930 // made — so it undoes with the character, not a step later.
7931 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
7932 d.insert("x");
7933 assert_eq!(d.source, "hi\n\n\n\nx\n");
7934 d.undo();
7935 assert_eq!(d.source, "hi\n\n\n");
7936 }
7937
7938 #[test]
7939 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
7940 // ⌘V dissolves the picture exactly as a keystroke does.
7941 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
7942 d.paste("pasted");
7943 assert_eq!(d.source, "hi\n\n\n\npasted\n");
7944 assert_eq!(media_count(&mut d), 1);
7945 }
7946
7947 #[test]
7948 fn typing_beside_an_inline_image_is_ordinary_editing() {
7949 // An inline image has no placeholder row and no stops of its own. Opening
7950 // a paragraph mid-sentence would be the bug, not the fix.
7951 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
7952 d.build_visual_unwrapped();
7953 d.caret = "see ".len();
7954 d.insert("!");
7955 assert_eq!(d.source, "see ! here\n");
7956 }
7957
7958 #[test]
7959 fn source_view_types_raw_markup_against_an_image_untouched() {
7960 // Source view is for writing the markup itself; a break inserted behind
7961 // the writer's back there would be the editor arguing with them.
7962 let mut d = doc_in(View::Source, "pic_src", "\n");
7963 d.caret = "".len();
7964 d.insert("x");
7965 assert_eq!(d.source, "x\n");
7966 }
7967
7968 #[test]
7969 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
7970 // A selection is replaced, not joined into, so there is nothing to
7971 // protect: the range takes the picture with it.
7972 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
7973 d.anchor = Some(d.caret);
7974 d.caret = d.source.find("".len();
7975 d.insert("x");
7976 assert_eq!(d.source, "hi\n\nx\n");
7977 }
7978
7979 #[test]
7980 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
7981 // What this actually cost: a real vault's photo, to one stray Backspace.
7982 // The caret past `` was deleting the closing paren — invisible
7983 // in the rendered view — and the photo became the text `\n", MediaStop::After);
7985 d.backspace();
7986 assert_eq!(d.source, "hi\n");
7987 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
7988 d.undo();
7989 assert_eq!(
7990 d.source, "hi\n\n\n",
7991 "and comes back in one piece"
7992 );
7993 }
7994
7995 #[test]
7996 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
7997 // Deleting the break here would join the picture to the paragraph above,
7998 // where it is an *inline* image and stops being drawn. Step over the
7999 // boundary; the next press deletes in the paragraph the caret reached.
8000 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
8001 d.backspace();
8002 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
8003 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8004 d.backspace();
8005 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
8006 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8007 }
8008
8009 #[test]
8010 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8011 // The mirror. A byte-step here eats the `!` and leaves a link.
8012 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
8013 d.delete_forward();
8014 assert_eq!(d.source, "hi\n\nbye\n");
8015 assert_eq!(media_count(&mut d), 0);
8016 }
8017
8018 #[test]
8019 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8020 let mut d = doc_at_picture(
8021 "pic_del_after",
8022 "hi\n\n\n\nbye\n",
8023 MediaStop::After,
8024 );
8025 d.delete_forward();
8026 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
8027 assert_eq!(
8028 d.caret,
8029 d.source.find("bye").unwrap(),
8030 "the caret stepped down to `bye`"
8031 );
8032 }
8033
8034 #[test]
8035 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8036 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
8037 d.backspace();
8038 assert_eq!(d.source, "\n");
8039 assert_eq!(media_count(&mut d), 0);
8040 }
8041
8042 #[test]
8043 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8044 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8045 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
8046 d.delete_word_back();
8047 assert_eq!(d.source, "hi there\n");
8048
8049 // And in front of one it runs *through* the paragraph break into the
8050 // prose above, which merges the picture inline — so it steps out first,
8051 // and the second press deletes the word it was aimed at.
8052 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
8053 d.delete_word_back();
8054 assert_eq!(d.source, "hi there\n\n\n");
8055 d.delete_word_back();
8056 assert_eq!(
8057 d.source, "hi \n\n\n",
8058 "the word above went, the picture stayed"
8059 );
8060 assert_eq!(media_count(&mut d), 1);
8061 }
8062
8063 #[test]
8064 fn source_view_deletes_raw_markup_against_an_image_untouched() {
8065 let mut d = doc_in(View::Source, "pic_src_del", "\n");
8066 d.caret = "".len();
8067 d.backspace();
8068 assert_eq!(d.source, ";
8069 }
8070
8071 #[test]
8072 fn image_destination_at_caret_reads_the_image_under_the_caret() {
8073 let mut d = doc_with("img_read", "\n");
8074 d.caret = 3; // inside the image markup
8075 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8076 // Past the image, the caret is in no image.
8077 d.caret = "".len();
8078 assert_eq!(d.image_destination_at_caret(), None);
8079 }
8080
8081 #[test]
8082 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8083 // The image is one placeholder row by default, and `set_media_rows` grows
8084 // it to the height the frontend measured: the label row plus blank
8085 // `decoration` fillers that hold the vertical space a raster is drawn into.
8086 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
8087 assert_eq!(d.vmap.media.len(), 1);
8088 let img_row = d.vmap.media[0].rows_span.start;
8089 assert_eq!(
8090 d.vmap.media[0].rows_span,
8091 img_row..img_row + 1,
8092 "default is one row"
8093 );
8094
8095 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8096 d.build_visual(80);
8097 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8098 let span = d.vmap.media[0].rows_span.clone();
8099 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8100 // The label row carries the mark and its glyphs; the three below are blank
8101 // decoration — drawn, but no caret and no text.
8102 assert!(
8103 d.vmap.rows[span.start].media.is_some(),
8104 "mark rides the first row"
8105 );
8106 for r in (span.start + 1)..span.end {
8107 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8108 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8109 assert!(
8110 d.vmap.rows[r].media.is_none(),
8111 "only the first row is marked"
8112 );
8113 }
8114 }
8115
8116 #[test]
8117 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8118 // The extra rows are pure spacers: the caret's only homes stay the stop in
8119 // front of the image and the one just past it, so walking the document top
8120 // to bottom visits the same offsets whether the image is 1 row or 5.
8121 let body = "ab\n\n\n\ncd\n";
8122 let stops_at = |rows: usize| -> Vec<usize> {
8123 let mut d = wysiwyg_doc("img_stops", body);
8124 if rows > 1 {
8125 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8126 d.build_visual(80);
8127 }
8128 d.caret = 0;
8129 let mut seen = vec![d.caret];
8130 loop {
8131 d.move_right(false);
8132 if *seen.last().unwrap() == d.caret {
8133 break;
8134 }
8135 seen.push(d.caret);
8136 }
8137 seen
8138 };
8139 assert_eq!(
8140 stops_at(1),
8141 stops_at(5),
8142 "reserving rows must not add stops"
8143 );
8144 }
8145
8146 #[test]
8147 fn insert_link_repoints_the_link_at_a_bare_caret() {
8148 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8149 d.caret = 3; // in the link's text, nothing selected
8150 d.insert_link("http://y.dev");
8151 assert_eq!(d.source, "[word](http://y.dev)\n");
8152 assert_eq!(d.selected_text(), Some("word"));
8153 }
8154
8155 #[test]
8156 fn insert_link_on_an_empty_range_autolinks_a_url() {
8157 // A link with no text of its own is an autolink, and twig spells it —
8158 // `<…>` is the canonical form and needs no text typed into it, so the
8159 // caret lands after it rather than selecting a finished link.
8160 let mut d = doc_with("link_empty", "\n");
8161 d.caret = 0;
8162 d.insert_link("http://x.dev");
8163 assert_eq!(d.source, "<http://x.dev>\n");
8164 assert_eq!(d.selection(), None);
8165 assert_eq!(d.caret, 14);
8166 }
8167
8168 #[test]
8169 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
8170 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
8171 // in Markdown, so a destination that can't autolink doubles as the text
8172 // instead — which is then selected, ready to be typed over.
8173 let mut d = doc_with("link_rel", "\n");
8174 d.caret = 0;
8175 d.insert_link("./notes.md");
8176 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
8177 assert_eq!(d.selection(), Some((1, 11)));
8178 d.insert("Notes");
8179 assert_eq!(d.source, "[Notes](./notes.md)\n");
8180 }
8181
8182 #[test]
8183 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
8184 // The autolink's text is its URL, so re-pointing replaces the whole
8185 // node — the caret must not splice a second link inside the first.
8186 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
8187 d.caret = 10;
8188 d.insert_link("https://y.dev");
8189 assert_eq!(d.source, "see <https://y.dev> ok\n");
8190 }
8191
8192 #[test]
8193 fn code_language_reads_and_edits_through_the_fence() {
8194 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
8195 d.caret = 10; // inside the code body
8196 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8197 assert!(d.caret_in_fenced_code());
8198
8199 d.set_code_language("python");
8200 assert!(
8201 d.source.starts_with("```python\n"),
8202 "source: {:?}",
8203 d.source
8204 );
8205 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
8206
8207 // Clearing it leaves a bare fence and no label.
8208 d.set_code_language("");
8209 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
8210 assert_eq!(d.code_language_at_caret(), None);
8211
8212 // A caret outside any code block edits nothing.
8213 let mut p = doc_with("code_lang_none", "just prose\n");
8214 assert!(!p.caret_in_fenced_code());
8215 p.set_code_language("rust");
8216 assert_eq!(p.source, "just prose\n");
8217 }
8218
8219 #[test]
8220 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
8221 // Markdown's info string ends at whitespace, so `two words` would write
8222 // a fence that reads back with a different language than the one asked
8223 // for. twig refuses it; leaf reports that and leaves the source alone.
8224 // The old splice trimmed the ends and wrote whatever was left.
8225 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
8226 d.caret = 10;
8227 d.set_code_language("two words");
8228 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
8229 assert!(d.status.is_some(), "the refusal should be reported");
8230 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8231 }
8232
8233 #[test]
8234 fn link_destination_at_caret_reads_both_spellings() {
8235 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
8236 d.caret = 5;
8237 assert_eq!(
8238 d.link_destination_at_caret().as_deref(),
8239 Some("https://x.dev")
8240 );
8241 d.caret = 0;
8242 assert_eq!(d.link_destination_at_caret(), None);
8243
8244 // An autolink has no `destination`; its text is the URL.
8245 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
8246 a.caret = 10;
8247 assert_eq!(
8248 a.link_destination_at_caret().as_deref(),
8249 Some("https://x.dev")
8250 );
8251 a.caret = 21;
8252 assert_eq!(a.link_destination_at_caret(), None);
8253 }
8254
8255 #[test]
8256 fn locate_finds_the_block_a_declared_id_names() {
8257 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
8258 // verse. The locator has to land on the *verse*, which is the whole
8259 // reason a link carries one.
8260 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
8261 {#v2}\nYea, I make a record in the language of my father.\n";
8262 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8263 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
8264 assert_eq!(
8265 d.source[v2.start..v2.end].trim_end(),
8266 "Yea, I make a record in the language of my father."
8267 );
8268 // The attribute line is not part of it: `start` is a place to put a
8269 // caret, and `{#v2}` is markup the caret has no business landing in.
8270 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
8271 assert_eq!(d.locate("v99"), None);
8272 }
8273
8274 #[test]
8275 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
8276 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
8277 // Markdown heading is literal text. So `#the-second-part` can only be
8278 // the heading's own words, which is the rule every Markdown renderer
8279 // already follows and therefore the one a link was authored against.
8280 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
8281 let mut d = doc_with("locate_md", src);
8282 let hit = d.locate("the-second-part").expect("the heading's slug");
8283 assert!(d.source[hit.start..].starts_with("## The Second Part"));
8284 // Bounded by the next heading that isn't under it, so a peek shows the
8285 // section rather than only its title.
8286 assert_eq!(
8287 &d.source[hit.start..hit.end],
8288 "## The Second Part\n\nbody\n\n"
8289 );
8290
8291 // A subsection does not end its parent: `# Title` runs to `## Third`'s
8292 // sibling only because there is no other `#`, so it covers the lot.
8293 let title = d.locate("title").expect("the top heading");
8294 assert_eq!(title.end, d.source.len());
8295 }
8296
8297 #[test]
8298 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
8299 // djot mints `Some-Heading-Here`; a link to it is written
8300 // `#some-heading-here` by nearly everything that writes links. Both
8301 // spellings are one question.
8302 let src = "## Some Heading Here\n\nbody\n";
8303 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8304 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
8305 let slugged = d.locate("some-heading-here").expect("the link's spelling");
8306 assert_eq!(exact, slugged);
8307 // The section, not the heading line — there is more to show than a title.
8308 assert_eq!(&d.source[exact.start..exact.end], src);
8309 }
8310
8311 #[test]
8312 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
8313 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
8314 assert_eq!(d.locate(""), None);
8315 assert_eq!(d.locate(" "), None);
8316 // All punctuation: it names nothing, and must not be read as "match the
8317 // first heading whose slug is also empty".
8318 assert_eq!(d.locate("!!!"), None);
8319 }
8320
8321 #[test]
8322 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
8323 // The document's mistake, and the answer every other anchor
8324 // implementation gives — the alternative is for a link to mean whichever
8325 // of the two a walk happened to reach first.
8326 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
8327 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8328 let hit = d.locate("dup").expect("the first `{#dup}`");
8329 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
8330 }
8331
8332 #[test]
8333 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
8334 // The button's whole job: a reference where the caret was, a definition
8335 // to give it meaning, and the caret waiting in the empty note so the
8336 // next keystroke is the note's first word.
8337 let mut d = doc_with("fn_insert", "A claim and more.\n");
8338 d.caret = 7; // just past "A claim"
8339 d.insert_footnote();
8340 assert!(
8341 d.source.starts_with("A claim[^1] and more."),
8342 "{:?}",
8343 d.source
8344 );
8345 assert!(
8346 d.source.contains("[^1]:"),
8347 "the definition too: {:?}",
8348 d.source
8349 );
8350 assert_eq!(d.status, None);
8351
8352 let reference = d.source.find("[^1]").unwrap();
8353 let note = d
8354 .footnote_at(reference + 2)
8355 .expect("the reference just written");
8356 assert_eq!(note.label, "1");
8357 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
8358 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
8359 // …and typing there is typing into the note, not near it.
8360 d.insert("the note");
8361 assert_eq!(
8362 d.footnote_at(reference + 2).and_then(|f| f.text),
8363 Some("the note".to_string())
8364 );
8365 }
8366
8367 #[test]
8368 fn insert_footnote_numbers_past_the_notes_already_written() {
8369 // A second press must not hand back a label somebody else is using: twig
8370 // reuses a defined label rather than appending a rival definition, so a
8371 // repeat of `1` would quietly point the new reference at the old note.
8372 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8373 d.caret = 7; // past `[^1]`, before " two."
8374 d.insert_footnote();
8375 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8376 assert_eq!(d.source.matches("[^2]:").count(), 1);
8377 }
8378
8379 #[test]
8380 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8381 // `[^2]` with no definition is still a 2 that means something to whoever
8382 // wrote it — stepping over it would mint a note for their reference. A
8383 // word label takes no number, so it blocks none.
8384 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8385 d.caret = d.source.find(" c").unwrap();
8386 d.insert_footnote();
8387 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8388 assert!(
8389 d.source.starts_with("a[^2] b[^why][^1] c"),
8390 "{:?}",
8391 d.source
8392 );
8393 }
8394
8395 #[test]
8396 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8397 // A reference annotates the words before it. Consuming the selection —
8398 // which is what an insert normally does — would delete the very claim
8399 // the author selected in order to footnote.
8400 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8401 d.anchor = Some(2);
8402 d.caret = 7; // "claim" selected
8403 d.insert_footnote();
8404 assert!(
8405 d.source.starts_with("A claim[^1] and more."),
8406 "{:?}",
8407 d.source
8408 );
8409 }
8410
8411 #[test]
8412 fn a_note_just_written_still_knows_where_its_reference_is() {
8413 // The authoring loop in one test: press the button, type the note, ask to
8414 // go back. The caret ends at the note's last byte — which is the *end* of
8415 // the definition's span, the one offset the query used to exclude — so
8416 // this is where the round trip either works or doesn't.
8417 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8418 d.caret = 7;
8419 d.insert_footnote();
8420 d.insert("the note");
8421 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8422 let back = d
8423 .footnote_definition_at_caret()
8424 .expect("still in the note we just typed");
8425 assert_eq!(back.label, "1");
8426 // …and following it lands on the reference's label, where a reader's
8427 // return leg lands.
8428 assert_eq!(back.offset, Some(9));
8429 assert_eq!(&d.source[9..10], "1");
8430 }
8431
8432 #[test]
8433 fn insert_footnote_takes_one_undo_for_both_halves() {
8434 // twig writes the pair as a single edit; the point of that is here.
8435 let before = "A claim and more.\n";
8436 let mut d = doc_with("fn_insert_undo", before);
8437 d.caret = 7;
8438 d.insert_footnote();
8439 assert_ne!(d.source, before);
8440 d.undo();
8441 assert_eq!(d.source, before, "one undo takes back both halves");
8442 }
8443
8444 #[test]
8445 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
8446 // HTML is authorable — it spells the inline marks — and has no footnote.
8447 // The refusal says so rather than writing brackets that would render as
8448 // brackets.
8449 let src = "<p>A claim.</p>\n";
8450 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
8451 assert!(!Capabilities::of(Format::Html).footnote);
8452 d.caret = 5;
8453 d.insert_footnote();
8454 assert_eq!(d.source, src, "nothing written");
8455 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
8456 }
8457
8458 #[test]
8459 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
8460 // The empty body is the one place this could go wrong: the definition
8461 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
8462 // byte early would draw up in the paragraph above the note it belongs to.
8463 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
8464 d.place_caret(7, false);
8465 d.insert_footnote();
8466 d.build_visual(80); // the frame a frontend draws after the edit
8467 assert_eq!(
8468 d.vmap.snap_to_stop(d.caret),
8469 d.caret,
8470 "the caret sits on a stop"
8471 );
8472 let (row, _) = d.caret_pos();
8473 assert!(
8474 drawn_rows(&d)[row].contains("[1]"),
8475 "the caret is on the note's row, not above it: {:?}",
8476 drawn_rows(&d)
8477 );
8478 }
8479
8480 #[test]
8481 fn footnote_at_caret_resolves_a_reference_to_its_note() {
8482 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
8483 // blank line, as one has to.
8484 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
8485 d.caret = 9;
8486 let f = d
8487 .footnote_at_caret()
8488 .expect("the caret stands in a reference");
8489 assert_eq!(f.label, "1");
8490 assert_eq!(f.text.as_deref(), Some("the note"));
8491 // The offset points at the note's first word, not at the definition's
8492 // `[` — the marker is decoration with no caret stop on it.
8493 assert_eq!(f.offset, Some(29));
8494 assert_eq!(&d.source[29..37], "the note");
8495 // …and `end` closes the range, so a frontend can ask which rendered rows
8496 // the note occupies rather than re-deriving them from the text.
8497 assert_eq!(f.end, Some(37));
8498 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
8499 }
8500
8501 /// Two definitions in a row: each is its own note, and neither reaches into
8502 /// the other.
8503 ///
8504 /// A djot definition's span used to run past the blank line into the first
8505 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
8506 /// offsets named the *next* note's rows too, showing a reader two footnotes
8507 /// when they had asked about one. twig 3.1 ends the span after the block's
8508 /// own last line; the test outlives the workaround leaf carried for it.
8509 #[test]
8510 fn footnote_at_stops_a_note_at_the_definition_after_it() {
8511 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
8512 for format in [Format::Markdown, Format::Djot] {
8513 let mut d = Doc::from_source(src.to_string(), format).unwrap();
8514 d.caret = 7;
8515 let f = d.footnote_at_caret().expect("a reference");
8516 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
8517 assert_eq!(
8518 &src[f.offset.unwrap()..f.end.unwrap()],
8519 "first note.",
8520 "in {format:?}"
8521 );
8522 }
8523 }
8524
8525 /// The other side of that boundary: a blank line *inside* a definition is
8526 /// interior to it, and the note keeps its second paragraph.
8527 ///
8528 /// This is what the old body scan cost. It stopped at the first line not
8529 /// indented under the note — a blank line is not — so a two-paragraph note
8530 /// came back as its first paragraph, and "go to note" framed half of it.
8531 /// Reading the span twig gives is both simpler and right.
8532 #[test]
8533 fn footnote_at_keeps_a_notes_second_paragraph() {
8534 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
8535 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8536 d.caret = 7;
8537 let f = d.footnote_at_caret().expect("a reference");
8538 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
8539 // And it stops there — `After.` is the next block, not more note.
8540 assert_eq!(
8541 &src[f.offset.unwrap()..f.end.unwrap()],
8542 f.text.as_deref().unwrap()
8543 );
8544 assert!(!f.text.as_deref().unwrap().contains("After"));
8545 }
8546
8547 #[test]
8548 fn footnote_at_bounds_a_note_whose_body_is_empty() {
8549 // `[^1]:` with nothing after it. The range is empty rather than
8550 // inverted, and still points inside the definition — which is what keeps
8551 // a frontend's row lookup from walking off into the block above.
8552 let src = "A claim[^1].\n\n[^1]:\n";
8553 let mut d = doc_with("fn_empty_body", src);
8554 d.caret = 9;
8555 let f = d.footnote_at_caret().expect("a reference");
8556 assert_eq!(f.text.as_deref(), Some(""));
8557 assert_eq!(f.offset, f.end, "an empty note is an empty range");
8558 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
8559 }
8560
8561 #[test]
8562 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
8563 let mut d = doc_with(
8564 "fn_at_caret_none",
8565 "A claim[^1] and more.\n\n[^1]: the note\n",
8566 );
8567 d.caret = 2; // in the prose
8568 assert_eq!(d.footnote_at_caret(), None);
8569 }
8570
8571 #[test]
8572 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
8573 // The two are deliberately separate: a reference names a note in this
8574 // document, a link names somewhere to leave for, and answering one with
8575 // the other is what made a reference click do nothing at all.
8576 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
8577 d.caret = 3; // the `1` of `[^1]`
8578 assert!(d.footnote_at_caret().is_some());
8579 assert_eq!(
8580 d.link_destination_at_caret(),
8581 None,
8582 "a reference is not a link"
8583 );
8584
8585 d.caret = 10; // inside the link's label
8586 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
8587 assert_eq!(
8588 d.link_destination_at_caret().as_deref(),
8589 Some("https://x.dev")
8590 );
8591 }
8592
8593 #[test]
8594 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
8595 // A `[^99]` the document never defines is a real state — a note deleted
8596 // out from under its reference — and the label is what lets a frontend
8597 // say so. `None` here would be indistinguishable from "not on a
8598 // reference", which is the wrong thing to tell a reader.
8599 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
8600 d.caret = 9;
8601 let f = d
8602 .footnote_at_caret()
8603 .expect("the reference is still a reference");
8604 assert_eq!(f.label, "99");
8605 assert_eq!(f.text, None);
8606 assert_eq!(f.offset, None);
8607 }
8608
8609 #[test]
8610 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
8611 // Labels are not always numbers, and a note's body runs past its first
8612 // line — the indented continuation belongs to the note, so it comes back
8613 // with it (source bytes, verbatim, as documented).
8614 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
8615 let mut d = doc_with("fn_word_label", src);
8616 d.caret = 6;
8617 let f = d
8618 .footnote_at_caret()
8619 .expect("the caret stands in a reference");
8620 assert_eq!(f.label, "note");
8621 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
8622 }
8623
8624 #[test]
8625 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
8626 // The point of the offset form: a pointer hovering a reference asks what
8627 // note it names, and must not drag the caret along to ask.
8628 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
8629 d.caret = 0;
8630 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
8631 assert_eq!(f.label, "1");
8632 assert_eq!(f.text.as_deref(), Some("the note"));
8633 assert_eq!(d.caret, 0, "asking must not move the caret");
8634 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
8635 }
8636
8637 #[test]
8638 fn footnote_definition_at_caret_points_back_at_the_reference() {
8639 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
8640 // the caret can rest on — is at 9.
8641 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
8642 d.caret = 30; // inside the note's body
8643 let f = d
8644 .footnote_definition_at_caret()
8645 .expect("the caret stands in a definition");
8646 assert_eq!(f.label, "1");
8647 assert_eq!(f.offset, Some(9));
8648 assert_eq!(&d.source[7..11], "[^1]");
8649 }
8650
8651 #[test]
8652 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
8653 // The two legs have to meet: wherever `footnote_at` sends the caret, the
8654 // definition query must answer for — otherwise arriving at a note leaves
8655 // the reader somewhere the way back isn't offered.
8656 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
8657 let mut d = doc_with("fn_def_marker", src);
8658 let landed = d.footnote_at(9).unwrap().offset.unwrap();
8659 assert_eq!(
8660 d.footnote_definition_at(landed).and_then(|f| f.offset),
8661 Some(9),
8662 "the note a reference sends you to offers the way back"
8663 );
8664 }
8665
8666 #[test]
8667 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
8668 // The two queries answer for disjoint places, which is what lets one
8669 // gesture mean "down to the note" in one and "back up" in the other
8670 // without either having to remember which way the reader is going.
8671 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
8672 d.caret = 2; // prose
8673 assert_eq!(d.footnote_definition_at_caret(), None);
8674 d.caret = 9; // the reference
8675 assert_eq!(d.footnote_definition_at_caret(), None);
8676 assert!(
8677 d.footnote_at_caret().is_some(),
8678 "which is the reference's own query"
8679 );
8680 }
8681
8682 #[test]
8683 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
8684 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
8685 // note", which is false and leaves a frontend unable to explain why the
8686 // way back is missing.
8687 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
8688 let mut d = doc_with("fn_def_orphan", src);
8689 d.caret = src.find("orphan").unwrap();
8690 let f = d
8691 .footnote_definition_at_caret()
8692 .expect("an orphan is still a definition");
8693 assert_eq!(f.label, "2");
8694 assert_eq!(f.offset, None);
8695 }
8696
8697 #[test]
8698 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
8699 // One label, cited twice. The first is where the reader most likely came
8700 // from, and the only answer that doesn't depend on how they got here.
8701 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
8702 let mut d = doc_with("fn_def_repeat", src);
8703 d.caret = src.find("the note").unwrap();
8704 let f = d.footnote_definition_at_caret().expect("a definition");
8705 assert_eq!(
8706 f.offset,
8707 Some(5),
8708 "the first `[^a]`'s label, not the second's"
8709 );
8710 assert_eq!(&src[3..7], "[^a]");
8711 }
8712
8713 #[test]
8714 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
8715 // Down and back up, each leg found from the document rather than from a
8716 // memory of the other — so it still works for a reader who scrolled to
8717 // the notes instead of jumping there.
8718 //
8719 // `place_caret` rather than assigning `caret`, because that is what a
8720 // frontend calls: it snaps to a real caret stop, and a jump that lands
8721 // on a byte the caret can't rest on would arrive somewhere the return
8722 // leg no longer answers for. `build_map` first, since snapping is a
8723 // no-op until the map exists — which is exactly how this went unnoticed
8724 // when the offsets pointed at the `[^` markers.
8725 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
8726 d.build_map(None);
8727 d.place_caret(9, false);
8728 let down = d
8729 .footnote_at_caret()
8730 .expect("a reference")
8731 .offset
8732 .expect("a note");
8733 d.place_caret(down, false);
8734 let up = d
8735 .footnote_definition_at_caret()
8736 .expect("a definition")
8737 .offset
8738 .expect("a reference");
8739 d.place_caret(up, false);
8740 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
8741 assert_eq!(
8742 d.footnote_at_caret().expect("back on the reference").label,
8743 "1"
8744 );
8745 }
8746
8747 #[test]
8748 fn insert_link_hands_the_destination_to_twig_raw() {
8749 // Escaping is twig's, and format-specific: Markdown ends a destination
8750 // at the first space and needs the `<…>` form, where djot would read
8751 // those angle brackets as part of the URL.
8752 let mut d = doc_with("link_space", "word\n");
8753 d.anchor = Some(0);
8754 d.caret = 4;
8755 d.insert_link("a b");
8756 assert_eq!(d.source, "[word](<a b>)\n");
8757 }
8758
8759 #[test]
8760 fn insert_link_reports_a_destination_no_format_can_carry() {
8761 let mut d = doc_with("link_bad", "word\n");
8762 d.anchor = Some(0);
8763 d.caret = 4;
8764 d.insert_link("a\nb");
8765 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
8766 assert!(
8767 d.status.is_some(),
8768 "InvalidArgument should reach the status line"
8769 );
8770 assert!(!d.dirty);
8771 }
8772
8773 #[test]
8774 fn insert_link_works_in_wysiwyg_view() {
8775 let mut d = wysiwyg_doc("link_wys", "word here\n");
8776 d.anchor = Some(0);
8777 d.caret = 4;
8778 d.insert_link("http://x.dev");
8779 assert_eq!(d.source, "[word](http://x.dev) here\n");
8780 assert_eq!(d.selected_text(), Some("word"));
8781 // The map the caret has to keep riding is rebuilt each frame; motion
8782 // over the fresh one must still land on a real stop (the debug_assert).
8783 d.build_visual(80);
8784 d.move_right(false);
8785 d.move_left(false);
8786 }
8787
8788 #[test]
8789 fn click_maps_a_row_col_to_a_byte_offset() {
8790 let mut d = doc_with("click", "ab\ncd\n");
8791 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
8792 assert_eq!(d.caret, 4);
8793 }
8794
8795 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
8796 // stop just as the `(row, col)` click path does, so the caret can never come
8797 // to rest in the blank gap between two paragraphs — where it would draw in one
8798 // place and type in another.
8799 #[test]
8800 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
8801 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
8802 // caret stop (stops are 0,1,3,4).
8803 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
8804 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
8805 d.place_caret(2, false);
8806 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8807 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
8808 }
8809
8810 #[test]
8811 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
8812 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
8813 d.place_caret(0, false); // anchor at the start of "A"
8814 d.place_caret(2, true); // drag into the gap
8815 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
8816 let (s, e) = d.selection().expect("a selection");
8817 assert!(
8818 d.vmap.is_stop(s) && d.vmap.is_stop(e),
8819 "selection {s}..{e} off a stop"
8820 );
8821 }
8822
8823 #[test]
8824 fn place_caret_on_a_real_stop_is_left_untouched() {
8825 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
8826 d.place_caret(3, false); // the start of "B" — a genuine stop
8827 assert_eq!(d.caret, 3);
8828 }
8829
8830 // An *empty paragraph* (two blank lines, an intentional blank line the user
8831 // opened) is a real caret stop, unlike the gap — a click into it must stay.
8832 #[test]
8833 fn place_caret_rests_in_an_empty_paragraph() {
8834 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
8835 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
8836 assert!(d.vmap.is_stop(empty));
8837 d.place_caret(empty, false);
8838 assert_eq!(d.caret, empty);
8839 }
8840
8841 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
8842 doc_in(View::Wysiwyg, name, body)
8843 }
8844
8845 /// How many list items the source actually parses into — the check that a
8846 /// marker Leaf wrote is a marker the format agrees is one.
8847 fn list_items(doc: &mut Doc) -> usize {
8848 doc.editor
8849 .nodes()
8850 .unwrap()
8851 .iter()
8852 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
8853 .count()
8854 }
8855
8856 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
8857 /// incremental (`build_spliced` / `build_cached`) path must always match.
8858 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
8859 reference_map_revealing(source, None)
8860 }
8861
8862 /// [`reference_map`] with a reveal line — the ground truth for the
8863 /// `MarkupMode::Full` builds, where the map is a function of the caret's
8864 /// line as well as the text.
8865 fn reference_map_revealing(
8866 source: &str,
8867 reveal: Option<Range<usize>>,
8868 ) -> crate::wysiwyg::VisualMap {
8869 // The same parse `Doc` uses. With twig's plain defaults instead, the two
8870 // sides disagree on what the *document* is before the renderer is even
8871 // reached — a bare `:word` is a text directive to one and prose to the
8872 // other — and the mismatch reads as a splice bug that isn't one.
8873 let mut ed =
8874 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
8875 let nodes = ed.nodes().unwrap();
8876 crate::wysiwyg::build(
8877 &nodes,
8878 source,
8879 None,
8880 false,
8881 &std::collections::HashMap::new(),
8882 reveal,
8883 )
8884 }
8885
8886 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
8887 if a.rows.len() != b.rows.len() {
8888 return true;
8889 }
8890 for (ra, rb) in a.rows.iter().zip(&b.rows) {
8891 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
8892 return true;
8893 }
8894 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
8895 if ga.ch != gb.ch || ga.src != gb.src {
8896 return true;
8897 }
8898 }
8899 }
8900 false
8901 }
8902
8903 #[test]
8904 fn incremental_build_matches_a_fresh_build_across_edits() {
8905 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
8906 // fast path, gated on twig's `dirty_range`) or falls back to
8907 // `build_cached`. After each edit the map must be byte-identical to a
8908 // from-scratch build — this is the correctness net under the splice.
8909 let docs = [
8910 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
8911 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
8912 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
8913 // A footnote definition is a root beside `doc`, merged back into the
8914 // top-level list by `wysiwyg::top_blocks`. The random edits below
8915 // make and unmake definitions as they go (a deleted `:` turns one
8916 // back into a paragraph, and vice versa), which is exactly the
8917 // structural churn the splice path has to notice and bail out of.
8918 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
8919 ];
8920 // A deterministic mix: mostly single characters (which stay inside one
8921 // block → splice), plus edits that reshape structure (a paragraph break,
8922 // a heading marker, a code fence → fallback), so both paths are exercised.
8923 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
8924 for src in docs {
8925 let mut d = wysiwyg_doc("diff", src);
8926 d.build_visual_unwrapped();
8927 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
8928
8929 for step in 0..60usize {
8930 let len = d.source.len();
8931 let raw = (step * 13 + 5) % (len + 1);
8932 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8933 let pre = d.source.clone();
8934 let action;
8935 if step % 3 == 0 && pos < len {
8936 let end = (pos + 1..=len)
8937 .find(|&i| d.source.is_char_boundary(i))
8938 .unwrap();
8939 action = format!("delete [{pos},{end})");
8940 d.edit(pos, end, "");
8941 } else {
8942 let ins = inserts[step % inserts.len()];
8943 action = format!("insert {ins:?} @ {pos}");
8944 d.edit(pos, pos, ins);
8945 }
8946 d.build_visual_unwrapped();
8947 if maps_differ(&d.vmap, &reference_map(&d.source)) {
8948 panic!(
8949 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
8950 d.source
8951 );
8952 }
8953 }
8954 }
8955 }
8956
8957 #[test]
8958 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
8959 // The same correctness net as `incremental_build_matches_a_fresh_build_
8960 // across_edits`, under `MarkupMode::Full` — where the map depends on
8961 // the caret's *line* as well as the text, so the two caches have a new
8962 // way to be wrong. Both are exercised: the block cache can hand back
8963 // rows built for a line that is no longer the revealed one, and the
8964 // splice path can reuse a suffix that still has yesterday's line raw.
8965 //
8966 // Caret motion is interleaved with the edits deliberately, because a
8967 // caret that only ever moved with the edit would never cross a line
8968 // without also dirtying it — the case where a stale reveal survives.
8969 let docs = [
8970 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
8971 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
8972 ];
8973 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
8974 for src in docs {
8975 let mut d = wysiwyg_doc("reveal_diff", src);
8976 d.set_markup_mode(MarkupMode::Full);
8977
8978 for step in 0..60usize {
8979 let len = d.source.len();
8980 let raw = (step * 13 + 5) % (len + 1);
8981 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
8982 let pre = d.source.clone();
8983 let action;
8984 if step % 3 == 0 && pos < len {
8985 let end = (pos + 1..=len)
8986 .find(|&i| d.source.is_char_boundary(i))
8987 .unwrap();
8988 action = format!("delete [{pos},{end})");
8989 d.edit(pos, end, "");
8990 } else {
8991 let ins = inserts[step % inserts.len()];
8992 action = format!("insert {ins:?} @ {pos}");
8993 d.edit(pos, pos, ins);
8994 }
8995 // Walk the caret somewhere else in the document, independently
8996 // of where the edit landed.
8997 let want = (step * 29 + 11) % (d.source.len() + 1);
8998 d.caret = (want..=d.source.len())
8999 .find(|&i| d.source.is_char_boundary(i))
9000 .unwrap();
9001 d.build_visual_unwrapped();
9002
9003 let want = reference_map_revealing(&d.source, d.reveal_line());
9004 if maps_differ(&d.vmap, &want) {
9005 panic!(
9006 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
9007 d.caret, d.source
9008 );
9009 }
9010 }
9011 }
9012 }
9013
9014 #[test]
9015 fn caret_motion_across_lines_rebuilds_only_under_full() {
9016 // The cache-key change has to earn its keep in both directions: `Full`
9017 // must rebuild when the caret changes line (or the reveal would never
9018 // move), and the hidden modes must *not* (or every arrow key would pay
9019 // for a feature they don't use). The existing `cache_motion` test pins
9020 // the second for the default mode; this pins the pair against a mode
9021 // change alone.
9022 let body = "*one* here\n\n*two* there\n";
9023
9024 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9025 full.set_markup_mode(MarkupMode::Full);
9026 caret_at(&mut full, "one");
9027 let before = full.revision();
9028 caret_at(&mut full, "two");
9029 assert_eq!(full.revision(), before, "motion is not an edit");
9030 assert!(
9031 drawn_rows(&full).iter().any(|r| r == "*two* there"),
9032 "the map followed the caret: {:?}",
9033 drawn_rows(&full)
9034 );
9035
9036 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9037 caret_at(&mut hidden, "one");
9038 let key = hidden.vmap_key.clone();
9039 caret_at(&mut hidden, "two");
9040 assert_eq!(
9041 hidden.vmap_key, key,
9042 "a hidden mode rebuilds nothing on motion"
9043 );
9044 }
9045
9046 #[test]
9047 fn wysiwyg_down_crosses_a_paragraph_boundary() {
9048 // Regression: the blank separator row used to share the previous
9049 // paragraph's end offset, so Down got pinned at the boundary (while Up
9050 // still crossed). Both directions must step through it symmetrically.
9051 //
9052 // It's now stepped *over* rather than onto: the blank line between two
9053 // paragraphs is the boundary being drawn, not a line of the document, so
9054 // one press of Down crosses it. The goal column survives the crossing —
9055 // col 3 at the end of "abc" is col 3 at the end of "def".
9056 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9057 d.caret = 3; // end of "abc" (row 0)
9058 d.move_down(false);
9059 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9060 assert_eq!(d.caret, 8); // end of "def", col 3 kept
9061 d.move_up(false);
9062 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9063 assert_eq!(d.caret, 3);
9064 }
9065
9066 #[test]
9067 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
9068 // The second Up and the second Down here run off the ends of the
9069 // document, which is no longer a place a press is swallowed: they carry
9070 // the caret to the start and the end of the text. The claim in the
9071 // middle — that a Down retraces the Up that crossed the paragraph gap —
9072 // is the one this test is for, and it is asserted where it is made.
9073 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
9074 d.caret = 5; // start of "def"
9075 let start = d.caret_pos();
9076 d.move_up(false);
9077 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
9078 d.move_up(false);
9079 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
9080 d.move_down(false);
9081 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
9082 d.move_down(false);
9083 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
9084 }
9085
9086 #[test]
9087 fn wysiwyg_new_paragraph_shows_before_typing() {
9088 // Regression: two Enters at the end of a paragraph produced trailing
9089 // newlines with no AST node, so the caret appeared stuck on the old line
9090 // until a character was typed. It must ride down onto the new line now.
9091 let mut d = doc_with("wys_newpara", "abc\n");
9092 d.view = View::Wysiwyg;
9093 d.caret = 3;
9094 d.insert("\n");
9095 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
9096 assert_eq!(d.source, "abc\n\n\n");
9097 d.build_visual(80);
9098 let (row, _) = d.caret_pos();
9099 assert!(
9100 row >= 2,
9101 "caret should have moved down to the new line, got row {row}"
9102 );
9103 assert!(
9104 d.vmap.num_rows() >= 3,
9105 "the blank lines should render as rows"
9106 );
9107 }
9108
9109 #[test]
9110 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
9111 // The reported bug: Enter at the end of a paragraph that has another
9112 // paragraph below put the caret at the *start of the next paragraph* —
9113 // the empty paragraph it opened had no row, so the caret snapped onto
9114 // "World". It must now sit on its own empty line, with a blank spacer
9115 // above it (the paragraph gap).
9116 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
9117 d.caret = 5; // end of "Hello"
9118 d.newline();
9119 d.build_visual(80);
9120 let (row, col) = d.caret_pos();
9121 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
9122 assert_eq!(
9123 d.vmap.row_width(row),
9124 0,
9125 "caret's row must be empty, not 'World'"
9126 );
9127 assert!(
9128 row >= 2,
9129 "a blank spacer row should sit above the caret, got row {row}"
9130 );
9131 // The row above the caret is a real (empty) gap, and "Hello" stays put.
9132 assert_eq!(
9133 d.vmap.row_width(row - 1),
9134 0,
9135 "the row above the caret is a gap"
9136 );
9137 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9138 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
9139 }
9140
9141 #[test]
9142 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
9143 // At the document end a single Enter must also show the paragraph gap —
9144 // a blank spacer row above the caret — so the layout already matches how
9145 // it will look once the new paragraph has text.
9146 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
9147 d.caret = 5; // end of "Hello", no trailing newline
9148 d.newline(); // source becomes "Hello\n\n"
9149 d.build_visual(80);
9150 let (row, col) = d.caret_pos();
9151 assert_eq!(col, 0);
9152 assert!(
9153 row >= 2,
9154 "caret should sit below a blank spacer, got row {row}"
9155 );
9156 assert_eq!(
9157 d.vmap.row_width(row - 1),
9158 0,
9159 "the row above the caret is a gap"
9160 );
9161 }
9162
9163 #[test]
9164 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
9165 // The spacer is view-only: typing the new paragraph must not reflow the
9166 // caret onto a different row — the transient view already matched the
9167 // settled one.
9168 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
9169 d.caret = 5;
9170 d.newline();
9171 d.build_visual(80);
9172 let before = d.caret_pos();
9173 d.insert("New");
9174 d.build_visual(80);
9175 let after = d.caret_pos();
9176 assert_eq!(
9177 after.0, before.0,
9178 "typing must not move the caret to another row ({before:?} -> {after:?})"
9179 );
9180 }
9181
9182 #[test]
9183 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
9184 let fm = "---\ntitle: hi\n---\n";
9185 let body = format!("{fm}# leaf\n\nbody\n");
9186 let mut d = wysiwyg_doc("wys_fm", &body);
9187 // Opening lifts the caret out of the now-hidden frontmatter.
9188 assert_eq!(
9189 d.caret,
9190 fm.len(),
9191 "caret should start at the first real block"
9192 );
9193 // Left at the content start can't step back into frontmatter.
9194 d.move_left(false);
9195 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
9196 // Doc-start lands on the content floor, not offset 0.
9197 d.move_doc_start(false);
9198 assert_eq!(d.caret, fm.len());
9199 // Select-all + copy never include the frontmatter bytes.
9200 d.select_all();
9201 let sel = d.selected_text().unwrap().to_string();
9202 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
9203 assert!(
9204 sel.starts_with("# leaf"),
9205 "selection should begin at content: {sel:?}"
9206 );
9207 }
9208
9209 #[test]
9210 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
9211 // Backspace deletes `prev_boundary..caret` directly; at the first real
9212 // block that boundary is inside the hidden frontmatter, so it must be a
9213 // no-op rather than eating the closing `---`.
9214 let fm = "---\ntitle: hi\n---\n";
9215 let body = format!("{fm}leaf\n");
9216 let mut d = wysiwyg_doc("wys_fm_bs", &body);
9217 assert_eq!(d.caret, fm.len());
9218 d.backspace();
9219 assert_eq!(d.source, body, "backspace must not touch frontmatter");
9220 d.delete_word_back();
9221 assert_eq!(
9222 d.source, body,
9223 "word-delete must not touch frontmatter either"
9224 );
9225 }
9226
9227 #[test]
9228 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
9229 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
9230 // fenced div, really, since core parses these on for every document
9231 // now (`parse_extensions`). The container is a `directive` node, an
9232 // `is_block_container` kind like `block_quote`, so the caret works
9233 // inside its child paragraph exactly as it would inside a quote: typing
9234 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
9235 // untouched.
9236 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
9237 let mut d = wysiwyg_doc("wys_vis", body);
9238 d.caret = body.find("hello").unwrap() + "hello".len();
9239 d.insert("!");
9240 assert_eq!(
9241 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
9242 "typing inside the block edits its content in place"
9243 );
9244 assert!(
9245 d.source.contains(":::vis{.public .family}"),
9246 "opening fence survives"
9247 );
9248 assert!(d.source.contains(":::\nafter"), "closing fence survives");
9249 }
9250
9251 #[test]
9252 fn source_view_still_reaches_frontmatter() {
9253 // The metadata is only *hidden*, never lost: the source view edits and
9254 // selects it in full, and it's always preserved on save.
9255 let fm = "---\ntitle: hi\n---\n";
9256 let body = format!("{fm}# leaf\n");
9257 let mut d = doc_with("src_fm", &body);
9258 d.select_all();
9259 let sel = d.selected_text().unwrap();
9260 assert!(
9261 sel.contains("title"),
9262 "source view should select everything"
9263 );
9264 d.move_doc_start(false);
9265 assert_eq!(d.caret, 0, "source view can reach offset 0");
9266 }
9267
9268 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
9269
9270 #[test]
9271 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
9272 // The border and padding between two cells all share one source offset,
9273 // so a column-stepping caret would sit on `│` and then stall there
9274 // forever. Right must step: end of "Name" -> start of "Qty".
9275 let mut d = wysiwyg_doc("tbl_right", TABLE);
9276 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
9277 d.move_right(false);
9278 assert_eq!(
9279 d.caret,
9280 TABLE.find("Qty").unwrap(),
9281 "should land in the next cell"
9282 );
9283 let (r, c) = d.caret_pos();
9284 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
9285 }
9286
9287 #[test]
9288 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
9289 let mut d = wysiwyg_doc("tbl_left", TABLE);
9290 d.caret = TABLE.find("Qty").unwrap();
9291 d.move_left(false);
9292 assert_eq!(
9293 d.caret,
9294 TABLE.find("Name").unwrap() + 4,
9295 "end of the previous cell"
9296 );
9297 }
9298
9299 #[test]
9300 fn wysiwyg_down_steps_over_a_table_rule() {
9301 // Between the header and the first body row sits a `├───┼───┤` rule.
9302 // It's drawn but holds no caret, so one Down must reach "Pear".
9303 let mut d = wysiwyg_doc("tbl_down", TABLE);
9304 d.caret = TABLE.find("Name").unwrap();
9305 d.move_down(false);
9306 assert_eq!(
9307 d.caret,
9308 TABLE.find("Pear").unwrap(),
9309 "one Down reaches the body row"
9310 );
9311 d.move_down(false);
9312 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
9313 }
9314
9315 #[test]
9316 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
9317 let mut d = wysiwyg_doc("tbl_tab", TABLE);
9318 d.caret = TABLE.find("Name").unwrap();
9319 // A hop lands with the destination cell's whole content selected, the
9320 // caret at its end — so typing replaces the cell like a form field.
9321 assert!(d.cell_hop(true));
9322 assert_eq!(
9323 d.selected_text(),
9324 Some("Qty"),
9325 "the target cell comes up selected"
9326 );
9327 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
9328 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
9329 assert_eq!(d.selected_text(), Some("Pear"));
9330 assert!(d.cell_hop(false));
9331 assert_eq!(d.selected_text(), Some("Qty"));
9332 }
9333
9334 #[test]
9335 fn tab_outside_a_table_is_not_a_cell_hop() {
9336 // `cell_hop` reports false so the frontend can indent as usual.
9337 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
9338 d.caret = 4;
9339 assert!(!d.cell_hop(true));
9340 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
9341 }
9342
9343 #[test]
9344 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
9345 let mut d = wysiwyg_doc("tbl_edge", TABLE);
9346 d.caret = TABLE.rfind("12").unwrap(); // the final cell
9347 assert!(!d.cell_hop(true), "no cell after the last one");
9348 d.caret = TABLE.find("Name").unwrap();
9349 assert!(!d.cell_hop(false), "no cell before the first one");
9350 }
9351
9352 #[test]
9353 fn wysiwyg_vertical_cell_motion_holds_the_column() {
9354 // Down/Up step to the cell above/below in the *same column*, not back to
9355 // the top-left the way a naive row/col motion over the picture would.
9356 let mut d = wysiwyg_doc("tbl_vert", TABLE);
9357 d.caret = TABLE.find("Qty").unwrap();
9358 // Each vertical hop selects the destination cell, holding the column.
9359 assert!(d.cell_move_vertical(true));
9360 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
9361 assert!(d.cell_move_vertical(true));
9362 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
9363 assert!(!d.cell_move_vertical(true), "no row below the last");
9364 assert!(d.cell_move_vertical(false));
9365 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
9366 assert!(d.cell_move_vertical(false));
9367 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
9368 assert!(!d.cell_move_vertical(false), "no row above the header");
9369 }
9370
9371 #[test]
9372 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
9373 let mut d = wysiwyg_doc("tbl_grow", TABLE);
9374 d.caret = TABLE.rfind("12").unwrap();
9375 let rows_before = d.source.matches('\n').count();
9376 assert!(d.cell_tab(true), "acts as a table key");
9377 assert_eq!(
9378 d.source.matches('\n').count(),
9379 rows_before + 1,
9380 "a fresh row was appended"
9381 );
9382 assert!(d.caret_in_table(), "the caret entered the new row");
9383 // The caret sits in the new row's first cell — past the old last cell.
9384 assert!(d.caret > TABLE.rfind("12").unwrap());
9385 }
9386
9387 #[test]
9388 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
9389 let mut d = wysiwyg_doc("tbl_ret", TABLE);
9390 d.caret = TABLE.find("Name").unwrap();
9391 assert!(d.cell_return(), "acts as a table key");
9392 assert_eq!(
9393 d.selected_text(),
9394 Some("Pear"),
9395 "Return drops one cell, selecting it"
9396 );
9397 // From the last row, Return appends a row and enters it.
9398 d.caret = TABLE.rfind("Fig").unwrap();
9399 let rows_before = d.source.matches('\n').count();
9400 assert!(d.cell_return());
9401 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
9402 assert!(d.caret_in_table());
9403 }
9404
9405 #[test]
9406 fn return_and_tab_outside_a_table_decline() {
9407 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
9408 d.caret = 4;
9409 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
9410 assert!(!d.cell_tab(true), "no table: the frontend indents");
9411 assert!(
9412 !d.cell_line_break(),
9413 "no table: the frontend breaks the line"
9414 );
9415 }
9416
9417 #[test]
9418 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
9419 let mut d = wysiwyg_doc("tbl_break", TABLE);
9420 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
9421 assert!(d.cell_line_break(), "acts as a table key");
9422 assert!(
9423 d.source.contains("Pear<br>"),
9424 "spelled as an inline <br>: {}",
9425 d.source
9426 );
9427 assert!(d.caret_in_table(), "still in the cell, past the break");
9428 // The break renders as a real line: the "Pear" cell now draws two lines,
9429 // so the table's picture is one row taller than a single-line table.
9430 d.build_visual(80);
9431 let table = &d.vmap.tables[0];
9432 let cell = &table.grid[1].cells[0]; // first body row, first column
9433 assert!(
9434 cell.glyphs.iter().any(|g| g.ch == '\n'),
9435 "the cell carries the break as a newline glyph for the frontend to split"
9436 );
9437 }
9438
9439 #[test]
9440 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
9441 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
9442 // back as structure — the whole point of routing through insert_line_break
9443 // instead of splicing raw `<br>` bytes.
9444 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
9445 d.caret = TABLE.find("Pear").unwrap() + 4;
9446 assert!(d.cell_line_break());
9447 let kinds: Vec<Kind> = d
9448 .editor
9449 .nodes()
9450 .unwrap()
9451 .iter()
9452 .map(|n| n.kind.clone())
9453 .collect();
9454 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
9455 assert!(
9456 !kinds.contains(&Kind::RawInline),
9457 "still raw HTML: {kinds:?}"
9458 );
9459 }
9460
9461 #[test]
9462 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
9463 // The `<br>` draws as one newline glyph, so Backspace over it must take
9464 // all four bytes — a one-byte delete would strand a visible `<br` in the
9465 // cell (the reported bug).
9466 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
9467 d.caret = TABLE.find("Pear").unwrap() + 4;
9468 assert!(d.cell_line_break());
9469 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
9470 d.backspace(); // caret sits just past the break
9471 assert!(
9472 !d.source.contains("<br"),
9473 "no half-deleted <br left: {}",
9474 d.source
9475 );
9476 assert!(
9477 d.source.contains("| Pear |"),
9478 "the cell is back to one line: {}",
9479 d.source
9480 );
9481 }
9482
9483 #[test]
9484 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
9485 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
9486 d.caret = TABLE.find("Pear").unwrap() + 4;
9487 assert!(d.cell_line_break());
9488 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
9489 d.delete_forward();
9490 assert!(
9491 !d.source.contains("<br"),
9492 "no half-deleted <br: {}",
9493 d.source
9494 );
9495 assert!(
9496 d.source.contains("| Pear |"),
9497 "cell back to one line: {}",
9498 d.source
9499 );
9500 }
9501
9502 #[test]
9503 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
9504 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
9505 // still consumed (a real newline would split the one-line row), but the
9506 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
9507 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
9508 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9509 d.caret = src.find("Pear").unwrap() + 4;
9510 assert!(d.caret_in_table(), "caret should be inside the djot table");
9511 assert!(
9512 d.cell_line_break(),
9513 "the key is consumed, not passed to the frontend"
9514 );
9515 assert_eq!(d.source, src, "the djot cell is left untouched");
9516 assert!(
9517 !d.source.contains("<br>"),
9518 "no non-idiomatic <br> spliced into djot"
9519 );
9520 assert!(
9521 d.status.is_some(),
9522 "the refusal is surfaced on the status line"
9523 );
9524 }
9525
9526 #[test]
9527 fn typing_in_a_cell_edits_that_cell() {
9528 // Editing comes free once offsets map correctly: the caret is a source
9529 // offset, so a normal splice lands inside the pipe table.
9530 let mut d = wysiwyg_doc("tbl_type", TABLE);
9531 d.caret = TABLE.find("Pear").unwrap() + 4;
9532 d.insert("s");
9533 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
9534 }
9535
9536 #[test]
9537 fn motion_and_delete_treat_an_emoji_as_one_character() {
9538 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
9539 // bytes, several codepoints. Right-arrow must clear it in one step, and
9540 // backspace must remove the whole cluster, not a stray joiner.
9541 let family = "👨👩👧";
9542 let mut d = doc_with("emoji", &format!("a{family}b\n"));
9543 d.caret = 1; // just after 'a', before the emoji
9544 d.move_right(false);
9545 assert_eq!(
9546 d.caret,
9547 1 + family.len(),
9548 "one step clears the whole cluster"
9549 );
9550 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
9551
9552 d.backspace(); // delete the emoji as a unit
9553 assert_eq!(d.source, "ab\n");
9554 assert_eq!(d.caret, 1);
9555 }
9556
9557 #[test]
9558 fn motion_handles_a_combining_accent_as_one_character() {
9559 // "e" + U+0301 (combining acute) renders as one é.
9560 let mut d = doc_with("combining", "e\u{0301}x\n");
9561 d.caret = 0;
9562 d.move_right(false);
9563 assert_eq!(
9564 d.caret,
9565 "e\u{0301}".len(),
9566 "steps past base + combining mark"
9567 );
9568 }
9569
9570 #[test]
9571 fn undo_then_redo_round_trips_an_edit() {
9572 let mut d = doc_with("undo", "hello\n");
9573 d.caret = 5;
9574 d.insert("!");
9575 assert_eq!(d.source, "hello!\n");
9576 d.undo();
9577 assert_eq!(d.source, "hello\n");
9578 assert_eq!(d.caret, 5, "undo restores the caret");
9579 d.redo();
9580 assert_eq!(d.source, "hello!\n");
9581 }
9582
9583 #[test]
9584 fn a_run_of_typing_undoes_as_one_step() {
9585 let mut d = doc_with("coalesce", "\n");
9586 d.caret = 0;
9587 d.insert("a");
9588 d.insert("b");
9589 d.insert("c");
9590 assert_eq!(d.source, "abc\n");
9591 d.undo(); // the whole typed run, not just "c"
9592 assert_eq!(d.source, "\n");
9593 d.undo(); // nothing left — the run was one step
9594 assert_eq!(d.source, "\n");
9595 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9596 }
9597
9598 // ── IME composition ──────────────────────────────────────────────────────
9599
9600 #[test]
9601 fn a_composition_run_undoes_as_one_step() {
9602 let mut d = doc_with("compose", "\n");
9603 d.caret = 0;
9604 // What an IME does: each step replaces the last one's provisional bytes.
9605 d.edit_composing(0, 0, "k");
9606 d.edit_composing(0, 1, "か");
9607 d.edit_composing(0, 3, "かん");
9608 d.edit_composing(0, 6, "感"); // the commit
9609 d.end_composition();
9610 assert_eq!(d.source, "感\n");
9611 d.undo(); // the whole composition, not its last keystroke
9612 assert_eq!(d.source, "\n");
9613 assert_eq!(d.status.as_deref(), None, "the run was a single step");
9614 }
9615
9616 #[test]
9617 fn two_compositions_are_two_undo_steps() {
9618 let mut d = doc_with("compose_two", "\n");
9619 d.caret = 0;
9620 d.edit_composing(0, 0, "か");
9621 d.edit_composing(0, 3, "蚊");
9622 d.end_composition();
9623 d.edit_composing(3, 3, "き");
9624 d.edit_composing(3, 6, "木");
9625 d.end_composition();
9626 assert_eq!(d.source, "蚊木\n");
9627 d.undo();
9628 assert_eq!(d.source, "蚊\n", "only the second composition");
9629 d.undo();
9630 assert_eq!(d.source, "\n");
9631 }
9632
9633 #[test]
9634 fn a_composition_does_not_fold_into_the_typing_around_it() {
9635 let mut d = doc_with("compose_typing", "\n");
9636 d.caret = 0;
9637 d.insert("a");
9638 d.insert("b");
9639 d.edit_composing(2, 2, "か");
9640 d.edit_composing(2, 5, "蚊");
9641 d.end_composition();
9642 d.insert("c");
9643 assert_eq!(d.source, "ab蚊c\n");
9644 d.undo();
9645 assert_eq!(d.source, "ab蚊\n");
9646 d.undo();
9647 assert_eq!(d.source, "ab\n");
9648 d.undo();
9649 assert_eq!(d.source, "\n");
9650 }
9651
9652 #[test]
9653 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
9654 let mut d = doc_with("compose_spurious", "\n");
9655 d.caret = 0;
9656 d.insert("a");
9657 d.end_composition(); // an IME unmarking unprompted
9658 d.insert("b");
9659 assert_eq!(d.source, "ab\n");
9660 d.undo();
9661 assert_eq!(d.source, "\n", "still one typed run");
9662 }
9663
9664 // ── the clipboard's rich flavor ──────────────────────────────────────────
9665
9666 #[test]
9667 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
9668 let mut d = doc_with("sel_inline", "a **bold** c\n");
9669 d.anchor = Some(2);
9670 d.caret = 10; // `**bold**`, inside the paragraph
9671 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
9672 }
9673
9674 #[test]
9675 fn a_whole_block_selection_keeps_its_paragraph() {
9676 let mut d = doc_with("sel_block", "a **bold** c\n");
9677 d.anchor = Some(0);
9678 d.caret = 12; // the entire paragraph
9679 assert_eq!(
9680 d.selection_html().as_deref(),
9681 Some("<p>a <strong>bold</strong> c</p>")
9682 );
9683 }
9684
9685 #[test]
9686 fn a_multi_block_selection_keeps_its_structure() {
9687 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
9688 d.select_all();
9689 let html = d.selection_html().expect("renders");
9690 assert!(html.contains("<p>para</p>"), "{html:?}");
9691 assert!(html.contains("<li>one</li>"), "{html:?}");
9692 }
9693
9694 #[test]
9695 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
9696 // The fragment `Head` is a paragraph standalone; the *document* says it
9697 // sits inside one block, so the wrapper is an artifact either way.
9698 let mut d = doc_with("sel_heading", "# Head line\n");
9699 d.anchor = Some(2);
9700 d.caret = 6;
9701 assert_eq!(d.selection_html().as_deref(), Some("Head"));
9702 }
9703
9704 #[test]
9705 fn no_selection_publishes_no_html() {
9706 let mut d = doc_with("sel_none", "a b\n");
9707 d.caret = 1;
9708 assert_eq!(d.selection_html(), None);
9709 }
9710
9711 #[test]
9712 fn pasting_html_converts_it_and_is_one_undo_step() {
9713 let mut d = doc_with("paste_html", "x\n");
9714 d.caret = 1;
9715 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
9716 assert_eq!(d.source, "xa **b** c\n");
9717 d.undo();
9718 assert_eq!(d.source, "x\n", "the whole paste, in one step");
9719 }
9720
9721 #[test]
9722 fn pasting_html_replaces_the_selection() {
9723 let mut d = doc_with("paste_html_sel", "keep drop\n");
9724 d.anchor = Some(5);
9725 d.caret = 9;
9726 assert!(d.paste_html("<em>new</em>"));
9727 assert_eq!(d.source, "keep *new*\n");
9728 }
9729
9730 #[test]
9731 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
9732 let mut d = doc_with("paste_html_bad", "x\n");
9733 d.caret = 1;
9734 // twig builds no table from HTML; raw `<table>` in prose is worse than
9735 // the plain flavor the caller still holds.
9736 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
9737 assert_eq!(d.source, "x\n", "declined edits nothing");
9738 }
9739
9740 #[test]
9741 fn copy_then_paste_round_trips_through_the_html_flavor() {
9742 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
9743 d.select_all();
9744 let html = d.selection_html().expect("renders");
9745 let mut into = doc_with("clip_round_dst", "\n");
9746 into.caret = 0;
9747 assert!(into.paste_html(&html));
9748 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
9749 }
9750
9751 #[test]
9752 fn moving_the_caret_starts_a_new_undo_group() {
9753 let mut d = doc_with("break", "\n");
9754 d.caret = 0;
9755 d.insert("a");
9756 d.insert("b"); // "ab\n", caret at 2
9757 d.move_left(false); // breaks the run
9758 d.insert("X"); // "aXb\n"
9759 assert_eq!(d.source, "aXb\n");
9760 d.undo();
9761 assert_eq!(
9762 d.source, "ab\n",
9763 "first undo removes only the post-move insert"
9764 );
9765 d.undo();
9766 assert_eq!(d.source, "\n", "second undo removes the earlier run");
9767 }
9768
9769 #[test]
9770 fn undo_reverses_a_format_toggle() {
9771 let mut d = doc_with("fmt_undo", "a word b\n");
9772 d.anchor = Some(2);
9773 d.caret = 6;
9774 d.toggle(InlineKind::Strong);
9775 assert_eq!(d.source, "a **word** b\n");
9776 d.undo();
9777 assert_eq!(d.source, "a word b\n");
9778 }
9779
9780 #[test]
9781 fn undo_back_to_the_saved_state_clears_dirty() {
9782 let mut d = doc_with("dirty_undo", "hello\n");
9783 assert!(!d.dirty);
9784 d.caret = 5;
9785 d.insert("!");
9786 assert!(d.dirty);
9787 d.undo();
9788 assert!(
9789 !d.dirty,
9790 "undoing to the saved source is not a modification"
9791 );
9792 }
9793
9794 #[test]
9795 fn a_new_edit_invalidates_redo() {
9796 let mut d = doc_with("redo_inv", "\n");
9797 d.caret = 0;
9798 d.insert("a");
9799 d.undo();
9800 d.insert("b"); // diverges — the redo of "a" is now gone
9801 d.redo();
9802 assert_eq!(d.source, "b\n");
9803 }
9804
9805 #[test]
9806 fn undo_on_empty_history_is_a_no_op() {
9807 let mut d = doc_with("undo_empty", "hi\n");
9808 d.undo();
9809 assert_eq!(d.source, "hi\n");
9810 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
9811 }
9812
9813 #[test]
9814 fn a_one_character_paste_is_its_own_undo_step() {
9815 for view in [View::Source, View::Wysiwyg] {
9816 let mut d = doc_in(view, "paste_step", "ab\n");
9817 d.caret = 0;
9818 d.insert("x");
9819 d.insert("y"); // a run of typing
9820 d.paste("z"); // one character, but pasted — not part of that run
9821 assert_eq!(d.source, "xyzab\n");
9822 d.undo();
9823 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
9824 assert_eq!(d.caret, 2, "and hands back the caret it found");
9825 d.undo();
9826 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
9827 }
9828 }
9829
9830 #[test]
9831 fn the_same_character_typed_still_joins_the_run() {
9832 // The other half of the pair: `z` is a keystroke here and a paste above,
9833 // and the two undo differently. Nothing about the *string* says which —
9834 // which is why provenance has to come from the door the caller uses.
9835 for view in [View::Source, View::Wysiwyg] {
9836 let mut d = doc_in(view, "typed_run", "ab\n");
9837 d.caret = 0;
9838 d.insert("x");
9839 d.insert("y");
9840 d.insert("z");
9841 d.undo();
9842 assert_eq!(d.source, "ab\n", "one run, one step");
9843 }
9844 }
9845
9846 #[test]
9847 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
9848 for view in [View::Source, View::Wysiwyg] {
9849 let mut d = doc_in(view, "undo_caret", "hello world\n");
9850 d.caret = 11; // standing at the end of "world", away from the edit
9851 d.edit(0, 5, "goodbye");
9852 assert_eq!(d.source, "goodbye world\n");
9853 d.undo();
9854 assert_eq!(d.source, "hello world\n");
9855 // The undone edit ends at offset 5; the user was at 11.
9856 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
9857 }
9858 }
9859
9860 #[test]
9861 fn undo_restores_the_selection_the_edit_replaced() {
9862 for view in [View::Source, View::Wysiwyg] {
9863 let mut d = doc_in(view, "undo_sel", "a word b\n");
9864 d.anchor = Some(2);
9865 d.caret = 6; // "word" selected
9866 d.insert("X");
9867 assert_eq!(d.source, "a X b\n");
9868 d.undo();
9869 assert_eq!(d.source, "a word b\n");
9870 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
9871 }
9872 }
9873
9874 #[test]
9875 fn redo_restores_the_caret_the_edit_left_behind() {
9876 for view in [View::Source, View::Wysiwyg] {
9877 let mut d = doc_in(view, "redo_caret", "hello world\n");
9878 d.caret = 11;
9879 d.edit(0, 5, "goodbye");
9880 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
9881 d.undo();
9882 d.redo();
9883 assert_eq!(d.source, "goodbye world\n");
9884 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
9885 }
9886 }
9887
9888 #[test]
9889 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
9890 for view in [View::Source, View::Wysiwyg] {
9891 let mut d = doc_in(view, "run_caret", "hi\n");
9892 d.caret = 2;
9893 d.insert("a");
9894 d.insert("b");
9895 d.insert("c");
9896 assert_eq!(d.source, "hiabc\n");
9897 d.undo();
9898 assert_eq!(d.source, "hi\n");
9899 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
9900 d.redo();
9901 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
9902 }
9903 }
9904
9905 #[test]
9906 fn undo_restores_the_caret_across_a_format_toggle() {
9907 // A toggle reaches twig without going through `splice`, so it has to
9908 // record its own step — miss it and every stack depth below it is off by
9909 // one, and undo starts handing back another edit's caret.
9910 for view in [View::Source, View::Wysiwyg] {
9911 let mut d = doc_in(view, "fmt_caret", "a word b\n");
9912 d.caret = 8;
9913 d.anchor = Some(2);
9914 d.caret = 6;
9915 d.toggle(InlineKind::Strong);
9916 assert_eq!(d.source, "a **word** b\n");
9917 d.undo();
9918 assert_eq!(d.source, "a word b\n");
9919 assert_eq!(
9920 d.selection(),
9921 Some((2, 6)),
9922 "the toggled selection comes back"
9923 );
9924 }
9925 }
9926
9927 #[test]
9928 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
9929 // The drift that would never announce itself: twig drops its redo stack
9930 // on any fresh edit, so a leaf redo entry that outlives it would restore
9931 // a caret from the timeline that edit abandoned.
9932 for view in [View::Source, View::Wysiwyg] {
9933 let mut d = doc_in(view, "redo_trunc", "hello world\n");
9934 d.caret = 11;
9935 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
9936 d.undo();
9937 assert_eq!(d.caret, 11);
9938 d.caret = 0;
9939 d.insert("X"); // diverges: A's redo is gone from twig
9940 assert_eq!(d.source, "Xhello world\n");
9941
9942 d.redo();
9943 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
9944 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
9945 d.undo();
9946 assert_eq!(d.source, "hello world\n");
9947 assert_eq!(
9948 d.caret, 0,
9949 "the surviving step's caret, not the dropped one"
9950 );
9951 }
9952 }
9953
9954 #[test]
9955 fn indent_and_outdent_move_the_caret_line_with_its_text() {
9956 for view in [View::Source, View::Wysiwyg] {
9957 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
9958 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
9959 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
9960 // Indentation the caret is standing *in* collapses to the line start
9961 // rather than dragging the caret into the text.
9962 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
9963 // A line with none to give back is left exactly as it was.
9964 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
9965 // Less than a full level gives back what it has.
9966 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
9967 // A tab is one level however many spaces it isn't.
9968 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
9969 }
9970 }
9971
9972 #[test]
9973 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
9974 // Why the level is two spaces and not the four both frontends type
9975 // today. Four is markdown's indented-code-block marker, so a Tab on a
9976 // paragraph would silently restyle it as code — a width that changes
9977 // what the document *means* isn't an indent. Pinned because the number
9978 // is the kind of thing a later list-aware pass would reach for.
9979 let mut d = doc_with("indent_kind", "hello\n");
9980 d.caret = 2;
9981 d.indent();
9982 assert_eq!(d.source, " hello\n");
9983 assert!(
9984 d.nodes().iter().any(|n| n.kind == Kind::Para),
9985 "still prose after a Tab"
9986 );
9987 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
9988
9989 // The four-space level this replaces, for contrast: same text, and twig
9990 // reparses the paragraph into a code block.
9991 let mut wide = doc_with("indent_kind_4", " hello\n");
9992 wide.build_visual(80);
9993 assert!(
9994 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
9995 "four spaces is a code block, not an indented paragraph"
9996 );
9997 }
9998
9999 #[test]
10000 fn indent_nests_a_list_item_under_its_parent() {
10001 // Tab indents a list item by its own marker width, landing its marker at
10002 // the parent's content column so twig reparses it as a nested list.
10003 for view in [View::Source, View::Wysiwyg] {
10004 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
10005 d.caret = 6; // on the second item
10006 d.indent();
10007 assert_eq!(d.source, "- a\n - b\n");
10008 let lists = d
10009 .nodes()
10010 .iter()
10011 .filter(|n| n.kind == Kind::BulletList)
10012 .count();
10013 assert_eq!(lists, 2, "the indented item is a nested list");
10014 }
10015 }
10016
10017 #[test]
10018 fn indent_nests_an_ordered_item_at_its_marker_width() {
10019 // An ordered marker `1. ` is three columns wide, so a two-space step
10020 // (which nests a bullet) leaves it flat. Regression: Tab must use the
10021 // marker width, three, so the item actually nests — and the source
10022 // renumbers so the sub-list restarts at 1 and the outer list resumes.
10023 for view in [View::Source, View::Wysiwyg] {
10024 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
10025 d.caret = d.source.find('b').unwrap();
10026 d.indent();
10027 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
10028 let lists = d
10029 .nodes()
10030 .iter()
10031 .filter(|n| n.kind == Kind::OrderedList)
10032 .count();
10033 assert_eq!(lists, 2, "the indented item is a nested ordered list");
10034 }
10035 }
10036
10037 #[test]
10038 fn indent_leaves_a_lists_first_item_put() {
10039 // The first item of a list has no sibling above it to nest under, so Tab
10040 // is a no-op there — the marker stays at column zero rather than being
10041 // shoved into indentation twig can't read as a sub-list.
10042 for view in [View::Source, View::Wysiwyg] {
10043 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
10044 d.caret = 1; // on the FIRST item
10045 d.indent();
10046 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
10047 // The sibling below still nests, proving the guard is per-item.
10048 d.caret = d.source.find('b').unwrap();
10049 d.indent();
10050 assert_eq!(d.source, "- a\n - b\n");
10051 }
10052 }
10053
10054 #[test]
10055 fn hidden_mode_keeps_typed_markup_literal() {
10056 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
10057 // twig escapes what would open markup, so the source is `\*hi\*` and the
10058 // AST is a plain string. Formatting is the commands' job in this mode.
10059 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
10060 d.insert("*hi*");
10061 assert_eq!(d.source, "\\*hi\\*");
10062 assert!(
10063 d.nodes()
10064 .iter()
10065 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
10066 );
10067 }
10068
10069 #[test]
10070 fn hidden_mode_escapes_a_line_start_block_marker() {
10071 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
10072 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
10073 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
10074 d.insert("# hi");
10075 assert_eq!(d.source, "\\# hi");
10076 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
10077 }
10078
10079 #[test]
10080 fn authoring_modes_keep_typed_markup_live() {
10081 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
10082 // (no escape), the same as source view — escaping is `None`'s alone, and
10083 // it's the axis, not the reveal, that decides.
10084 for (view, mode) in [
10085 (View::Wysiwyg, MarkupMode::Shortcuts),
10086 (View::Wysiwyg, MarkupMode::Full),
10087 (View::Source, MarkupMode::None),
10088 ] {
10089 let mut d = doc_in(view, "live_markup", "");
10090 d.set_markup_mode(mode);
10091 d.insert("*hi*");
10092 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
10093 }
10094 }
10095
10096 #[test]
10097 fn hidden_mode_overwrite_undoes_in_one_step() {
10098 // Typing over a selection escapes the replacement *and* stays a single
10099 // undo — the selection-delete and the literal insert fold together, so
10100 // one undo brings the whole selection back, like a plain overwrite.
10101 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
10102 d.anchor = Some(2);
10103 d.caret = 6; // "word"
10104 d.insert("*");
10105 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
10106 d.undo();
10107 assert_eq!(d.source, "a word b\n");
10108 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
10109 }
10110
10111 #[test]
10112 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
10113 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
10114 // the whole visual character, never stranding the hidden `\`.
10115 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
10116 d.insert("*");
10117 assert_eq!(d.source, "\\*");
10118 d.backspace();
10119 assert_eq!(d.source, "", "the escape backslash went with the *");
10120 // A *literal* backslash (source view, no escape) is an ordinary char.
10121 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
10122 s.caret = 3; // after `b`
10123 s.backspace();
10124 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
10125 }
10126
10127 #[test]
10128 fn hidden_mode_leaves_structural_markup_alone() {
10129 // Enter continues a bullet list by writing a real `- ` marker (an
10130 // `insert_raw`, not the typing path), so Hidden mode's escaping never
10131 // touches it — the list keeps working.
10132 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
10133 d.caret = 6;
10134 d.newline();
10135 d.insert("two");
10136 assert_eq!(d.source, "- item\n- two\n");
10137 }
10138
10139 #[test]
10140 fn markup_mode_defaults_to_none_and_round_trips() {
10141 // Diaryx's default is the clean `None` surface; a markup-fluent
10142 // frontend can climb the ladder, and the choice sticks.
10143 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
10144 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
10145 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
10146 d.set_markup_mode(mode);
10147 assert_eq!(d.markup_mode(), mode);
10148 }
10149 }
10150
10151 #[test]
10152 fn full_mode_reveals_only_the_caret_line() {
10153 // The mode's whole claim: the caret's line shows its raw delimiters and
10154 // every other line stays resolved. Two paragraphs with identical markup
10155 // so the only difference between the rows is where the caret is.
10156 let mut d = doc_in(
10157 View::Wysiwyg,
10158 "reveal_caret_line",
10159 "*one* here\n\n*two* there\n",
10160 );
10161 d.set_markup_mode(MarkupMode::Full);
10162
10163 caret_at(&mut d, "one");
10164 let rows = drawn_rows(&d);
10165 assert!(
10166 rows.iter().any(|r| r == "*one* here"),
10167 "caret's line raw: {rows:?}"
10168 );
10169 assert!(
10170 rows.iter().any(|r| r == "two there"),
10171 "other line resolved: {rows:?}"
10172 );
10173
10174 // Move to the other paragraph: the reveal follows, and the line just
10175 // left goes back to being resolved.
10176 caret_at(&mut d, "two");
10177 let rows = drawn_rows(&d);
10178 assert!(
10179 rows.iter().any(|r| r == "*two* there"),
10180 "caret's line raw: {rows:?}"
10181 );
10182 assert!(
10183 rows.iter().any(|r| r == "one here"),
10184 "left line resolved: {rows:?}"
10185 );
10186 }
10187
10188 #[test]
10189 fn hidden_modes_never_reveal_wherever_the_caret_is() {
10190 // The two rungs below `Full` share a rendering: delimiters stay hidden
10191 // even under the caret. `Shortcuts` differing from `None` only in what
10192 // typing does is exactly the point of splitting the axes.
10193 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
10194 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
10195 d.set_markup_mode(mode);
10196 caret_at(&mut d, "one");
10197 let rows = drawn_rows(&d);
10198 assert!(
10199 rows.iter().any(|r| r == "one here"),
10200 "{mode:?} hides: {rows:?}"
10201 );
10202 assert!(
10203 !rows.iter().any(|r| r.contains('*')),
10204 "{mode:?} shows no `*`: {rows:?}"
10205 );
10206 }
10207 }
10208
10209 #[test]
10210 fn revealed_delimiters_are_the_authors_own_spelling() {
10211 // Delimiters are re-read from the source rather than synthesized per
10212 // kind, so a line comes back spelled the way it was written: `_em_` does
10213 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
10214 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
10215 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
10216 d.set_markup_mode(MarkupMode::Full);
10217 caret_at(&mut d, "em");
10218 let rows = drawn_rows(&d);
10219 assert!(
10220 rows.iter().any(|r| r == body.trim_end()),
10221 "the revealed line is its own source: {rows:?}"
10222 );
10223 }
10224
10225 #[test]
10226 fn revealed_heading_shows_its_hashes() {
10227 // The `# ` marker is a block-level prefix, not an inline delimiter, so
10228 // it takes its own path — but it reveals on the same rule.
10229 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
10230 d.set_markup_mode(MarkupMode::Full);
10231
10232 caret_at(&mut d, "Title");
10233 assert!(
10234 drawn_rows(&d).iter().any(|r| r == "# Title"),
10235 "{:?}",
10236 drawn_rows(&d)
10237 );
10238
10239 caret_at(&mut d, "body");
10240 let rows = drawn_rows(&d);
10241 assert!(
10242 rows.iter().any(|r| r == "Title"),
10243 "hashes hidden again: {rows:?}"
10244 );
10245 }
10246
10247 #[test]
10248 fn revealed_delimiters_are_caret_stops() {
10249 // A delimiter that is drawn but can't be reached is worse than one
10250 // that's hidden: the mode exists so the markup can be *edited*. Every
10251 // revealed byte must be somewhere the caret can stand.
10252 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
10253 d.set_markup_mode(MarkupMode::Full);
10254 caret_at(&mut d, "em");
10255 let opener = d.source.find('*').unwrap();
10256 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
10257 assert!(
10258 d.vmap.is_stop(opener + 3),
10259 "the closing `*` is a caret stop"
10260 );
10261 }
10262
10263 #[test]
10264 fn setext_heading_reveals_nothing_across_its_newline() {
10265 // A setext heading's underline is on another line, so it is not the
10266 // caret line's to reveal — and emitting it would inject a `\n` glyph
10267 // that splits the row where the author wrote no break.
10268 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
10269 d.set_markup_mode(MarkupMode::Full);
10270 caret_at(&mut d, "Title");
10271 let rows = drawn_rows(&d);
10272 assert!(
10273 rows.iter().any(|r| r == "Title"),
10274 "title renders alone: {rows:?}"
10275 );
10276 assert!(
10277 !rows.iter().any(|r| r.contains('=')),
10278 "no underline leaks in: {rows:?}"
10279 );
10280 }
10281
10282 #[test]
10283 fn markup_mode_axes_split_the_ladder() {
10284 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
10285 // that authors markup but still hides it, and it's the only rung where
10286 // the two axes disagree.
10287 assert!(!MarkupMode::None.authors());
10288 assert!(!MarkupMode::None.reveals_caret_line());
10289 assert!(MarkupMode::Shortcuts.authors());
10290 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
10291 assert!(MarkupMode::Full.authors());
10292 assert!(MarkupMode::Full.reveals_caret_line());
10293 }
10294
10295 #[test]
10296 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
10297 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
10298 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
10299 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
10300 // nested bullet and `hello` stays prose: the file round-trips instead of
10301 // hiding a heading the user never asked for.
10302 for view in [View::Source, View::Wysiwyg] {
10303 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
10304 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
10305 d.indent();
10306 assert_eq!(d.source, "- hello\n * \n");
10307 assert!(
10308 d.nodes().iter().all(|n| n.kind != Kind::Heading),
10309 "no heading"
10310 );
10311 // And it's genuinely a nested list, not a flat one.
10312 assert_eq!(
10313 d.nodes()
10314 .iter()
10315 .filter(|n| n.kind == Kind::BulletList)
10316 .count(),
10317 2
10318 );
10319 }
10320 }
10321
10322 #[test]
10323 fn indenting_a_dash_item_with_content_keeps_its_dash() {
10324 // With content, `- x` can't be a setext underline, so there's nothing to
10325 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
10326 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
10327 d.caret = d.source.find('x').unwrap();
10328 d.indent();
10329 assert_eq!(d.source, "- hello\n - x\n");
10330 }
10331
10332 #[test]
10333 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
10334 // The dash→`*` repair coalesces into the Tab, so a single undo restores
10335 // the whole pre-Tab state rather than stranding a half-collapsed doc.
10336 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
10337 d.caret = d.source.find("- \n").unwrap() + 2;
10338 d.indent();
10339 assert_eq!(d.source, "- hello\n * \n");
10340 d.undo();
10341 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
10342 }
10343
10344 #[test]
10345 fn indent_leaves_a_nested_lists_first_item_put_too() {
10346 // The guard is about siblings, not depth: the first item of an *inner*
10347 // list (already nested under `a`) still has nothing before it at its own
10348 // level, so Tab can't take it deeper.
10349 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
10350 d.caret = d.source.find('b').unwrap();
10351 d.indent();
10352 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
10353 // But `c` (a sibling of `b`) nests under `b`.
10354 d.caret = d.source.find('c').unwrap();
10355 d.indent();
10356 assert_eq!(d.source, "- a\n - b\n - c\n");
10357 }
10358
10359 #[test]
10360 fn backspace_at_a_nested_item_start_outdents_it() {
10361 // Backspace with the caret right after a nested item's marker gives back
10362 // one level of nesting, the mirror of Tab — and renumbers the flattened
10363 // ordered list back to a clean run.
10364 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
10365 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
10366 d.backspace();
10367 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10368 }
10369
10370 #[test]
10371 fn backspace_at_a_top_level_item_start_strips_the_marker() {
10372 // At the outermost level there's no nesting left to give back, so the same
10373 // keystroke drops the bullet and leaves a plain paragraph.
10374 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
10375 d.caret = d.source.find('b').unwrap(); // right after `- `
10376 d.backspace();
10377 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
10378 }
10379
10380 #[test]
10381 fn backspace_mid_item_still_deletes_a_character() {
10382 // The list behaviour is armed only at the item's content start; anywhere
10383 // else Backspace is the ordinary character delete.
10384 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
10385 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
10386 d.backspace();
10387 assert_eq!(d.source, "- b\n");
10388 }
10389
10390 #[test]
10391 fn backspace_at_a_heading_start_strips_the_marker() {
10392 // The `# ` is markup the rich view hides, so Backspace over it takes the
10393 // whole marker and leaves a paragraph. Deleting a byte of it instead left
10394 // `#Title` — no longer a heading, with the hash now literal text the user
10395 // never typed and has to delete again.
10396 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
10397 d.caret = d.source.find('T').unwrap(); // right after `## `
10398 d.backspace();
10399 assert_eq!(d.source, "Title\n");
10400 assert_eq!(
10401 d.caret, 0,
10402 "the caret stays with the text it was in front of"
10403 );
10404 }
10405
10406 #[test]
10407 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
10408 // Only the heading's own marker goes — the quote (or list) it sits in is
10409 // untouched, exactly as un-heading it should be.
10410 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
10411 d.caret = d.source.find('T').unwrap();
10412 d.backspace();
10413 assert_eq!(d.source, "> Title\n");
10414 }
10415
10416 #[test]
10417 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
10418 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
10419 // behind would surface the same stray hash the marker delete just avoided.
10420 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
10421 d.caret = d.source.find('T').unwrap();
10422 d.backspace();
10423 assert_eq!(d.source, "Title\n");
10424 // And it's one edit: a single undo puts the whole heading back.
10425 d.undo();
10426 assert_eq!(d.source, "# Title #\n");
10427 }
10428
10429 #[test]
10430 fn backspace_mid_heading_still_deletes_a_character() {
10431 // The heading behaviour is armed only at the content's start; anywhere
10432 // else Backspace is the ordinary character delete.
10433 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
10434 d.caret = d.source.find('b').unwrap();
10435 d.backspace();
10436 assert_eq!(d.source, "# b\n");
10437 }
10438
10439 #[test]
10440 fn source_view_backspace_still_edits_the_heading_marker_literally() {
10441 // In source view the `# ` is text on the screen the user is deleting a
10442 // byte of, so it keeps its literal meaning — the same split the list
10443 // ladder and Enter draw between the two views.
10444 let mut d = doc_with("bsp_head_src", "# Title\n");
10445 d.caret = d.source.find('T').unwrap();
10446 d.backspace();
10447 assert_eq!(d.source, "#Title\n");
10448 }
10449
10450 #[test]
10451 fn outdent_unnests_an_ordered_item_in_one_press() {
10452 // Shift+Tab gives back exactly the marker width the indent added, so a
10453 // nested ordered item unnests in a single press, and the flattened list
10454 // renumbers back to a clean 1, 2, 3.
10455 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
10456 d.caret = d.source.find('b').unwrap();
10457 d.outdent();
10458 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10459 let lists = d
10460 .nodes()
10461 .iter()
10462 .filter(|n| n.kind == Kind::OrderedList)
10463 .count();
10464 assert_eq!(lists, 1, "back to one flat list");
10465 }
10466
10467 #[test]
10468 fn table_insert_row_adds_a_row_below_the_caret() {
10469 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10470 d.caret = d.source.find('1').unwrap(); // in the body row
10471 d.table_insert_row(true);
10472 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
10473 }
10474
10475 #[test]
10476 fn table_insert_and_delete_column_at_the_caret() {
10477 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10478 d.caret = d.source.find('a').unwrap(); // column 0
10479 d.table_insert_column(true); // add a column to the right of `a`
10480 assert_eq!(
10481 d.source,
10482 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
10483 );
10484 d.caret = d.source.find('b').unwrap(); // now the third column
10485 d.table_delete_column();
10486 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
10487 }
10488
10489 // ── ragged formats ───────────────────────────────────────────────────────
10490 // No format spells every gesture. HTML writes the inline marks as a tag pair
10491 // and no heading, list, quote or link; Markdown spells three of the eight
10492 // marks; djot spells all eight and no in-cell break. leaf asks twig per
10493 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
10494 // op discover the fact on its own — one of them didn't.
10495
10496 /// An HTML document in the rich view, ready for a gesture.
10497 fn html_doc(body: &str) -> Doc {
10498 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
10499 d.view = View::Wysiwyg;
10500 d.build_visual(80);
10501 d
10502 }
10503
10504 #[test]
10505 fn a_table_gesture_leaves_an_html_table_alone() {
10506 // The regression this guard exists for. twig's table editor consults no
10507 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
10508 // HTML `<table>` as a *pipe table* and reported success: the whole
10509 // element replaced by `| a | b |`, silently, on one press of a toolbar
10510 // button. Every grid op went the same way.
10511 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
10512 // A table of named operations, which is what it looks like.
10513 #[allow(clippy::type_complexity)]
10514 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
10515 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
10516 ("delete row", &|d: &mut Doc| d.table_delete_row()),
10517 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
10518 ("delete column", &|d: &mut Doc| d.table_delete_column()),
10519 ("align", &|d: &mut Doc| {
10520 d.table_set_alignment(Alignment::Right)
10521 }),
10522 ("move row", &|d: &mut Doc| d.table_move_row(true)),
10523 ("move column", &|d: &mut Doc| d.table_move_column(true)),
10524 ];
10525 for (name, op) in ops {
10526 let mut d = html_doc(src);
10527 d.caret = d.source.find('a').unwrap();
10528 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
10529 op(&mut d);
10530 assert_eq!(d.source, src, "{name} rewrote an HTML table");
10531 assert!(
10532 !d.dirty,
10533 "{name} marked the document dirty without editing it"
10534 );
10535 assert!(d.status.is_some(), "{name} refused without saying why");
10536 }
10537 }
10538
10539 #[test]
10540 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
10541 // A heading is a wrapping tag pair carrying its level in both ends, a
10542 // quote wraps a range rather than prefixing each line, a link's
10543 // destination lives in an attribute — different *shapes*, not a
10544 // different alphabet, so twig spells none of them and neither does leaf.
10545 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
10546 // A table of named operations, which is what it looks like.
10547 #[allow(clippy::type_complexity)]
10548 let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
10549 ("heading", &|d: &mut Doc| d.toggle_heading(2)),
10550 ("paragraph", &|d: &mut Doc| {
10551 d.set_block(BlockKind::Paragraph)
10552 }),
10553 ("quote", &|d: &mut Doc| d.toggle_blockquote()),
10554 ("list", &|d: &mut Doc| d.toggle_list(false)),
10555 ("task item", &|d: &mut Doc| d.toggle_task_item()),
10556 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
10557 ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
10558 ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
10559 ("video", &|d: &mut Doc| {
10560 d.insert_media(MediaKind::Video, "clip.mp4", "")
10561 }),
10562 ];
10563 for (name, op) in ops {
10564 let mut d = html_doc(src);
10565 let at = d.source.find("Hello").unwrap();
10566 d.caret = at;
10567 d.anchor = Some(at + 5); // a selection, for the ops that want one
10568 op(&mut d);
10569 assert_eq!(d.source, src, "{name} edited an HTML document");
10570 assert!(
10571 !d.dirty,
10572 "{name} marked the document dirty without editing it"
10573 );
10574 let status = d.status.as_deref().unwrap_or("");
10575 assert!(
10576 status.contains("html"),
10577 "{name}: the refusal should name the format, got {status:?}"
10578 );
10579 }
10580 }
10581
10582 #[test]
10583 fn html_spells_the_inline_marks_and_the_rule() {
10584 // The other half, and why one per-document flag stopped being enough:
10585 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
10586 // already emits and the parser reads straight back as the same mark —
10587 // and the rule button writes an `<hr>`. Refusing these on the old
10588 // "HTML is parse-only" reading would now be leaf's own limitation.
10589 let mut d = html_doc("<p>Hello world</p>\n");
10590 let at = d.source.find("world").unwrap();
10591 d.caret = at;
10592 d.anchor = Some(at + 5);
10593 d.toggle(InlineKind::Strong);
10594 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
10595 assert!(d.dirty);
10596 assert_eq!(d.status, None, "a supported gesture reports nothing");
10597
10598 // And off again — the toggle reverses, which is the property that makes
10599 // authoring in HTML worth offering rather than a one-way trip.
10600 d.toggle(InlineKind::Strong);
10601 assert_eq!(d.source, "<p>Hello world</p>\n");
10602
10603 let mut d = html_doc("<p>Hello world</p>\n");
10604 d.caret = d.source.find("world").unwrap();
10605 d.insert_thematic_break();
10606 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
10607 }
10608
10609 #[test]
10610 fn a_mark_the_format_cannot_spell_arms_nothing() {
10611 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
10612 // sticky mark for the next text typed. Guarding only the twig call
10613 // leaves that path live, promising a highlight Markdown will never spell
10614 // and then swallowing the error inside `insert`. Markdown carries the
10615 // case now that HTML spells `<mark>`: `==mark==` is djot's alone.
10616 let mut d = doc_with("mark", "Hello world\n");
10617 d.view = View::Wysiwyg;
10618 d.build_visual(80);
10619 d.caret = d.source.find("world").unwrap();
10620 d.toggle(InlineKind::Mark);
10621 assert!(d.pending_marks.is_empty(), "no mark should be armed");
10622 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
10623 d.insert("X");
10624 assert_eq!(d.source, "Hello Xworld\n");
10625 }
10626
10627 #[test]
10628 fn html_documents_still_take_typed_text() {
10629 // The guard covers *markup* gestures and must not touch plain editing:
10630 // twig's splicer is language-neutral, and typing into an HTML document
10631 // is the thing that does work today.
10632 let mut d = html_doc("<p>Hello world</p>\n");
10633 d.caret = d.source.find("world").unwrap();
10634 d.insert("big ");
10635 assert_eq!(d.source, "<p>Hello big world</p>\n");
10636 assert!(d.dirty);
10637 d.backspace();
10638 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
10639 d.undo();
10640 d.undo();
10641 assert_eq!(d.source, "<p>Hello world</p>\n");
10642 }
10643
10644 #[test]
10645 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
10646 // `authorable` only separates "there is a door in" from "there is not",
10647 // and HTML is on the near side of that line — which is exactly why a
10648 // toolbar must not be built from it.
10649 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
10650 assert!(html.authorable());
10651 assert!(
10652 !Doc::from_source("<r>x</r>".into(), Format::Xml)
10653 .unwrap()
10654 .authorable()
10655 );
10656
10657 let caps = html.capabilities();
10658 assert!(caps.bold && caps.italic && caps.code && caps.mark);
10659 assert!(caps.thematic_break && caps.cell_line_break);
10660 assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
10661 assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
10662 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
10663 // twig's table editor would happily re-emit as `| a | b |`.
10664 assert!(!caps.table);
10665
10666 // The two lightweight formats spell everything leaf offers — and still
10667 // differ from each other, which is the other half of why one boolean
10668 // can't serve.
10669 for fmt in [Format::Markdown, Format::Djot] {
10670 let caps = Capabilities::of(fmt);
10671 assert!(
10672 caps.heading && caps.blockquote && caps.ordered_list,
10673 "{fmt:?}"
10674 );
10675 assert!(
10676 caps.task && caps.link && caps.image && caps.table,
10677 "{fmt:?}"
10678 );
10679 }
10680 assert!(Capabilities::of(Format::Djot).mark);
10681 assert!(!Capabilities::of(Format::Markdown).mark);
10682 assert!(Capabilities::of(Format::Markdown).cell_line_break);
10683 assert!(!Capabilities::of(Format::Djot).cell_line_break);
10684
10685 // A parse-only format answers no to every one of them, so the coarse
10686 // predicate and the record agree there.
10687 let caps = Capabilities::of(Format::Xml);
10688 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
10689 }
10690
10691 #[test]
10692 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
10693 // The guard exists to name the *document's* format rather than twig's
10694 // internals, so the message has to survive being one leaf writes itself.
10695 // Checked against the gesture twig also refuses, since that is the pair
10696 // most at risk of drifting apart.
10697 let mut d = html_doc("<p>Hello</p>\n");
10698 d.caret = d.source.find("Hello").unwrap();
10699 d.set_code_language("zig");
10700 assert_eq!(
10701 d.status.as_deref(),
10702 Some("code language: not supported in html")
10703 );
10704 assert!(!d.dirty);
10705 }
10706
10707 #[test]
10708 fn table_set_alignment_respells_the_delimiter() {
10709 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10710 d.caret = d.source.find('b').unwrap();
10711 d.table_set_alignment(Alignment::Right);
10712 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
10713 }
10714
10715 #[test]
10716 fn each_empty_table_cell_has_its_own_editable_home() {
10717 // Regression: an empty cell has no twig content_span, so both cells of a
10718 // `| | |` row collapsed onto the row's start (before the first `│`).
10719 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
10720 // tell the cells apart. Each empty cell must now have a distinct home
10721 // inside it.
10722 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
10723 let (c0, c1) = {
10724 let cells = &d.vmap.tables[0].grid[1].cells;
10725 (cells[0].start, cells[1].start)
10726 };
10727 assert!(
10728 c0 < c1,
10729 "the two empty cells have distinct homes: {c0} < {c1}"
10730 );
10731 d.caret = c0;
10732 d.insert("x");
10733 assert_eq!(
10734 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
10735 "typed inside the cell"
10736 );
10737 }
10738
10739 #[test]
10740 fn arrows_step_into_each_empty_table_cell() {
10741 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
10742 let (c0, c1) = {
10743 let cells = &d.vmap.tables[0].grid[1].cells;
10744 (cells[0].start, cells[1].start)
10745 };
10746 d.caret = d.source.find('b').unwrap(); // in the header's second cell
10747 let mut seen = std::collections::HashSet::new();
10748 for _ in 0..6 {
10749 d.move_right(false);
10750 seen.insert(d.caret);
10751 }
10752 assert!(
10753 seen.contains(&c0),
10754 "right arrow reaches the first empty cell"
10755 );
10756 assert!(
10757 seen.contains(&c1),
10758 "right arrow reaches the second empty cell"
10759 );
10760 }
10761
10762 #[test]
10763 fn table_op_off_a_table_is_a_no_op_with_a_status() {
10764 let mut d = doc_with("tbl_none", "just text\n");
10765 d.caret = 3;
10766 d.table_insert_row(true);
10767 assert_eq!(d.source, "just text\n", "nothing changed");
10768 assert!(d.status.is_some(), "a status explains why");
10769 assert!(!d.caret_in_table());
10770 }
10771
10772 #[test]
10773 fn enter_in_an_ordered_list_renumbers_the_following_items() {
10774 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
10775 // the renumber pass keeps them sequential, matching what the view draws.
10776 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
10777 d.caret = d.source.find('a').unwrap() + 1; // end of item a
10778 d.newline();
10779 d.insert("x");
10780 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
10781 }
10782
10783 #[test]
10784 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
10785 for view in [View::Source, View::Wysiwyg] {
10786 let mut d = doc_in(view, "outdent_noop", "hello\n");
10787 d.caret = 2;
10788 d.outdent();
10789 assert_eq!(d.source, "hello\n");
10790 assert!(!d.dirty, "a no-op is not a modification");
10791 d.undo();
10792 assert_eq!(
10793 d.status.as_deref(),
10794 Some("nothing to undo"),
10795 "spends no undo step"
10796 );
10797 assert_eq!(d.source, "hello\n");
10798 }
10799 }
10800
10801 #[test]
10802 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
10803 for view in [View::Source, View::Wysiwyg] {
10804 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
10805 d.anchor = Some(0);
10806 d.caret = 7; // through "two"
10807 d.indent();
10808 assert_eq!(
10809 d.source, " one\n\n two\n",
10810 "the blank line keeps no trailing pad"
10811 );
10812 // Selected, so a second Tab lands on the same lines rather than on
10813 // whatever the shifted offsets now cover.
10814 assert_eq!(d.selection(), Some((0, 12)));
10815 d.indent();
10816 assert_eq!(d.source, " one\n\n two\n");
10817 }
10818 }
10819
10820 #[test]
10821 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
10822 for view in [View::Source, View::Wysiwyg] {
10823 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
10824 d.anchor = Some(0);
10825 d.caret = 15;
10826 d.outdent();
10827 assert_eq!(d.source, "two\none\nnone\n");
10828 }
10829 }
10830
10831 #[test]
10832 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
10833 for view in [View::Source, View::Wysiwyg] {
10834 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
10835 d.anchor = Some(0);
10836 d.caret = 7;
10837 d.indent();
10838 assert_eq!(d.source, " one\n\n two\n");
10839 d.undo();
10840 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
10841 assert_eq!(
10842 d.selection(),
10843 Some((0, 7)),
10844 "with the selection it was aimed at"
10845 );
10846 d.redo();
10847 assert_eq!(d.source, " one\n\n two\n");
10848 assert_eq!(
10849 d.selection(),
10850 Some((0, 12)),
10851 "redo replays the caret the indent placed, not the one splice left"
10852 );
10853 }
10854 }
10855
10856 #[test]
10857 fn vertical_motion_keeps_the_column() {
10858 let mut d = doc_with("move", "abcd\nef\n");
10859 d.caret = 3; // "abc|d" on row 0, col 3
10860 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
10861 assert_eq!(d.caret, 7); // just after "ef"
10862 }
10863
10864 // ── goal column ──────────────────────────────────────────────────────────
10865
10866 #[test]
10867 fn vertical_motion_goal_column_survives_a_short_line() {
10868 // Regression: re-deriving the column from the clamped position on
10869 // every step permanently forgets it once a short line clamps it.
10870 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
10871 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
10872 assert_eq!(
10873 g("abcd|ef\nxy\nghijkl\n", |d| {
10874 d.move_down(false); // clamps to end of "xy"
10875 d.move_down(false); // restores col 4 on the long line
10876 }),
10877 "abcdef\nxy\nghij|kl\n"
10878 );
10879 }
10880
10881 #[test]
10882 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
10883 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
10884 assert_eq!(d.goal_col, None);
10885 d.caret = 4; // row 0, col 4
10886 d.move_down(false); // clamps into "xy"; goal stays the original col
10887 assert_eq!(d.goal_col, Some(4));
10888 assert_eq!(d.caret_pos(), (1, 2));
10889
10890 // A horizontal motion drops the goal column...
10891 d.move_left(false);
10892 assert_eq!(d.goal_col, None);
10893
10894 // ...so the next vertical motion picks up the *new* column (1), not
10895 // the stale one (4).
10896 d.move_down(false);
10897 assert_eq!(d.goal_col, Some(1));
10898 assert_eq!(d.caret_pos(), (2, 1));
10899 }
10900
10901 #[test]
10902 fn editing_clears_the_goal_column() {
10903 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
10904 d.caret = 4;
10905 d.move_down(false);
10906 assert_eq!(d.goal_col, Some(4));
10907 d.insert("Z");
10908 assert_eq!(d.goal_col, None);
10909 }
10910
10911 #[test]
10912 fn vertical_motion_on_an_empty_document_is_a_no_op() {
10913 let mut d = doc_with("empty_vert", "");
10914 d.move_down(false);
10915 assert_eq!(d.caret, 0);
10916 d.move_up(false);
10917 assert_eq!(d.caret, 0);
10918 }
10919
10920 // ── the document's edges ─────────────────────────────────────────────────
10921
10922 #[test]
10923 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
10924 // The reproduction, and the disagreement: Down on the last line ran to
10925 // the end of the document in the source view — by accident, an
10926 // out-of-range row clamping to the end of the string — and did nothing
10927 // whatever in the view leaf opens in. One rule now, in both.
10928 for (view, tag) in VIEWS {
10929 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
10930 d.caret = 1;
10931 d.move_down(false);
10932 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
10933 d.move_up(false);
10934 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
10935 }
10936 }
10937
10938 #[test]
10939 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
10940 // Down off the bottom is a motion like any other, so it latches a goal
10941 // column — and Up comes back to the column the caret left, not to the
10942 // one the document's end happened to be in.
10943 for (view, tag) in VIEWS {
10944 let gap = if view == View::Source { "\n" } else { "\n\n" };
10945 let src = format!("abcdef{gap}ghijkl");
10946 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
10947 d.caret = 2; // row 0, col 2
10948 d.move_down(false);
10949 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
10950 d.move_down(false);
10951 assert_eq!(
10952 d.caret,
10953 src.len(),
10954 "{tag}: Down off the bottom reaches the end"
10955 );
10956 d.move_up(false);
10957 assert_eq!(
10958 d.caret_pos().1,
10959 2,
10960 "{tag}: Up returns to the column Down left"
10961 );
10962 }
10963 }
10964
10965 #[test]
10966 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
10967 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
10968 // Up that did nothing still armed a goal column, and the next Down aimed
10969 // at a column the caret had never been in.
10970 for (view, tag) in VIEWS {
10971 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
10972 d.caret = 0;
10973 d.move_up(false);
10974 assert_eq!(d.caret, 0, "{tag}: already at the start");
10975 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
10976
10977 d.caret = d.source.len();
10978 d.move_down(false);
10979 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
10980 assert_eq!(
10981 d.goal_col, None,
10982 "{tag}: a no-op Down latched a goal column"
10983 );
10984 }
10985 }
10986
10987 // ── soft wrap ────────────────────────────────────────────────────────────
10988 // Every other test here builds the map at 80 columns, where no fixture is
10989 // long enough to fold. A wrap is where one offset belongs to two rows at
10990 // once, and it broke everything that asks the caret what row it is on.
10991
10992 /// The wrapped fixture these cases share, folded at 12 columns into
10993 /// `one two ` / `three four ` / `five six ` / `seven eight`.
10994 fn wrapped_doc(name: &str) -> Doc {
10995 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
10996 d.build_visual(12);
10997 d
10998 }
10999
11000 #[test]
11001 fn home_and_end_work_from_a_wrapped_row() {
11002 // The reproduction: offset 19 is the `f` of "five", the first character
11003 // of the third row — and also the offset the second row ends at. It
11004 // resolved to the *second* row, so End aimed at a place the caret was
11005 // already in and did nothing, while Home walked backwards onto a row the
11006 // caret had left.
11007 let mut d = wrapped_doc("wrap_home_end");
11008 d.caret = 19;
11009 assert_eq!(
11010 d.caret_pos(),
11011 (2, 0),
11012 "the wrap boundary opens the third row"
11013 );
11014 d.move_end(false);
11015 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
11016 d.move_home(false);
11017 assert_eq!(d.caret, 19, "Home left the row the caret was on");
11018 }
11019
11020 #[test]
11021 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
11022 // The row's end is the last offset that is only ever its own: the offset
11023 // past it opens the row below, and aiming there would send a second
11024 // press on to *that* row's end, and a third to the next — End walking
11025 // down the paragraph rather than sitting where it landed.
11026 let mut d = wrapped_doc("wrap_end_twice");
11027 d.caret = 12; // inside "three", on the second row
11028 d.move_end(false);
11029 assert_eq!(
11030 d.caret, 18,
11031 "the end of `three four`, before the space the wrap ate"
11032 );
11033 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
11034 d.move_end(false);
11035 assert_eq!(d.caret, 18, "a second End moved the caret");
11036 d.move_home(false);
11037 assert_eq!(d.caret, 8, "Home takes the row's own start");
11038 }
11039
11040 #[test]
11041 fn vertical_motion_crosses_a_soft_wrap() {
11042 // Down aimed at the row below's column 0, an offset that resolved *up*
11043 // to the row above's end — so it landed on the offset it already had and
11044 // the caret could never leave a paragraph's first row.
11045 let mut d = wrapped_doc("wrap_down");
11046 d.caret = 0;
11047 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
11048 d.move_down(false);
11049 assert_eq!(d.caret, want, "Down stalled");
11050 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
11051 }
11052 d.move_down(false);
11053 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
11054
11055 // ...and back up, one row per press. The goal column is the end of the
11056 // last row, past every other row's width, so each press clamps to the
11057 // row's own last offset rather than to the one that opens the next.
11058 let mut d = wrapped_doc("wrap_up");
11059 d.caret = 39;
11060 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
11061 d.move_up(false);
11062 assert_eq!(d.caret, want, "Up stalled");
11063 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
11064 }
11065 }
11066
11067 #[test]
11068 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
11069 // The kills take the same line Home and End do, so in WYSIWYG they take
11070 // the visual row — and a soft wrap has no newline in it to delete, so
11071 // nothing is joined by reaching the end of one.
11072 let mut d = wrapped_doc("wrap_kill");
11073 d.caret = 19; // the `f` of "five", opening the third row
11074 d.delete_to_line_end();
11075 // The space the wrap ate goes with the row it was drawn on: sparing it
11076 // would leave "four seven", two spaces where the row had been.
11077 assert_eq!(d.source, "one two three four seven eight");
11078
11079 // Backwards from the row's last caret position — which is *before* that
11080 // space, so this one survives, being on the far side of the caret.
11081 let mut d = wrapped_doc("wrap_kill_back");
11082 d.caret = 27;
11083 d.delete_to_line_start();
11084 assert_eq!(d.source, "one two three four seven eight");
11085 }
11086
11087 // ── document start / end ────────────────────────────────────────────────
11088
11089 #[test]
11090 fn move_doc_start_and_end_jump_to_the_edges() {
11091 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
11092 assert_eq!(
11093 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
11094 "|hello\nworld\n"
11095 );
11096 assert_eq!(
11097 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
11098 "hello\nworld\n|"
11099 );
11100 // Already at the edge: a no-op.
11101 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
11102 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
11103 }
11104
11105 #[test]
11106 fn move_doc_start_and_end_extend_the_selection() {
11107 assert_eq!(
11108 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
11109 .move_doc_end(true)),
11110 "hello wor[ld\n|]"
11111 );
11112 assert_eq!(
11113 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
11114 .move_doc_start(true)),
11115 "[|hello wor]ld\n"
11116 );
11117 }
11118
11119 #[test]
11120 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
11121 let mut d = doc_with("empty_edges", "");
11122 d.move_doc_end(false);
11123 assert_eq!(d.caret, 0);
11124 d.move_doc_start(false);
11125 assert_eq!(d.caret, 0);
11126 }
11127
11128 // ── arrow collapses an active selection ─────────────────────────────────
11129
11130 #[test]
11131 fn arrow_collapses_selection_to_its_near_edge() {
11132 let mut d = doc_with("collapse", "hello world\n");
11133
11134 // Forward selection (anchor before caret): Right -> end, Left -> start.
11135 d.anchor = Some(2);
11136 d.caret = 7;
11137 d.move_right(false);
11138 assert_eq!((d.caret, d.anchor), (7, None));
11139
11140 d.anchor = Some(2);
11141 d.caret = 7;
11142 d.move_left(false);
11143 assert_eq!((d.caret, d.anchor), (2, None));
11144
11145 // Backward selection (anchor after caret): edges are the same
11146 // regardless of which end the caret started on.
11147 d.anchor = Some(7);
11148 d.caret = 2;
11149 d.move_right(false);
11150 assert_eq!((d.caret, d.anchor), (7, None));
11151
11152 d.anchor = Some(7);
11153 d.caret = 2;
11154 d.move_left(false);
11155 assert_eq!((d.caret, d.anchor), (2, None));
11156 }
11157
11158 #[test]
11159 fn arrow_with_extend_keeps_growing_the_selection() {
11160 let mut d = doc_with("collapse_extend", "hello world\n");
11161 d.anchor = Some(2);
11162 d.caret = 7;
11163 d.move_right(true); // extend: no collapse, caret steps one further
11164 assert_eq!((d.caret, d.anchor), (8, Some(2)));
11165 }
11166
11167 #[test]
11168 fn arrow_without_a_selection_moves_one_character_as_before() {
11169 let mut d = doc_with("no_collapse", "hello\n");
11170 d.caret = 2;
11171 d.move_right(false);
11172 assert_eq!(d.caret, 3);
11173 d.move_left(false);
11174 assert_eq!(d.caret, 2);
11175 }
11176
11177 /// Press Right until it stops, collecting the offsets walked through. Every
11178 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
11179 /// two stops sharing one source offset can't be moved between, so the caret
11180 /// stalls on the first of them and the walk never reaches the rest.
11181 fn walk_right(d: &mut Doc) -> Vec<usize> {
11182 let mut seen = vec![d.caret];
11183 for _ in 0..2000 {
11184 let before = d.caret;
11185 d.move_right(false);
11186 if d.caret == before {
11187 break;
11188 }
11189 seen.push(d.caret);
11190 }
11191 seen
11192 }
11193
11194 #[test]
11195 fn the_caret_crosses_a_soft_break() {
11196 // A newline inside a paragraph is a `soft_break`, which twig gives no
11197 // span of its own — the space it renders as used to borrow the offset of
11198 // the character before it, and a caret can't move without changing
11199 // offset. Right must walk clean off the end of the first line.
11200 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
11201 d.caret = 0;
11202 let seen = walk_right(&mut d);
11203 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11204 }
11205
11206 #[test]
11207 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
11208 // The paragraph holds one soft break. Folded (the default) it lays out as
11209 // a single reflowed row; Preserve re-lays it as a row per source line.
11210 // The setter must invalidate the cached map for the change to show, and
11211 // again on the way back — so a round trip returns to the folded layout.
11212 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
11213 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
11214 d.build_visual(80);
11215 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
11216
11217 d.set_line_flow(LineFlow::Preserve);
11218 d.build_visual(80);
11219 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
11220
11221 d.set_line_flow(LineFlow::Fold);
11222 d.build_visual(80);
11223 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
11224 }
11225
11226 #[test]
11227 fn the_caret_still_crosses_a_preserved_soft_break() {
11228 // Preserve renders the soft break as a row boundary rather than a space,
11229 // but the caret must still reach every offset — the break's own offset is
11230 // the first row's end stop, so Right walks clean off the end of line one
11231 // onto line two, exactly as it does when the break is folded.
11232 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
11233 d.set_line_flow(LineFlow::Preserve);
11234 d.build_visual(80);
11235 d.caret = 0;
11236 let seen = walk_right(&mut d);
11237 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11238 }
11239
11240 #[test]
11241 fn the_caret_walks_a_code_block() {
11242 // Every glyph of a code block used to map to the block's start, so the
11243 // whole block was a single offset and the caret couldn't move inside it.
11244 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
11245 let mut d = wysiwyg_doc("code_walk", src);
11246 d.caret = 0;
11247 let seen = walk_right(&mut d);
11248 // The fences are markup: hidden, and no caret stop. The code between
11249 // them is reached a character at a time.
11250 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
11251 for off in code.clone() {
11252 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
11253 }
11254 assert!(seen.contains(&code.end), "no stop after the last line");
11255 }
11256
11257 #[test]
11258 fn the_caret_walks_an_indented_code_block() {
11259 // An indented block's text has the four-space indent stripped, so it
11260 // isn't a verbatim slice and its lines have to be re-found. The caret
11261 // lands on the code, never in the indent.
11262 let src = " indented\n code\n";
11263 let mut d = wysiwyg_doc("indent_code_walk", src);
11264 d.caret = 0;
11265 let seen = walk_right(&mut d);
11266 assert!(seen.contains(&src.find("indented").unwrap()));
11267 assert!(seen.contains(&src.find("code").unwrap()));
11268 assert!(
11269 !seen.contains(&0) || seen[0] == 0,
11270 "the caret starts where it was put"
11271 );
11272 // Nothing in the stripped indent is a stop.
11273 for off in [1, 2, 3] {
11274 assert!(!seen.contains(&off), "landed in the indent at {off}");
11275 }
11276 }
11277
11278 #[test]
11279 fn the_caret_leaves_a_tight_heading() {
11280 // "# H" with text directly under it: the heading row's end and the
11281 // separator row's end are the same offset. Right used to find the
11282 // separator's copy, set the caret to where it already was, and stop.
11283 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
11284 d.caret = 2; // the "H"
11285 let seen = walk_right(&mut d);
11286 assert!(
11287 seen.len() > 2,
11288 "Right stalled at the heading's end: {seen:?}"
11289 );
11290 assert!(
11291 seen.contains(&8),
11292 "never reached the end of \"text\": {seen:?}"
11293 );
11294 }
11295
11296 #[test]
11297 fn the_caret_skips_the_gap_between_two_paragraphs() {
11298 // The blank line between two paragraphs is the boundary itself. The
11299 // caret used to be able to sit on it, and typing there landed in the
11300 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
11301 // soft break, so the text visibly snapped back up.
11302 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
11303 d.caret = 1; // the end of "A"
11304 d.move_right(false);
11305 assert_eq!(d.caret, 3, "Right stopped in the gap");
11306 d.insert("x");
11307 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
11308 }
11309
11310 #[test]
11311 fn down_from_a_paragraph_lands_on_the_next_one() {
11312 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
11313 d.caret = 0;
11314 d.move_down(false);
11315 assert_eq!(d.caret, 3, "Down stopped in the gap");
11316 }
11317
11318 #[test]
11319 fn clicking_the_gap_lands_on_real_text() {
11320 // A click can still *reach* the gap — it's drawn, so it's clickable.
11321 // It has to resolve to somewhere the caret can be.
11322 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
11323 d.click(1, 0, false); // the gap row
11324 assert!(
11325 d.caret == 1 || d.caret == 3,
11326 "click left the caret in the gap at {}",
11327 d.caret
11328 );
11329 d.insert("x");
11330 // Either edge of the boundary is a fair place to land; inside it isn't.
11331 assert!(
11332 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
11333 "click in the gap typed into the boundary: {:?}",
11334 d.source
11335 );
11336 }
11337
11338 #[test]
11339 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
11340 // Enter inserts a paragraph break, which leaves a blank line spare on
11341 // either side of a new one. That middle line is a real empty paragraph:
11342 // the caret lands there, and typing makes a paragraph rather than
11343 // extending a neighbour.
11344 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
11345 d.caret = 1;
11346 d.newline();
11347 assert_eq!(d.source, "A\n\n\n\nB\n");
11348 d.build_visual(80);
11349 let (row, _) = d.caret_pos();
11350 assert!(
11351 d.vmap.row_is_navigable(row),
11352 "the caret landed on a gap row"
11353 );
11354 d.insert("x");
11355 assert_eq!(
11356 d.source, "A\n\nx\n\nB\n",
11357 "the new paragraph merged into a neighbour"
11358 );
11359 }
11360
11361 #[test]
11362 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
11363 let mut d = wysiwyg_doc("gap_eof", "A\n");
11364 d.caret = 1;
11365 d.newline();
11366 d.build_visual(80);
11367 let (row, _) = d.caret_pos();
11368 assert!(
11369 d.vmap.row_is_navigable(row),
11370 "the caret landed on a gap row"
11371 );
11372 d.insert("x");
11373 assert!(
11374 d.source.starts_with("A\n\n") && d.source.contains('x'),
11375 "typing at the end merged into A: {:?}",
11376 d.source
11377 );
11378 }
11379
11380 #[test]
11381 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
11382 // A paragraph broken over two source lines is one paragraph. Selecting
11383 // it must not stop at the newline inside it — that newline is markup the
11384 // rich-text view exists to hide.
11385 let src = "one two\nthree four\n\nnext\n";
11386 let mut d = wysiwyg_doc("triple_para", src);
11387 d.select_block_at(2);
11388 assert_eq!(
11389 d.selected_text(),
11390 Some("one two\nthree four"),
11391 "stopped at the soft break"
11392 );
11393 }
11394
11395 #[test]
11396 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
11397 // The reader scrolls down past the caret's row. Nothing moved the
11398 // caret, so the view must stay where it was put — the old code revealed
11399 // the caret every frame, which dragged the view straight back and made
11400 // the document unscrollable past the caret.
11401 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
11402 d.caret = 0;
11403 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
11404 d.scroll = 4; // the wheel
11405 d.follow_caret(0, 3, 9);
11406 assert_eq!(
11407 d.scroll, 4,
11408 "the wheel was overruled by a caret that never moved"
11409 );
11410 }
11411
11412 #[test]
11413 fn moving_the_caret_brings_the_view_back_to_it() {
11414 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
11415 d.caret = 0;
11416 d.follow_caret(0, 3, 9);
11417 d.scroll = 6; // scrolled away
11418 d.move_right(false); // ...and now the caret moves
11419 let (row, _) = d.caret_pos();
11420 d.follow_caret(row, 3, 9);
11421 assert!(
11422 d.scroll <= row && row < d.scroll + 3,
11423 "caret row {row} off screen at scroll {}",
11424 d.scroll
11425 );
11426 }
11427
11428 #[test]
11429 fn scrolling_stops_at_the_last_row() {
11430 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
11431 d.caret = 0;
11432 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
11433 d.scroll = 999; // the wheel, spun hard
11434 d.follow_caret(0, 3, 3);
11435 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
11436 }
11437
11438 #[test]
11439 fn every_cell_of_a_wide_table_is_reachable() {
11440 // A table whose cells are far wider than the surface: the columns are
11441 // cut to fit and the text wraps inside them, so no cell hangs off the
11442 // right edge where the caret can never go.
11443 let src = "| Ingredient | Notes |\n|---|---|\n\
11444 | flour milled coarse | sift it twice before folding it in |\n";
11445 let mut d = wysiwyg_doc("wide_table_walk", src);
11446 d.build_visual(30);
11447 d.caret = 0;
11448 let seen = walk_right(&mut d);
11449 for word in ["Ingredient", "Notes", "coarse", "folding"] {
11450 let at = src.find(word).unwrap();
11451 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
11452 }
11453 }
11454
11455 // ── view parity ──────────────────────────────────────────────────────────
11456 // `doc_with` pins the source view, so everything above tests a view users
11457 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
11458 // deletion golden cases through *both*, plus the WYSIWYG cases the two
11459 // can't share: where the source carries markup the rendered text is a
11460 // different string, and the views agreeing would itself be the bug.
11461
11462 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
11463
11464 /// Run `action` in both views on one `|`-marked fixture and assert they
11465 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
11466 /// source verbatim, so the two views are looking at the same text and any
11467 /// disagreement is one of them having lost the plot.
11468 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
11469 let (src, caret) = parse_caret(marked);
11470 let run = |view: View, tag: &str| {
11471 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
11472 d.caret = caret;
11473 action(&mut d);
11474 render_caret(&d)
11475 };
11476 let source = run(VIEWS[0].0, VIEWS[0].1);
11477 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
11478 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
11479 source
11480 }
11481
11482 #[test]
11483 fn word_motion_agrees_across_the_views_on_plain_prose() {
11484 let g = both_views;
11485 assert_eq!(
11486 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
11487 "hello |world"
11488 );
11489 assert_eq!(
11490 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
11491 "|hello world"
11492 );
11493 assert_eq!(
11494 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
11495 "hello| world"
11496 );
11497 assert_eq!(
11498 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
11499 "hello world|"
11500 );
11501 assert_eq!(
11502 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
11503 "foo|.bar"
11504 );
11505 assert_eq!(
11506 g("par_ext", "hello |world", |d| d.move_word_right(true)),
11507 "hello [world|]"
11508 );
11509 }
11510
11511 #[test]
11512 fn word_deletion_agrees_across_the_views_on_plain_prose() {
11513 let g = both_views;
11514 assert_eq!(
11515 g("par_db", "hello world|", |d| d.delete_word_back()),
11516 "hello |"
11517 );
11518 assert_eq!(
11519 g("par_df", "hello |world", |d| d.delete_word_forward()),
11520 "hello |"
11521 );
11522 assert_eq!(
11523 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
11524 "|bar baz"
11525 );
11526 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
11527 }
11528
11529 #[test]
11530 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
11531 let g = both_views;
11532 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
11533 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
11534 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
11535 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
11536 }
11537
11538 #[test]
11539 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
11540 // The reproduction: the stop table was built one stop per `char`, so
11541 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
11542 // source view, which steps by grapheme, can't reach and backspace can't
11543 // survive. The two views must land on the same offset.
11544 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
11545 for (view, tag) in VIEWS {
11546 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
11547 d.caret = 1;
11548 d.move_right(false);
11549 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
11550
11551 // ...and the edit that used to sever a joiner off the front of it.
11552 d.backspace();
11553 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
11554 assert_eq!(d.caret, 1);
11555 }
11556 }
11557
11558 #[test]
11559 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
11560 for (view, tag) in VIEWS {
11561 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
11562 d.caret = 0;
11563 d.move_right(false);
11564 assert_eq!(
11565 d.caret,
11566 "e\u{0301}".len(),
11567 "{tag} stopped on the combining mark"
11568 );
11569 }
11570 }
11571
11572 #[test]
11573 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
11574 // The general form: whatever route the caret takes through a document
11575 // full of clusters, it never lands between the codepoints of one — so no
11576 // motion-then-backspace sequence can leave a dangling joiner behind.
11577 use unicode_segmentation::UnicodeSegmentation;
11578
11579 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
11580 let mut d = wysiwyg_doc("cluster_walk", src);
11581 d.caret = 0;
11582 let boundaries: Vec<usize> = src
11583 .grapheme_indices(true)
11584 .map(|(i, _)| i)
11585 .chain(std::iter::once(src.len()))
11586 .collect();
11587 for off in walk_right(&mut d) {
11588 assert!(
11589 boundaries.contains(&off),
11590 "Right stopped at {off}, inside a grapheme cluster"
11591 );
11592 }
11593 }
11594
11595 #[test]
11596 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
11597 // The reproduction: ⌥→ from inside the opening `**` computed its
11598 // boundary over the raw source and landed on byte 8 — inside the
11599 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
11600 // "bold". The caret drew past the bold word and sat inside it.
11601 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
11602 d.caret = 2;
11603 d.move_word_right(false);
11604 assert!(
11605 d.vmap.is_stop(d.caret),
11606 "landed at {}, not a caret stop",
11607 d.caret
11608 );
11609 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
11610 // The rendered row is "a bold c": column 6 is the space just past "bold",
11611 // and now the caret is really there rather than only drawn there.
11612 assert_eq!(d.caret_pos(), (0, 6));
11613
11614 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
11615 d.move_word_left(false);
11616 assert_eq!(d.caret, 4);
11617 assert_eq!(d.caret_pos(), (0, 2));
11618 }
11619
11620 #[test]
11621 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
11622 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
11623 // inside the closing `**`, and left "a ** c\n" — delimiters with no
11624 // opener. Glyph space covers the word alone, which would leave
11625 // "a **** c": markup wrapped around nothing. The word and the styling
11626 // that was only ever the word's go together.
11627 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
11628 d.caret = 10;
11629 d.delete_word_back();
11630 assert_eq!(d.source, "a c\n");
11631 assert_eq!(d.caret, 2);
11632
11633 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
11634 d.caret = 4; // the "b"
11635 d.delete_word_forward();
11636 assert_eq!(d.source, "a c\n");
11637 }
11638
11639 #[test]
11640 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
11641 let src = "a ***bold*** c\n";
11642 let mut d = wysiwyg_doc("wys_word_del_nest", src);
11643 d.caret = src.find(" c").unwrap();
11644 d.delete_word_back();
11645 assert_eq!(
11646 d.source, "a c\n",
11647 "the emph inside the strong empties it too"
11648 );
11649
11650 let src = "a `code` c\n";
11651 let mut d = wysiwyg_doc("wys_word_del_code", src);
11652 d.caret = src.find(" c").unwrap();
11653 d.delete_word_back();
11654 assert_eq!(d.source, "a c\n");
11655 }
11656
11657 #[test]
11658 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
11659 // Only an *emptied* node goes. Take one word of two and the `**` still
11660 // has a job to do — over the word that's left, with the space the delete
11661 // pushed against the opening delimiter moved out in front of it, or the
11662 // run would be no run at all (`** words**` is literal asterisks — see
11663 // the mark-edge rule on `splice`).
11664 let src = "a **two words** c\n";
11665 let mut d = wysiwyg_doc("wys_word_del_partial", src);
11666 d.caret = src.find(" words").unwrap();
11667 d.delete_word_back();
11668 assert_eq!(d.source, "a **words** c\n");
11669 }
11670
11671 #[test]
11672 fn source_view_word_motion_still_walks_the_markup() {
11673 // The other half of the decision: in the source view the `**` are
11674 // characters like any other — they're on the screen, so word motion has
11675 // to stop at them and a word-delete has to leave them behind. Only
11676 // WYSIWYG hides them, so only WYSIWYG steps over them.
11677 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
11678 assert_eq!(
11679 g("src_word_motion", "a |**bold** c\n", |d| d
11680 .move_word_right(false)),
11681 "a **bold|** c\n"
11682 );
11683 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
11684 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
11685 assert_eq!(
11686 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
11687 "a **| c\n"
11688 );
11689 }
11690
11691 #[test]
11692 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
11693 // The single invariant both bugs violated: the caret draws and edits at
11694 // the same place only when it's on a stop. `debug_assert_on_a_stop`
11695 // makes the same claim in-place; this pins it from the outside, over a
11696 // document with every kind of thing the map has to be careful about.
11697 // At two widths: the wide one every other test builds at, where no
11698 // fixture folds, and one narrow enough that they all do. A soft wrap is
11699 // where an offset stops being on exactly one row, and testing only the
11700 // width that never wraps is how the caret came to be pinned at the first
11701 // one Down reached.
11702 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
11703 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
11704 // A table of named operations, which is what it looks like.
11705 #[allow(clippy::type_complexity)]
11706 let motions: [(&str, fn(&mut Doc)); 8] = [
11707 ("right", |d| d.move_right(false)),
11708 ("left", |d| d.move_left(false)),
11709 ("word_right", |d| d.move_word_right(false)),
11710 ("word_left", |d| d.move_word_left(false)),
11711 ("down", |d| d.move_down(false)),
11712 ("up", |d| d.move_up(false)),
11713 ("home", |d| d.move_home(false)),
11714 ("end", |d| d.move_end(false)),
11715 ];
11716 for width in [80, 12] {
11717 let mut d = wysiwyg_doc("stop_invariant", src);
11718 d.build_visual(width);
11719 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11720 assert!(stops.len() > 20, "fixture should have plenty of stops");
11721 for start in stops {
11722 for (name, motion) in &motions {
11723 d.caret = start;
11724 d.anchor = None;
11725 motion(&mut d);
11726 assert!(
11727 d.vmap.is_stop(d.caret),
11728 "{name} from {start} at width {width} landed at {} — not a caret stop",
11729 d.caret
11730 );
11731 }
11732 }
11733 }
11734 }
11735
11736 #[test]
11737 fn no_wysiwyg_motion_is_a_dead_end() {
11738 // Down held to the bottom of a document reaches the bottom, and Up held
11739 // to the top reaches the top — from anywhere, at a width that wraps. The
11740 // invariant above says a motion lands somewhere legal; this one says it
11741 // gets somewhere at all, which is what a caret pinned at a wrap boundary
11742 // was quietly failing to do while every assertion around it held.
11743 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
11744 - item one two three four five\n\nlast\n";
11745 for width in [80, 12] {
11746 let mut d = wysiwyg_doc("no_dead_end", src);
11747 d.build_visual(width);
11748 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11749 let (first, last) = (stops[0], stops[stops.len() - 1]);
11750 for &start in &stops {
11751 for (name, motion, want) in [
11752 (
11753 "down",
11754 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
11755 last,
11756 ),
11757 ("up", |d: &mut Doc| d.move_up(false), first),
11758 ] {
11759 d.caret = start;
11760 d.anchor = None;
11761 d.goal_col = None;
11762 // Every row, plus the presses the edges take, plus slack.
11763 for _ in 0..d.vmap.num_rows() + 4 {
11764 motion(&mut d);
11765 }
11766 assert_eq!(
11767 d.caret, want,
11768 "{name} held from {start} at width {width} never arrived"
11769 );
11770 }
11771 }
11772 }
11773 }
11774 // ── display columns ──────────────────────────────────────────────────────
11775 // A `col` is a terminal cell, not a character. The two are the same number
11776 // for the ASCII the fixtures above are written in, which is how they came
11777 // apart in the first place: `你` is one character drawn in two cells, so a
11778 // column counted in characters names a cell the text isn't in — one earlier
11779 // for every wide character to its left.
11780
11781 #[test]
11782 fn a_wide_character_is_two_columns_wide() {
11783 // The reproduction: `你` is one char and two cells, so the caret just
11784 // past it drew at column 1 — inside the character it had already left.
11785 for (view, tag) in VIEWS {
11786 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
11787 d.caret = "你".len();
11788 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
11789 d.caret = "你好".len();
11790 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
11791 }
11792 }
11793
11794 #[test]
11795 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
11796 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
11797 // two-cell — measuring six cells one at a time, but the character they
11798 // spell is drawn in two. Width belongs to the cluster, not the glyph,
11799 // and the frontends measure it the same way.
11800 let family = "👨👩👧";
11801 for (view, tag) in VIEWS {
11802 let src = format!("a{family}b\n");
11803 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
11804 d.caret = 1 + family.len();
11805 assert_eq!(
11806 d.caret_pos(),
11807 (0, 3),
11808 "{tag}: 'a' is one cell, the family two"
11809 );
11810 }
11811 }
11812
11813 #[test]
11814 fn both_cells_of_a_wide_character_mean_the_character() {
11815 // Clicking the far half of `好` is still clicking `好`: half a character
11816 // is not a place the caret can be, so it comes to rest at the
11817 // character's start — the column it would have been drawn at anyway.
11818 for (view, tag) in VIEWS {
11819 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
11820 for col in [2, 3] {
11821 d.caret = 0;
11822 d.click(0, col, false);
11823 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
11824 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
11825 }
11826 // Past the last cell is the line's end, as it is for ASCII.
11827 d.click(0, 9, false);
11828 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
11829 }
11830 }
11831
11832 #[test]
11833 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
11834 // The mapping is only a mapping if it inverts: the cell the caret is
11835 // drawn in has to be the cell that brings it back to the same offset.
11836 // Over a fixture where a character may be one cell or two, and one
11837 // codepoint or five.
11838 use unicode_segmentation::UnicodeSegmentation;
11839
11840 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
11841
11842 let mut d = doc_in(View::Source, "roundtrip_source", src);
11843 // Every offset the source view's caret can occupy: it steps by grapheme
11844 // cluster, so those are its boundaries.
11845 for (off, _) in src
11846 .grapheme_indices(true)
11847 .chain(std::iter::once((src.len(), "")))
11848 {
11849 d.caret = off;
11850 let (row, col) = d.caret_pos();
11851 d.click(row, col, false);
11852 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
11853 }
11854
11855 // And in WYSIWYG, where the offsets the caret can occupy are the map's
11856 // stops rather than every boundary.
11857 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
11858 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
11859 assert!(stops.len() > 20, "fixture should have plenty of stops");
11860 for off in stops {
11861 d.caret = off;
11862 let (row, col) = d.caret_pos();
11863 d.click(row, col, false);
11864 assert_eq!(
11865 d.caret, off,
11866 "wysiwyg: {off} → ({row}, {col}) → {}",
11867 d.caret
11868 );
11869 }
11870 }
11871
11872 #[test]
11873 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
11874 // Down from under `世` lands under the glyph in that cell, not two
11875 // characters further along the line. The goal is a column, so a line of
11876 // wide characters and a line of ASCII line up the way they're drawn.
11877 //
11878 // The gap differs by view: a bare newline inside a paragraph is a soft
11879 // break, which WYSIWYG draws as a space on a single row. The views share
11880 // a grid only where the source's lines are the renderer's rows too.
11881 for (view, tag) in VIEWS {
11882 let gap = if view == View::Source { "\n" } else { "\n\n" };
11883 let src = format!("你好世{gap}abcdef\n");
11884 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
11885 d.caret = "你好".len();
11886 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
11887 d.move_down(false);
11888 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
11889 assert!(
11890 d.source[d.caret..].starts_with('e'),
11891 "{tag}: landed on the wrong glyph"
11892 );
11893 }
11894 }
11895
11896 #[test]
11897 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
11898 // Down from column 3 onto `你好`, whose characters start at columns 0
11899 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
11900 // between the cells of one character, so the caret rests on it — and on
11901 // its start, which is the only offset there that is a caret stop.
11902 for (view, tag) in VIEWS {
11903 let gap = if view == View::Source { "\n" } else { "\n\n" };
11904 let src = format!("abcdef{gap}你好\n");
11905 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
11906 let line = src.find('你').unwrap();
11907 d.caret = 3;
11908 d.move_down(false);
11909 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
11910 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
11911 }
11912 }
11913
11914 #[test]
11915 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
11916 // The column the cell's text is laid out in is measured in cells, so the
11917 // caret walking that text has to be too — the two agreeing is the whole
11918 // point of the grid staying square.
11919 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
11920 let at = d.source.find("你").unwrap();
11921 d.caret = at;
11922 let (row, col) = d.caret_pos();
11923 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
11924 // cells further along.
11925 assert_eq!(col, 2, "the cell's first character");
11926 d.move_right(false);
11927 assert_eq!(
11928 d.caret_pos(),
11929 (row, 4),
11930 "`好` is drawn past `你`'s two cells"
11931 );
11932 assert_eq!(d.caret, at + "你".len());
11933 }
11934
11935 // ── active inline marks ───────────────────────────────────────────────────
11936
11937 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
11938 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
11939 let (src, caret) = parse_caret(marked);
11940 let mut d = doc_in(view, name, &src);
11941 d.caret = caret;
11942 d.active_inline_marks().iter().collect()
11943 }
11944
11945 /// The marks over the selection `[start, end)`.
11946 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
11947 let mut d = doc_in(view, name, src);
11948 d.anchor = Some(start);
11949 d.caret = end;
11950 d.active_inline_marks().iter().collect()
11951 }
11952
11953 #[test]
11954 fn a_caret_in_a_mark_reports_it() {
11955 for (view, tag) in VIEWS {
11956 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
11957 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
11958 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
11959 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
11960 // Plain text under no mark lights nothing — the toolbar's resting state.
11961 assert_eq!(m("a| **bold** b"), [], "{tag}");
11962 assert!(m("plain t|ext").is_empty(), "{tag}");
11963 }
11964 }
11965
11966 #[test]
11967 fn nested_marks_all_report() {
11968 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
11969 // the ancestor chain is a chain, and every mark on it is in force.
11970 for (view, tag) in VIEWS {
11971 assert_eq!(
11972 marks(
11973 view,
11974 &format!("marks_nested_{tag}"),
11975 "**bold and *bo|th*** end"
11976 ),
11977 [InlineKind::Strong, InlineKind::Emph],
11978 "{tag}"
11979 );
11980 }
11981 }
11982
11983 #[test]
11984 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
11985 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
11986 // the first byte of its text and the byte after its last — both inside
11987 // the mark's span, both places typing lands inside the bold. The offset
11988 // past the closing delimiter is the next text, and reports nothing.
11989 let src = "a **bold** b";
11990 let inner_start = src.find("bold").unwrap(); // 4
11991 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
11992 for (view, tag) in VIEWS {
11993 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
11994 for off in [2, 3, inner_start, inner_end, 9] {
11995 d.caret = off;
11996 assert!(
11997 d.active_inline_marks().contains(InlineKind::Strong),
11998 "{tag}: offset {off} is inside the strong span"
11999 );
12000 }
12001 for off in [0, 1, 10, 11, 12] {
12002 d.caret = off;
12003 assert!(
12004 !d.active_inline_marks().contains(InlineKind::Strong),
12005 "{tag}: offset {off} is outside the strong run"
12006 );
12007 }
12008 }
12009 }
12010
12011 #[test]
12012 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
12013 // Regression: twig resolves an offset that is one node's end and the
12014 // next one's start to the node that *starts* there, so `**bold**|\n`
12015 // isn't bold. With nothing following there's no tie to break and the
12016 // chain still ended at the mark, which made a trailing `\n` — not the
12017 // text — decide whether the caret after a bold word reported bold. It's
12018 // the offset past the mark either way, and typing there is plain either
12019 // way. A blank document typed into is exactly this shape.
12020 for (view, tag) in VIEWS {
12021 let m = |name: String, marked| marks(view, &name, marked);
12022 assert_eq!(
12023 m(format!("marks_eob_{tag}"), "**bold**|"),
12024 [],
12025 "{tag}: no trailing newline"
12026 );
12027 assert_eq!(
12028 m(format!("marks_eol_{tag}"), "**bold**|\n"),
12029 [],
12030 "{tag}: with one"
12031 );
12032 // And the last offset that *is* in the mark still is.
12033 assert_eq!(
12034 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
12035 [InlineKind::Strong],
12036 "{tag}"
12037 );
12038 }
12039 }
12040
12041 #[test]
12042 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
12043 let src = "a **bold** b";
12044 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
12045 for (view, tag) in VIEWS {
12046 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
12047 // The whole bold word, and a slice of it.
12048 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
12049 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
12050 // Ending exactly at the closing delimiter's start is still all-bold:
12051 // an exclusive end sits *past* the last selected character, so the
12052 // question is asked of the character, not the boundary.
12053 assert_eq!(
12054 m(b, d_ + 2),
12055 [InlineKind::Strong],
12056 "{tag}: through the close"
12057 );
12058 // Half in, half out: Bold lit here would claim a press turns it off.
12059 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
12060 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
12061 }
12062 }
12063
12064 #[test]
12065 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
12066 // Both ends are bold, but the space between them isn't — two runs are two
12067 // nodes, which is exactly what the node id catches and a kind-only
12068 // comparison would not.
12069 let src = "**one** **two**";
12070 for (view, tag) in VIEWS {
12071 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
12072 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
12073 }
12074 }
12075
12076 #[test]
12077 fn marks_read_the_document_as_it_is_edited() {
12078 // The point of asking twig every frame instead of caching: the answer has
12079 // to follow the toggle that changed it.
12080 let mut d = wysiwyg_doc("marks_live", "one two\n");
12081 d.anchor = Some(0);
12082 d.caret = 3;
12083 assert!(d.active_inline_marks().is_empty(), "plain to start");
12084 d.toggle(InlineKind::Strong);
12085 assert_eq!(d.source, "**one** two\n");
12086 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
12087 assert!(d.active_inline_marks().contains(InlineKind::Strong));
12088 d.toggle(InlineKind::Strong);
12089 assert!(d.active_inline_marks().is_empty(), "and off again");
12090 }
12091
12092 #[test]
12093 fn a_link_is_not_an_inline_mark() {
12094 // `link`/`str` are inline nodes, but nothing on the inline toolbar
12095 // toggles them — a set with a "link mark" in it would have no button.
12096 for (view, tag) in VIEWS {
12097 assert_eq!(
12098 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
12099 [],
12100 "{tag}"
12101 );
12102 }
12103 }
12104
12105 // ── blank documents ───────────────────────────────────────────────────────
12106
12107 #[test]
12108 fn a_blank_document_is_untitled_empty_and_markdown() {
12109 let mut d = Doc::blank().unwrap();
12110 assert!(d.is_untitled());
12111 assert_eq!(d.path, PathBuf::new());
12112 assert_eq!(
12113 d.file_name(),
12114 "untitled",
12115 "the header has to show something"
12116 );
12117 assert_eq!(d.format_name(), "markdown");
12118 assert_eq!(d.source, "");
12119 assert!(!d.dirty, "nothing typed yet is nothing to lose");
12120 assert_eq!(d.disk_state(), DiskState::Untitled);
12121 // And it's a document you can be in: the default view renders it.
12122 d.build_visual(80);
12123 assert_eq!(d.caret, 0);
12124 }
12125
12126 #[test]
12127 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
12128 let mut d = Doc::blank().unwrap();
12129 d.insert("hello");
12130 assert!(d.dirty);
12131 d.save();
12132 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
12133 assert!(d.dirty, "it must not come away believing it saved");
12134 assert!(d.is_untitled(), "and it still has no file");
12135 }
12136
12137 #[test]
12138 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
12139 let p = temp_path("blank_save_as");
12140 let mut d = Doc::blank().unwrap();
12141 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
12142 // be kept literal (`\#`); this test is about save-as, not escaping (which
12143 // has its own test), so it types nothing that escaping would touch.
12144 d.insert("hi");
12145 d.save_as(p.clone());
12146 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
12147 assert!(!d.is_untitled());
12148 assert!(!d.dirty);
12149 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
12150 assert_eq!(
12151 d.disk_state(),
12152 DiskState::Unchanged,
12153 "the watermark is stamped"
12154 );
12155 // And ⌘S is a plain save from here on.
12156 d.insert("!");
12157 d.save();
12158 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
12159 let _ = std::fs::remove_file(&p);
12160 }
12161
12162 // ── save as ───────────────────────────────────────────────────────────────
12163
12164 /// A unique path in the temp dir that no fixture wrote — a Save As target.
12165 fn temp_path(name: &str) -> PathBuf {
12166 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12167 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12168 let mut p = std::env::temp_dir();
12169 p.push(format!("leaf_test_target_{name}_{seq}.md"));
12170 let _ = std::fs::remove_file(&p);
12171 p
12172 }
12173
12174 #[test]
12175 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
12176 let mut d = doc_with("save_as_move", "original\n");
12177 let old = d.path.clone();
12178 let new = temp_path("save_as_move");
12179 d.insert("edited: ");
12180 d.save_as(new.clone());
12181
12182 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
12183 assert_eq!(
12184 std::fs::read_to_string(&old).unwrap(),
12185 "original\n",
12186 "Save As doesn't touch the file it came from"
12187 );
12188 assert_eq!(d.path, new, "the document moved");
12189 assert!(!d.dirty);
12190 assert_eq!(
12191 d.status.as_deref(),
12192 Some(&*format!("saved {}", d.file_name()))
12193 );
12194
12195 // Every later save follows it, which is the whole difference from a copy.
12196 d.caret = 0;
12197 d.insert("re-");
12198 d.save();
12199 assert_eq!(
12200 std::fs::read_to_string(&new).unwrap(),
12201 "re-edited: original\n"
12202 );
12203 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
12204 let _ = std::fs::remove_file(&new);
12205 }
12206
12207 #[test]
12208 fn save_as_overwrites_an_existing_target() {
12209 // The picker already asked; asking again down here is the same question
12210 // twice, and the second one has no way to be answered.
12211 let new = temp_path("save_as_over");
12212 std::fs::write(&new, "theirs\n").unwrap();
12213 let mut d = doc_with("save_as_over", "ours\n");
12214 d.save_as(new.clone());
12215 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
12216 let _ = std::fs::remove_file(&new);
12217 }
12218
12219 #[test]
12220 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
12221 let mut d = doc_with("save_as_fail", "body\n");
12222 let old = d.path.clone();
12223 d.insert("x");
12224 // A directory that doesn't exist: the write can't land.
12225 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
12226 d.save_as(bad);
12227
12228 assert_eq!(
12229 d.path, old,
12230 "the document must not move to a file that isn't there"
12231 );
12232 assert!(d.dirty, "and must not believe it saved");
12233 assert!(
12234 d.status.as_deref().unwrap().starts_with("save failed:"),
12235 "the same failure a plain save reports, got {:?}",
12236 d.status
12237 );
12238 // The original is still the document's file, and still saveable.
12239 d.save();
12240 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
12241 assert!(!d.dirty);
12242 }
12243
12244 #[test]
12245 fn save_as_renames_without_reparsing_the_format() {
12246 // `.dj` on the name doesn't make the buffer djot: it was parsed as
12247 // Markdown and still is, and saying otherwise would be a conversion the
12248 // user never asked for (and an undo history thrown away to do it).
12249 let mut d = doc_with("save_as_format", "**b**\n");
12250 let mut new = temp_path("save_as_format");
12251 new.set_extension("dj");
12252 d.save_as(new.clone());
12253 assert_eq!(d.format_name(), "markdown");
12254 let _ = std::fs::remove_file(&new);
12255 }
12256
12257 // ── external change / reload ──────────────────────────────────────────────
12258
12259 #[test]
12260 fn an_untouched_file_reports_unchanged() {
12261 let mut d = doc_with("disk_clean", "body\n");
12262 assert_eq!(d.disk_state(), DiskState::Unchanged);
12263 // Editing the buffer is not editing the file.
12264 d.insert("x");
12265 assert_eq!(d.disk_state(), DiskState::Unchanged);
12266 assert!(d.dirty);
12267 // Saving re-stamps the watermark rather than reporting our own bytes back.
12268 d.save();
12269 assert_eq!(d.disk_state(), DiskState::Unchanged);
12270 }
12271
12272 #[test]
12273 fn a_file_written_underneath_reports_changed() {
12274 let mut d = doc_with("disk_changed", "body\n");
12275 std::fs::write(&d.path, "someone else\n").unwrap();
12276 assert_eq!(d.disk_state(), DiskState::Changed);
12277 // Dirty *and* changed is the clobber: both halves are readable, and
12278 // leaf-core takes neither side.
12279 d.insert("x");
12280 assert!(d.dirty && d.disk_state() == DiskState::Changed);
12281 // Saving anyway is allowed — the frontend asked, or chose not to.
12282 d.save();
12283 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
12284 assert_eq!(d.disk_state(), DiskState::Unchanged);
12285 }
12286
12287 #[test]
12288 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
12289 // The hash is what makes this honest: the file was written (a fresh
12290 // mtime), and nothing about the document is stale.
12291 let d = doc_with("disk_same_bytes", "body\n");
12292 std::fs::write(&d.path, "body\n").unwrap();
12293 assert_eq!(d.disk_state(), DiskState::Unchanged);
12294 }
12295
12296 #[test]
12297 fn a_deleted_file_reports_missing() {
12298 let mut d = doc_with("disk_missing", "body\n");
12299 std::fs::remove_file(&d.path).unwrap();
12300 assert_eq!(d.disk_state(), DiskState::Missing);
12301 // A save recreates it, and the document is whole again.
12302 d.save();
12303 assert_eq!(d.disk_state(), DiskState::Unchanged);
12304 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
12305 }
12306
12307 #[test]
12308 fn reload_replaces_the_document_with_the_file() {
12309 for (view, tag) in VIEWS {
12310 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
12311 d.insert("edited ");
12312 assert!(d.dirty);
12313 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12314 d.reload();
12315
12316 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
12317 assert!(!d.dirty, "{tag}: the file is what we have");
12318 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
12319 assert_eq!(
12320 d.status.as_deref(),
12321 Some(&*format!("reloaded {}", d.file_name()))
12322 );
12323 // The reloaded tree is live, not the old parse.
12324 d.caret = d.source.find("three").unwrap();
12325 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
12326 }
12327 }
12328
12329 #[test]
12330 fn reload_clamps_the_caret_and_drops_the_selection() {
12331 let mut d = doc_with("reload_caret", "a long first line\n");
12332 d.caret = 12;
12333 d.anchor = Some(4);
12334 std::fs::write(&d.path, "short\n").unwrap();
12335 d.reload();
12336 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
12337 assert_eq!(
12338 d.anchor, None,
12339 "a selection over bytes that changed is a lie"
12340 );
12341 assert!(d.selection().is_none());
12342
12343 // A caret the file still has room for stays put.
12344 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
12345 d.caret = 2;
12346 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12347 d.reload();
12348 assert_eq!(d.caret, 2);
12349 }
12350
12351 #[test]
12352 fn reload_drops_the_undo_history() {
12353 // twig's stack belongs to the buffer, and these are different bytes:
12354 // replaying a step recorded against the old ones would corrupt the file.
12355 let mut d = doc_with("reload_undo", "body\n");
12356 d.insert("x");
12357 std::fs::write(&d.path, "replaced\n").unwrap();
12358 d.reload();
12359 d.undo();
12360 assert_eq!(
12361 d.source, "replaced\n",
12362 "an undo must not resurrect the old buffer"
12363 );
12364 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12365 }
12366
12367 #[test]
12368 fn a_reload_that_cant_read_leaves_the_document_alone() {
12369 let mut d = doc_with("reload_gone", "body\n");
12370 d.insert("x");
12371 std::fs::remove_file(&d.path).unwrap();
12372 d.reload();
12373 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
12374 assert!(d.dirty);
12375 assert!(
12376 d.status.as_deref().unwrap().starts_with("reload failed:"),
12377 "{:?}",
12378 d.status
12379 );
12380
12381 // And an untitled document has nothing to reload from.
12382 let mut d = Doc::blank().unwrap();
12383 d.insert("typed");
12384 d.reload();
12385 assert_eq!(d.source, "typed");
12386 assert_eq!(d.status.as_deref(), Some("no file to reload"));
12387 }
12388}