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::source::{self, SourceMap};
43use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
44
45/// Which view the body shows.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum View {
48 /// The raw document with a caret in source bytes.
49 Source,
50 /// Markup resolved to real styles, caret riding the rendered glyphs.
51 Wysiwyg,
52}
53
54/// How much of the source markup the WYSIWYG view exposes — a per-editor
55/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
56/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
57/// terms, and every rung below is about *delimiters*, whatever grammar spells
58/// them. The examples are Markdown only because that is what most documents are.
59///
60/// A single ladder over two underlying axes, because only three of their four
61/// combinations are coherent:
62///
63/// | | authoring off | authoring on |
64/// |---|---|---|
65/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
66/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
67///
68/// The empty quadrant would show delimiters on the caret's line and then escape
69/// the ones you type — a surface that displays a syntax it refuses to accept.
70/// Someone who wants to read raw markup without authoring it has
71/// [`View::Source`], which is the better tool for it.
72///
73/// The two axes are read separately by the code that cares — see
74/// [`reveals_caret_line`](Self::reveals_caret_line) and
75/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
76/// as a ladder.
77#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
78pub enum MarkupMode {
79 /// Delimiters stay hidden even on the caret's line, and typed syntax stays
80 /// literal — twig escapes anything that would open markup, so formatting
81 /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
82 /// reading surface for people who don't write markup by hand; the default,
83 /// and what Diaryx ships.
84 #[default]
85 None,
86 /// Delimiters stay hidden, but typing them authors real markup: `*x*`
87 /// becomes italic and the asterisks disappear into the styling
88 /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
89 /// clean surface back once it has been applied.
90 Shortcuts,
91 /// The caret's line shows its raw markup while every other line renders
92 /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
93 /// — for people fluent in the document's grammar who want to see and edit
94 /// the delimiters they type.
95 Full,
96}
97
98impl MarkupMode {
99 /// Whether the rich view shows raw delimiters on the line holding the caret.
100 /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
101 /// WYSIWYG builder.
102 pub fn reveals_caret_line(self) -> bool {
103 matches!(self, MarkupMode::Full)
104 }
105
106 /// Whether typed markup characters author real formatting. The editing axis
107 /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
108 pub fn authors(self) -> bool {
109 !matches!(self, MarkupMode::None)
110 }
111}
112
113/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
114/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
115/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
116/// either flow. The renderer consults it when it lays a block's inline content
117/// into visual rows.
118#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
119pub enum LineFlow {
120 /// A soft break folds into a space and the paragraph reflows to the
121 /// viewport width — flowing prose, where the source's line wrapping is
122 /// insignificant. The default, and what Diaryx ships.
123 #[default]
124 Fold,
125 /// A soft break renders as a line break exactly where it was written, so
126 /// the author's source line structure shows on screen unchanged — the mode
127 /// for people who lay out their prose deliberately (one sentence or clause
128 /// per line, semantic line breaks). The break is still a soft break in the
129 /// source; only its rendering changes.
130 Preserve,
131}
132
133/// What the file behind a document looks like right now, against the bytes leaf
134/// last read from it or wrote to it — the question a frontend asks before it
135/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
136/// happen) or when its window regains focus. See [`Doc::disk_state`].
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum DiskState {
139 /// The file holds exactly the bytes leaf last read or wrote.
140 Unchanged,
141 /// Someone else wrote the file since. Saving overwrites their work; see
142 /// [`Doc::reload`] for the other direction.
143 Changed,
144 /// The file is gone — deleted or renamed away. A save recreates it.
145 Missing,
146 /// There is a path, but the file couldn't be read (permissions, a directory
147 /// in the way): leaf can't tell, and won't guess.
148 Unreadable,
149 /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
150 /// changed under a document that was never on disk.
151 Untitled,
152}
153
154/// The inline marks in force at a point in the document — what a toolbar
155/// lights up. A `Copy` bitset rather than a `HashSet`, because
156/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
157/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub struct InlineMarks(u8);
160
161impl InlineMarks {
162 /// Every kind, in the order [`InlineMarks::iter`] yields them.
163 const ALL: [InlineKind; 8] = [
164 InlineKind::Strong,
165 InlineKind::Emph,
166 InlineKind::Verbatim,
167 InlineKind::Mark,
168 InlineKind::Superscript,
169 InlineKind::Subscript,
170 InlineKind::Insert,
171 InlineKind::Delete,
172 ];
173
174 pub const fn empty() -> Self {
175 InlineMarks(0)
176 }
177
178 /// Private: the set is an *answer*, and adding a mark to it doesn't mark
179 /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
180 fn insert(&mut self, kind: InlineKind) {
181 self.0 |= Self::bit(kind);
182 }
183
184 /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
185 fn flip(&mut self, kind: InlineKind) {
186 self.0 ^= Self::bit(kind);
187 }
188
189 /// The symmetric difference: which marks differ between the two sets. Used
190 /// to resolve the marks already in force at the caret against the pending
191 /// delta — a bit set in the delta flips the base mark for the next keystroke.
192 fn xor(self, other: InlineMarks) -> InlineMarks {
193 InlineMarks(self.0 ^ other.0)
194 }
195
196 /// Whether `kind` is in force — the toolbar's "is Bold active?".
197 pub fn contains(self, kind: InlineKind) -> bool {
198 self.0 & Self::bit(kind) != 0
199 }
200
201 pub fn is_empty(self) -> bool {
202 self.0 == 0
203 }
204
205 /// The marks in force, for a frontend that renders whatever is on rather
206 /// than asking after a fixed list.
207 pub fn iter(self) -> impl Iterator<Item = InlineKind> {
208 Self::ALL.into_iter().filter(move |&k| self.contains(k))
209 }
210
211 fn bit(kind: InlineKind) -> u8 {
212 1 << match kind {
213 InlineKind::Strong => 0,
214 InlineKind::Emph => 1,
215 InlineKind::Verbatim => 2,
216 InlineKind::Mark => 3,
217 InlineKind::Superscript => 4,
218 InlineKind::Subscript => 5,
219 InlineKind::Insert => 6,
220 InlineKind::Delete => 7,
221 }
222 }
223}
224
225impl FromIterator<InlineKind> for InlineMarks {
226 fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
227 let mut m = InlineMarks::empty();
228 for k in iter {
229 m.insert(k);
230 }
231 m
232 }
233}
234
235/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
236/// into one undo step (a run of typed characters undoes together); `Other` never
237/// coalesces, so a paste, format toggle, or block change is always its own step.
238#[derive(Clone, Copy, PartialEq, Eq)]
239enum EditKind {
240 Insert,
241 Delete,
242 /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
243 /// rather than `Insert`'s because a composition is not typing: each step
244 /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
245 /// though no two steps insert the same bytes, and it must not fold into the
246 /// typed characters on either side of it.
247 Compose,
248 Other,
249}
250
251/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
252/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
253/// the caret), `Forward` is Delete (one starting at it).
254#[derive(Clone, Copy)]
255enum BreakEdge {
256 Backward,
257 Forward,
258}
259
260/// A re-spelling of one inline mark run, held ready in case the edit about to
261/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
262/// Every offset in it is in the coordinates the document will have *after* the
263/// plain edit, since that is when it may be applied.
264struct MarkEdgeFix {
265 /// The run's kind, and an offset inside what was its content: together they
266 /// answer "did the plain edit actually break this mark?" — the question that
267 /// decides whether any of this is applied at all.
268 kind: InlineKind,
269 probe: usize,
270 /// The byte range to re-spell (the run's delimiters included) and its new
271 /// spelling, with the edge whitespace moved outside the delimiters.
272 start: usize,
273 end: usize,
274 text: String,
275 /// Where the caret belongs afterwards — the same place on screen it would
276 /// have had, which is now on the other side of a delimiter.
277 caret: usize,
278 /// The marks in force for text typed at that caret. The caret can land
279 /// outside a run it was inside, and the marks have to survive the move or
280 /// the toolbar goes dark mid-word.
281 want: InlineMarks,
282}
283
284/// The caret and selection at one moment — the part of a history step twig's
285/// `Change` cannot carry, because the caret is leaf's state and twig only knows
286/// about bytes. leaf serializes it into the opaque per-state blob twig now
287/// stores in its own undo history (see `record_caret`), so undo and redo hand
288/// back the caret that matches the source they restore.
289#[derive(Clone, Copy)]
290struct CaretState {
291 caret: usize,
292 anchor: Option<usize>,
293}
294
295impl CaretState {
296 /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
297 /// then an anchor-present flag and the anchor. twig copies these bytes and
298 /// never reads them.
299 fn to_blob(self) -> [u8; 17] {
300 let mut b = [0u8; 17];
301 b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
302 if let Some(a) = self.anchor {
303 b[8] = 1;
304 b[9..].copy_from_slice(&(a as u64).to_le_bytes());
305 }
306 b
307 }
308
309 /// Recover a state from twig's blob, or `None` when it is empty or the wrong
310 /// length — a state twig restored that never had a caret set on it, which
311 /// leaves the caller to fall back to the edit site.
312 fn from_blob(b: &[u8]) -> Option<Self> {
313 let b: &[u8; 17] = b.try_into().ok()?;
314 let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
315 let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
316 Some(CaretState { caret, anchor })
317 }
318}
319
320/// A footnote reference and the note it names — the answer to
321/// [`Doc::footnote_at`].
322///
323/// The two `Option`s move together: a reference whose definition is missing has
324/// neither a body to show nor a place to jump to, and one that resolved has
325/// both.
326#[derive(Clone, PartialEq, Eq, Debug)]
327pub struct FootnoteRef {
328 /// The reference's label — the `1` of `[^1]`, with neither the `^` that
329 /// spells it a footnote nor the brackets around it.
330 pub label: String,
331 /// The note's body as source bytes (see
332 /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
333 /// document defines no `[^label]:` to read one from.
334 pub text: Option<String>,
335 /// Where the note's *body* starts, for a "go to note" that moves the caret
336 /// there. `None` alongside a `None` `text`.
337 ///
338 /// The body rather than the definition, because this is an offset to put a
339 /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
340 /// aiming at the definition's first byte snaps to the nearest real stop,
341 /// which is up in the paragraph above the note. It is also simply where a
342 /// reader following a reference wants to land: at the note's first word,
343 /// ready to read or amend it.
344 pub offset: Option<usize>,
345 /// Where the note's body ends, exclusive — so a frontend can ask which
346 /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
347 ///
348 /// The rows are the note with its markup resolved: `see *later*` reaches a
349 /// frontend as an italic run, not as asterisks. `text` is the source bytes
350 /// and stays the honest answer for anything that wants the note as written
351 /// (a search index, a copy); this pair of offsets is for anything that wants
352 /// it as *read*. `None` alongside a `None` `offset`.
353 pub end: Option<usize>,
354}
355
356/// A footnote definition and the reference that sends a reader to it — the
357/// answer to [`Doc::footnote_definition_at`], and the other half of the round
358/// trip [`FootnoteRef`] starts.
359///
360/// A note is a place a reader *arrives*, so the useful thing to know while
361/// standing in one is the way back. Without this the jump to a note is a
362/// one-way door: the definitions sit at the foot of the document, so returning
363/// by hand means scrolling back up and finding the sentence again.
364#[derive(Clone, PartialEq, Eq, Debug)]
365pub struct FootnoteDef {
366 /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
367 /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
368 pub label: String,
369 /// Where the reference's *label* is, for a "back to reference" that moves
370 /// the caret there. `None` for a note nothing refers to — an orphan, which
371 /// is worth being able to say rather than silently doing nothing.
372 ///
373 /// The label rather than the reference's first byte, for
374 /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
375 /// and its label is the only part of it the caret can rest on.
376 ///
377 /// The *first* reference, when a label is cited more than once: a repeated
378 /// citation has no one true home, and the first is both the one a reader
379 /// most likely came from and the only choice that doesn't depend on how
380 /// they got here.
381 pub offset: Option<usize>,
382}
383
384/// Where a locator lands — the answer to [`Doc::locate`].
385///
386/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
387/// document, and a place is a span rather than a point: a reader following one
388/// wants the caret at its first byte, and a reader merely *peeking* at one wants
389/// the block it covers drawn. Both are served by carrying the whole span, and
390/// only one of the two can be recovered from an offset alone.
391#[derive(Clone, PartialEq, Eq, Debug)]
392pub struct Landing {
393 /// The first byte of the block the locator names — where a caret goes.
394 pub start: usize,
395 /// One past its last byte, so a frontend can map the pair through
396 /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
397 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
398 pub end: usize,
399}
400
401/// A selection cited out of the source: the text itself, up to a requested
402/// number of characters either side, and the byte range it came from. See
403/// [`Doc::selection_quote`].
404///
405/// The prefix and suffix are what make the quote *re-findable*: the same text
406/// can occur twice, and a little of what surrounded it is how a later reader —
407/// or the same document after an edit — tells the occurrences apart. The Web
408/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
409/// the name.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Quote {
412 /// The selected source, verbatim.
413 pub exact: String,
414 /// What immediately preceded it — possibly empty, at the document's start.
415 pub prefix: String,
416 /// What immediately followed it — possibly empty, at the document's end.
417 pub suffix: String,
418 /// Byte offset in the source where the selection begins.
419 pub start: usize,
420 /// Byte offset where it ends (exclusive).
421 pub end: usize,
422}
423
424/// A host-painted range of the source — an annotation's footprint, a search
425/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
426/// glyphs whose source falls inside it) and hands back the `id` when the
427/// reader activates it; what the range *means* is entirely the host's.
428///
429/// Ranges are source bytes, like the caret and the selection, so a host that
430/// anchors quotes against the source ([`Doc::selection_quote`] is the other
431/// half of that loop) can paint what it found without any coordinate
432/// conversion. A range that drifts off the text it meant is the host's to
433/// re-anchor; leaf draws what it is told.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct Highlight {
436 /// Byte offset in the source where the wash begins.
437 pub start: usize,
438 /// Byte offset where it ends (exclusive).
439 pub end: usize,
440 /// The host's name for it, handed back on activation. Opaque to leaf.
441 pub id: String,
442 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
443 /// nothing for the theme's default wash.
444 pub color: Option<String>,
445 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
446 /// a marker gets a small glyph in the margin beside its first line, and
447 /// the glyph — not the wash — is what activates it: the wash is ink, the
448 /// marker is the control, which is what lets a reader put a caret in (or
449 /// copy from) annotated text without a card leaping at them. The name is
450 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
451 /// as a class.
452 pub marker: Option<String>,
453}
454
455impl Highlight {
456 /// The range covering source `offset` in a list [`Doc::set_highlights`]
457 /// sorted, first by start where several overlap — the one place that
458 /// question is answered, for the frontends that paint by asking it as well
459 /// as for [`Doc::highlight_at`].
460 ///
461 /// The list is sorted by `(start, end)`, so the scan can stop at the first
462 /// range starting past `offset` rather than running to the end. A painter
463 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
464 /// one-shot form, for the host asking what the reader just activated.
465 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
466 highlights
467 .iter()
468 .take_while(|h| h.start <= offset)
469 .find(|h| offset < h.end)
470 }
471}
472
473/// [`Highlight::covering`] for a caller walking the document in order — which
474/// is every painter, since a frontend draws rows top to bottom and glyphs left
475/// to right.
476///
477/// The one-shot form is a scan from the front of the list per glyph, and a
478/// document with two hundred search hits pays that two hundred times a row. A
479/// range that ends at or before an offset can never cover that offset *or any
480/// later one*, so the cursor retires those permanently and each glyph costs the
481/// ranges that actually reach it. The answer is identical to
482/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
483/// the part that was being redone dropped, not a cheaper approximation.
484///
485/// Offsets are expected to arrive non-decreasing. One that goes backwards is
486/// still answered correctly: the cursor re-seats to the front, since a painter
487/// that revisits a row is asking a question the retired ranges may own again.
488pub struct HighlightCursor<'a> {
489 highlights: &'a [Highlight],
490 /// The first range not yet retired.
491 at: usize,
492 /// The last offset asked about, to notice a caller going backwards.
493 last: usize,
494}
495
496impl<'a> HighlightCursor<'a> {
497 pub fn new(highlights: &'a [Highlight]) -> Self {
498 HighlightCursor {
499 highlights,
500 at: 0,
501 last: 0,
502 }
503 }
504
505 /// The range covering `offset`, advancing the cursor past every range that
506 /// can no longer cover anything.
507 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
508 if offset < self.last {
509 self.at = 0;
510 }
511 self.last = offset;
512 while self
513 .highlights
514 .get(self.at)
515 .is_some_and(|h| h.end <= offset)
516 {
517 self.at += 1;
518 }
519 Highlight::covering(&self.highlights[self.at..], offset)
520 }
521}
522
523pub struct Doc {
524 editor: Editor,
525 pub format: Format,
526 pub path: PathBuf,
527 /// Current source, refreshed from the editor after every successful edit.
528 pub source: String,
529 /// The caret, as a byte offset into `source` (always on a char boundary).
530 pub caret: usize,
531 /// The selection's fixed end, if a selection is active; the moving end is
532 /// the caret. `None` means no selection.
533 pub anchor: Option<usize>,
534 pub dirty: bool,
535 pub status: Option<String>,
536 pub view: View,
537 /// Whether the document refuses to change — a *reading* surface over the
538 /// same rendering, selection, and navigation the editor has.
539 ///
540 /// Enforced here rather than by each frontend hiding its input paths,
541 /// because every mutation funnels through three doors —
542 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
543 /// [`redo`](Self::redo) — and three guarded doors are a guarantee where a
544 /// frontend's suppressed keyboard is a hope. A gated splice reports
545 /// exactly like a rolled-back one, a path every caller already handles.
546 read_only: bool,
547 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
548 /// State like the selection rather than like the text: no edit history,
549 /// no dirty bit, redrawn from whatever the host last set.
550 highlights: Vec<Highlight>,
551 /// How much of the source markup the rich view exposes — a frontend preference (see
552 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
553 /// [`reveal_line`](Self::reveal_line), the editing one by
554 /// [`insert`](Self::insert).
555 markup_mode: MarkupMode,
556 /// Whether soft breaks fold into the reflowed paragraph or render where
557 /// they were written (see [`LineFlow`]) — an independent frontend
558 /// preference the WYSIWYG builder consults when it lays out a block.
559 line_flow: LineFlow,
560 /// The kind of the last edit, for coalescing: twig owns the undo *history*
561 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
562 /// call, so leaf decides when a run continues and tells twig to coalesce.
563 last_edit_kind: Option<EditKind>,
564 /// The inline marks the user has toggled *at a collapsed caret* with no
565 /// selection — "start typing bold here". Held as the XOR delta from the marks
566 /// already in force at [`pending_at`](Self::pending_at): a set bit means
567 /// "flip this kind for the next typed text", so it both turns a mark on where
568 /// none is (type into bold) and off where one already covers the caret (type
569 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
570 /// freshly typed text and then clears it — a mark once realised is carried by
571 /// the caret sitting inside the run, not by this delta.
572 pending_marks: InlineMarks,
573 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
574 /// delta is live only while the caret still stands here with no selection;
575 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
576 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
577 pending_at: Option<usize>,
578 /// The source as of the last open/save — `dirty` is `source != clean_source`,
579 /// so undoing back to the saved state correctly clears the modified flag.
580 clean_source: String,
581 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
582 /// while the document has no file behind it. [`Doc::disk_state`] compares
583 /// the file against this to catch an edit made *outside* leaf before a save
584 /// silently overwrites it — `clean_source` only knows what leaf itself did.
585 ///
586 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
587 /// writes inside one filesystem timestamp tick are indistinguishable, a
588 /// clock that steps backwards (or a writer that restores an mtime) hides a
589 /// real change, and a `touch` invents one. The whole point of the watermark
590 /// is to not clobber someone's work, so it reads the bytes and compares what
591 /// is actually there. That costs a file read per question, which is why the
592 /// question is asked on a user event (focus, save) and not every frame.
593 disk_hash: Option<u64>,
594 /// The "sticky" display column vertical motion aims for, in the active
595 /// view's grid. Set on the first `move_up`/`move_down` of a run and
596 /// reused by every subsequent one in that run, so passing through a
597 /// shorter line doesn't permanently forget the original column. Any
598 /// horizontal motion or edit clears it.
599 ///
600 /// A column, not a character index: dropping down a line of `你好` onto one
601 /// of ASCII has to land under the glyph the caret was drawn beneath, which
602 /// is the only thing the user can see to aim by. Where the goal falls inside
603 /// a wide character on the target line, the mapping resolves it to that
604 /// character — the caret lands on it rather than between its cells.
605 goal_col: Option<usize>,
606 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
607 /// and clicks read it to stay in visible space.
608 pub vmap: VisualMap,
609 /// The syntax map for the source view; empty in the WYSIWYG view, which
610 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
611 /// frontend that never calls it paints raw source unstyled, which is what
612 /// every frontend did before this map existed.
613 pub smap: SourceMap,
614 /// The revision `smap` was built from, or `None` before the first build.
615 /// The map is a pure function of the text alone — no width, no caret, no
616 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
617 /// whole key.
618 smap_key: Option<u64>,
619 /// Everything the map is built from, as one number: bumped whenever the
620 /// document's text changes, and never by a motion, a selection, or a save.
621 /// A frontend can hold work against it — see [`Doc::revision`].
622 revision: u64,
623 /// What `vmap` was built from, or `None` before the first build. The map is
624 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
625 /// moved, rebuilding it produces the identical map — see
626 /// [`Doc::build_visual`].
627 ///
628 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
629 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
630 /// text and width alone, and a caret motion still rebuilds nothing.
631 vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
632 /// Per-block row cache backing the incremental rebuild: when the text
633 /// changes, only the top-level blocks whose bytes moved are re-rendered and
634 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
635 /// builds; a pure accelerator, so it's never read for correctness.
636 block_cache: wysiwyg::BlockCache,
637 /// How many visual rows each block image reserves, keyed by its destination —
638 /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
639 /// measured the pictures. Core does no image I/O, so this is the only way it
640 /// learns a picture's height; a destination not in the map reserves the bare
641 /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
642 /// sizes each placeholder, and folded into `vmap_key` so a height change
643 /// rebuilds the map.
644 media_rows: HashMap<String, usize>,
645
646 // View geometry the renderer stamps each frame, so mouse events can map a
647 // screen cell back to a byte offset.
648 pub scroll: usize,
649 pub body_origin: (u16, u16),
650 /// Width of the body rectangle last painted by the frontend. Zero means
651 /// unknown (used by tests or a frontend that has not drawn yet).
652 pub body_width: u16,
653 pub body_height: u16,
654 /// The caret as of the last frame drawn, or `None` before the first.
655 ///
656 /// Scrolling is the viewport's business, not the caret's: the view follows
657 /// the caret when the caret *moves*, but a wheel that doesn't touch the
658 /// caret has to be free to scroll away from it — otherwise the view is
659 /// pinned to the caret and stops dead at the edge of the document you can
660 /// see. Comparing against this is what tells the two apart, and it catches a
661 /// caret set by any route, including a frontend assigning the field itself.
662 pub drawn_caret: Option<usize>,
663}
664
665/// The Markdown extensions every leaf document is parsed with. `html_elements`
666/// and `directives` depart from twig's defaults. `html_elements` promotes
667/// embedded raw HTML (`<img>`, `<picture>`, `<source>`, …) into semantic AST
668/// nodes, so a picture becomes a real `image` node the frontends can frame and
669/// rasterize instead of opaque `raw_block` text. `directives` turns on generic
670/// `:::name{.class}` fenced-div containers (`directive` nodes), which a host
671/// app uses for its own semantics (diaryx's `:::vis{.audience}` visibility
672/// blocks) — core renders any directive as a plain tinted container, agnostic
673/// of `name`. Both flags are inert for non-Markdown formats, so it's safe to
674/// pass them unconditionally. Threading this through every constructor (not
675/// just `open`) keeps `from_source`, `blank`, and `reload` parsing the same
676/// document the same way — twig reparses with these same flags after each edit.
677fn parse_extensions() -> MarkdownExtensions {
678 MarkdownExtensions {
679 html_elements: true,
680 directives: true,
681 ..Default::default()
682 }
683}
684
685/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
686/// mapping twig's error into the `anyhow` context every constructor shares.
687fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
688 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
689}
690
691/// Does `format` spell a table as a **pipe table** — the one grid twig's table
692/// editor knows how to emit?
693///
694/// This is the single capability leaf still has to answer for itself, and the
695/// only hand-maintained format list left in this file. Every other gesture is
696/// [`Format::supports`], which is twig's own answer read across the C ABI — but
697/// twig deliberately leaves the table ops out of that query, because they read
698/// no `Syntax` table at all. They rewrite a grid that is already in the source
699/// and refuse on *position*, never on format. Handed a caret inside an HTML
700/// `<table>`, `table_insert_row` therefore re-emits the whole element as
701/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
702/// `dirty` flag, and nothing downstream able to tell it from a good edit.
703///
704/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
705/// wildcard answers "no" for a format leaf has never heard of: a new twig
706/// language that *does* spell pipe tables loses its grid controls until this
707/// line is updated, which shows up as a missing button. The other default hands
708/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
709fn spells_pipe_tables(format: Format) -> bool {
710 matches!(format, Format::Markdown | Format::Djot)
711}
712
713/// Which of leaf's authoring controls this document's format can actually
714/// spell — one flag per toolbar button, resolved once so a frontend can build
715/// its chrome instead of discovering each refusal on a click.
716///
717/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
718/// the matching [`Doc`] method calls, so this record cannot drift from what the
719/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
720/// export.
721///
722/// **The formats are ragged, and that is the point.** A single per-document
723/// boolean was enough while the two authorable formats were Markdown and djot
724/// and everything else spelled nothing. HTML is neither: it writes seven of the
725/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
726/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
727/// no link — because its versions of those have a different *shape*, not a
728/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
729/// one flag can say that. Markdown and djot differ from each other too:
730/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
731#[derive(Clone, Copy, Debug, Eq, PartialEq)]
732pub struct Capabilities {
733 /// ⌘B — `InlineKind::Strong`.
734 pub bold: bool,
735 /// ⌘I — `InlineKind::Emph`.
736 pub italic: bool,
737 /// Inline code — `InlineKind::Verbatim`.
738 pub code: bool,
739 /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
740 pub mark: bool,
741 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
742 pub underline: bool,
743 /// Strikethrough — `InlineKind::Delete`.
744 pub strike: bool,
745 pub superscript: bool,
746 pub subscript: bool,
747 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
748 pub heading: bool,
749 pub blockquote: bool,
750 pub bullet_list: bool,
751 pub ordered_list: bool,
752 /// The checkbox controls: giving an item a box, and ticking one.
753 pub task: bool,
754 pub link: bool,
755 /// Covers [`Doc::insert_media`] too — see the note there on why the three
756 /// media kinds stand or fall together.
757 pub image: bool,
758 /// The horizontal-rule button. HTML spells this one (`<hr>`).
759 pub thematic_break: bool,
760 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
761 /// the pair; HTML has no footnote of its own, so the button goes away rather
762 /// than writing brackets that would render as brackets.
763 pub footnote: bool,
764 /// Setting a fenced block's language — a control only ever offered with the
765 /// caret already in a fence.
766 pub code_language: bool,
767 /// The grid controls: insert/delete/move a row or column, set a column's
768 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
769 /// question — an HTML `<table>` holds the caret and still can't be edited.
770 pub table: bool,
771 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
772 /// idiomatic in-cell break.
773 pub cell_line_break: bool,
774}
775
776impl Capabilities {
777 /// Resolve every flag for `format`. Pure and cheap — twig computes each from
778 /// a static table — but a frontend that wants to hold them can.
779 pub fn of(format: Format) -> Self {
780 let inline = |k| format.supports(Gesture::ToggleInline(k));
781 let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
782 Self {
783 bold: inline(InlineKind::Strong),
784 italic: inline(InlineKind::Emph),
785 code: inline(InlineKind::Verbatim),
786 mark: inline(InlineKind::Mark),
787 underline: inline(InlineKind::Insert),
788 strike: inline(InlineKind::Delete),
789 superscript: inline(InlineKind::Superscript),
790 subscript: inline(InlineKind::Subscript),
791 heading: format.supports(Gesture::SetBlock),
792 blockquote: container(BlockContainerKind::BlockQuote),
793 bullet_list: container(BlockContainerKind::BulletList),
794 ordered_list: container(BlockContainerKind::OrderedList),
795 // Both halves of the checkbox story, and leaf offers no control that
796 // needs only one: the item gesture mints the box, the checked one
797 // ticks it, and a format spelling a `task_marker` spells both.
798 task: format.supports(Gesture::ToggleTaskItem)
799 && format.supports(Gesture::ToggleTaskChecked),
800 link: format.supports(Gesture::InsertLink),
801 image: format.supports(Gesture::InsertImage),
802 thematic_break: format.supports(Gesture::InsertThematicBreak),
803 footnote: format.supports(Gesture::InsertFootnote),
804 code_language: format.supports(Gesture::SetCodeLanguage),
805 table: spells_pipe_tables(format),
806 cell_line_break: format.supports(Gesture::InsertLineBreak),
807 }
808 }
809}
810
811impl Doc {
812 #[cfg(feature = "fs")]
813 pub fn open(path: PathBuf) -> Result<Self> {
814 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
815 Self::from_disk_bytes(path, bytes)
816 }
817
818 /// An empty document *named* `path`, for a file that isn't there yet — what
819 /// every other terminal editor gives you when you name a file that doesn't
820 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
821 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
822 /// the header shows the name the user asked for.
823 ///
824 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
825 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
826 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
827 /// parse is still an error: a mistyped flag or a stray argument should say
828 /// so, not open a buffer promising to save somewhere.
829 ///
830 /// The watermark is the hash of *no bytes*, not `None`, and that is the
831 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
832 /// answering [`DiskState::Untitled`] for a document that has a path and
833 /// intends to write to it. Hashing `""` instead makes the answers the true
834 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
835 /// recreates it, which is exactly what this is for), and
836 /// [`DiskState::Changed`] if somebody creates it underneath us between
837 /// launch and save, so the frontend's overwrite prompt guards a new file as
838 /// it guards an opened one.
839 ///
840 /// Nothing is written here. A buffer that is never typed into never touches
841 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
842 /// open — the write is where that fails, and it says so then.
843 #[cfg(feature = "fs")]
844 pub fn create(path: PathBuf) -> Result<Self> {
845 Self::from_disk_bytes(path, Vec::new())
846 }
847
848 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
849 /// the call a CLI frontend wants for its path argument.
850 ///
851 /// The decision is made from the failed read itself rather than a `exists()`
852 /// check first, so there is no window between the two for the file to appear
853 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
854 /// a directory in the way is still an error, because pretending those are
855 /// "no file yet" would offer to save over something leaf couldn't read.
856 #[cfg(feature = "fs")]
857 pub fn open_or_create(path: PathBuf) -> Result<Self> {
858 match std::fs::read(&path) {
859 Ok(bytes) => Self::from_disk_bytes(path, bytes),
860 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
861 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
862 }
863 }
864
865 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
866 /// stand in for) the file at `path`, parsed as the format its extension
867 /// names. Keeping the two on one path is what makes a new file's document
868 /// identical in every respect to an opened one but its contents.
869 #[cfg(feature = "fs")]
870 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
871 let format = detect_format(&path)?;
872 let editor = new_editor(&bytes, format)?;
873 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
874 let disk_hash = Some(hash_bytes(source.as_bytes()));
875 // Store the document's *absolute* path. A relative one (`leaf README.md`)
876 // has an empty parent, so a frontend can't resolve a relative image
877 // destination (``) against the document's directory and the
878 // picture silently falls back to its text placeholder. `absolute` is
879 // purely lexical — it prefixes the current directory and normalizes, but
880 // reads nothing and resolves no symlinks — so `file_name` and save are
881 // unchanged; it only gives `path.parent()` something to join against.
882 let path = std::path::absolute(&path).unwrap_or(path);
883 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
884 }
885
886 /// Build a document from an in-memory string, the format named explicitly —
887 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
888 /// path and sniffs the format from its extension). A wasm or FFI host, which
889 /// has no path to read, uses this: it hands over bytes it fetched however it
890 /// could, and later persists [`Doc::source`] however it can (a browser
891 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
892 ///
893 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
894 /// true) exactly like a [`Doc::blank`] that has been given content.
895 pub fn from_source(source: String, format: Format) -> Result<Self> {
896 let editor = new_editor(source.as_bytes(), format)?;
897 Ok(Doc::from_parts(
898 editor,
899 format,
900 PathBuf::new(),
901 source,
902 None,
903 ))
904 }
905
906 /// An untitled, empty document — the `+` button and a `leaf` launched with
907 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
908 ///
909 /// It is Markdown, because a format has to be chosen before a name exists to
910 /// read one from: `detect_format` reads the extension and an untitled
911 /// document has neither. Markdown is what leaf's own files are, what its
912 /// block markers are already written for (`insert_block_prefix`), and the
913 /// extension a Save As will overwhelmingly pick — a wrong guess here would
914 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
915 /// *doesn't* revisit this: see [`Doc::save_as`].
916 pub fn blank() -> Result<Self> {
917 let format = Format::Markdown;
918 let editor = new_editor(b"", format)?;
919 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
920 // field two frontends already read; making it an `Option` to say this
921 // would break both). `is_untitled` is the question to ask, not the
922 // representation to copy.
923 Ok(Doc::from_parts(
924 editor,
925 format,
926 PathBuf::new(),
927 String::new(),
928 None,
929 ))
930 }
931
932 /// The fields every constructor agrees on, so `open` and `blank` can't drift
933 /// apart in the ones neither of them has an opinion about.
934 fn from_parts(
935 editor: Editor,
936 format: Format,
937 path: PathBuf,
938 source: String,
939 disk_hash: Option<u64>,
940 ) -> Self {
941 Doc {
942 editor,
943 format,
944 path,
945 disk_hash,
946 clean_source: source.clone(),
947 source,
948 caret: 0,
949 anchor: None,
950 dirty: false,
951 status: None,
952 read_only: false,
953 highlights: Vec::new(),
954 // leaf opens in the rich-text (WYSIWYG) view by default — the
955 // markup-resolved surface is leaf's differentiator. Frontends can
956 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
957 // toggles at runtime.
958 view: View::Wysiwyg,
959 // `None` by default — the clean surface Diaryx ships, with typed
960 // syntax kept literal; a markup-fluent frontend can climb the
961 // ladder to `Shortcuts` or `Full`.
962 markup_mode: MarkupMode::default(),
963 // Fold by default — flowing prose that reflows to the viewport, the
964 // behaviour every frontend had before this preference existed.
965 line_flow: LineFlow::default(),
966 last_edit_kind: None,
967 pending_marks: InlineMarks::empty(),
968 pending_at: None,
969 goal_col: None,
970 vmap: VisualMap::default(),
971 smap: SourceMap::default(),
972 // No map yet — the first `build_source` always builds.
973 smap_key: None,
974 revision: 0,
975 // No map yet — the first `build_visual` always builds.
976 vmap_key: None,
977 block_cache: wysiwyg::BlockCache::default(),
978 media_rows: HashMap::new(),
979 scroll: 0,
980 body_origin: (0, 0),
981 body_width: 0,
982 body_height: 0,
983 drawn_caret: None,
984 }
985 }
986
987 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
988 /// has never been saved. The question a ⌘S handler asks to know it should
989 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
990 /// header asks to know the name it shows is a placeholder.
991 pub fn is_untitled(&self) -> bool {
992 self.path.as_os_str().is_empty()
993 }
994
995 pub fn toggle_view(&mut self) {
996 self.view = match self.view {
997 View::Source => View::Wysiwyg,
998 View::Wysiwyg => View::Source,
999 };
1000 self.scroll = 0;
1001 self.status = None;
1002 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1003 // lift it to the first rendered offset.
1004 self.clamp_caret();
1005 }
1006
1007 /// The current markup-exposure preference (see [`MarkupMode`]).
1008 pub fn markup_mode(&self) -> MarkupMode {
1009 self.markup_mode
1010 }
1011
1012 /// Set the markup-exposure preference. Both of its axes take effect at
1013 /// once: the editing one on the next [`insert`](Self::insert), and the
1014 /// rendering one on the next build — which is why this drops the cached
1015 /// visual map and the per-block render cache, exactly as
1016 /// [`set_line_flow`](Self::set_line_flow) does.
1017 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1018 if self.markup_mode == mode {
1019 return;
1020 }
1021 self.markup_mode = mode;
1022 // Neither cache is keyed on the mode, and moving between `Full` and the
1023 // hidden modes changes every row the caret's line renders to — so
1024 // invalidate both explicitly.
1025 self.vmap_key = None;
1026 self.block_cache = wysiwyg::BlockCache::default();
1027 }
1028
1029 /// The source byte range of the line the caret sits on, when that line
1030 /// should render its raw delimiters — `None` in every mode and view that
1031 /// hides them, which is what the builder reads as "reveal nothing".
1032 ///
1033 /// A *source* line (newline to newline), not a visual row: a wrapped
1034 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1035 /// line across several rows, and revealing half a delimiter pair because the
1036 /// other half wrapped would be worse than revealing neither. The range
1037 /// excludes the terminating newline and is empty-but-present on a blank
1038 /// line, which reveals nothing but still keys the caches correctly.
1039 ///
1040 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1041 /// there is nothing there to reveal.
1042 pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1043 if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1044 return None;
1045 }
1046 Some(source_line_range(&self.source, self.caret))
1047 }
1048
1049 /// The current soft-break flow preference (see [`LineFlow`]).
1050 pub fn line_flow(&self) -> LineFlow {
1051 self.line_flow
1052 }
1053
1054 /// Set the soft-break flow preference. The mode changes how every block lays
1055 /// out, so a change drops the cached visual map and the per-block render
1056 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1057 ///
1058 /// [`build_visual`]: Self::build_visual
1059 pub fn set_line_flow(&mut self, mode: LineFlow) {
1060 if self.line_flow == mode {
1061 return;
1062 }
1063 self.line_flow = mode;
1064 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1065 // so invalidate them explicitly, or the next build would reuse rows laid
1066 // out under the old flow.
1067 self.vmap_key = None;
1068 self.block_cache = wysiwyg::BlockCache::default();
1069 }
1070
1071 pub fn view_name(&self) -> &'static str {
1072 match self.view {
1073 View::Source => "source",
1074 View::Wysiwyg => "wysiwyg",
1075 }
1076 }
1077
1078 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1079 /// (called by the renderer each frame it's in the WYSIWYG view).
1080 /// Build the WYSIWYG map, wrapped at `width` display columns.
1081 ///
1082 /// Cheap to call every frame, which is what both frontends do: the map is a
1083 /// pure function of the document and the wrap width, so a call that would
1084 /// rebuild the same map returns the one already built. Only an edit (or a
1085 /// resize) pays.
1086 ///
1087 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1088 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1089 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1090 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1091 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1092 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1093 pub fn build_visual(&mut self, width: usize) {
1094 self.build_map(Some(width));
1095 }
1096
1097 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1098 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1099 /// than a fixed character column.
1100 pub fn build_visual_unwrapped(&mut self) {
1101 self.build_map(None);
1102 }
1103
1104 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1105 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1106 /// styling for [`View::Wysiwyg`].
1107 ///
1108 /// A frontend calls this before painting raw source. One that doesn't gets
1109 /// an empty map and paints unstyled text, so this is additive: nothing
1110 /// breaks by not calling it.
1111 ///
1112 /// Built at most once per revision, and the revision is the whole key — the
1113 /// map has no width and no caret in it, so it survives every resize, every
1114 /// motion, and every selection change.
1115 ///
1116 /// The builds it does do cost a whole-arena marshal, which is precisely what
1117 /// the WYSIWYG path works to avoid, so this has no incremental path where
1118 /// that one has two. From `cargo run --release -p leaf-core --example
1119 /// bench`, per keystroke, against the WYSIWYG build the source view is
1120 /// *not* doing:
1121 ///
1122 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1123 /// |------:|-------:|--------:|----------------:|-------------------:|
1124 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1125 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1126 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1127 ///
1128 /// Linear, two thirds of it the marshal, and the build itself five to seven
1129 /// times cheaper than the one it stands in for at every size. Comfortable
1130 /// well past any document a person edits in a terminal — a megabyte is where
1131 /// it would want [`Editor::dirty_range`] and the same splice treatment
1132 /// `build_spliced` gives the other map. The door is open; nothing has needed
1133 /// it yet.
1134 pub fn build_source(&mut self) {
1135 if self.smap_key == Some(self.revision) {
1136 return;
1137 }
1138 let nodes = self.nodes();
1139 self.smap = source::build(&nodes, &self.source);
1140 self.smap_key = Some(self.revision);
1141 }
1142
1143 /// Tell the model how many visual rows each block image should reserve, keyed
1144 /// by the image's destination. A terminal frontend calls this once it has
1145 /// decoded and measured its pictures — core does no image I/O, so this is the
1146 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1147 /// placeholder out that tall (the label row plus blank filler rows the
1148 /// frontend paints the raster over). A destination left out of the map falls
1149 /// back to the bare one-row placeholder, which is also what a frontend that
1150 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1151 /// by never calling this.
1152 ///
1153 /// Cheap to call every frame with the same map: only a *change* invalidates
1154 /// the built map (and the block-row cache, since a height isn't part of a
1155 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1156 /// no-op, so a frontend can just hand over its current measurements each frame.
1157 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1158 if self.media_rows == rows {
1159 return;
1160 }
1161 self.media_rows = rows;
1162 // A height lives outside the block's source bytes, so the content-keyed
1163 // block cache would hand back the old-height rows on a hit. Drop it (and
1164 // the splice layout it carries) so the next build re-renders every block
1165 // at the new heights, and force that build by clearing the map key.
1166 self.block_cache = wysiwyg::BlockCache::default();
1167 self.vmap_key = None;
1168 }
1169
1170 /// The revision the document's text is at — bumped by every edit, undo,
1171 /// redo, and reload, and by nothing else. A frontend caches against this to
1172 /// tell a repaint that needs new work from one that doesn't.
1173 ///
1174 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1175 /// lands on the same text two revisions later. Work is only ever rebuilt
1176 /// needlessly, never wrongly reused.
1177 pub fn revision(&self) -> u64 {
1178 self.revision
1179 }
1180
1181 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1182 /// runs on every call: the caret moves without the document changing, and
1183 /// keeping it on a legal stop is this function's job either way.
1184 fn build_map(&mut self, wrap: Option<usize>) {
1185 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1186 // as well as the text, so the line joins the key: moving within a line
1187 // still reuses the map, and crossing into another one rebuilds it. In
1188 // every other mode `reveal_line` is `None` and the key is what it was,
1189 // so caret motion goes on costing nothing.
1190 let reveal = self.reveal_line();
1191 let key = (self.revision, wrap, reveal.clone());
1192 if self.vmap_key.as_ref() != Some(&key) {
1193 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1194 // A subtree is pulled only for the block(s) that actually changed, so
1195 // the FFI marshal shrinks from O(document) to O(edited block).
1196 let top = self.top_blocks();
1197
1198 // Fast path: when twig reports a dirty byte range, try to patch the
1199 // previous map in place — a single-block edit moves the prefix,
1200 // shifts the suffix, and re-renders only one block. `build_spliced`
1201 // returns `None` (and we fall back to the always-correct full rebuild)
1202 // whenever the edit reshaped the block structure, hit a table, or
1203 // there's no previous map to patch.
1204 // Preserve soft breaks as written when the flow preference asks for
1205 // it — the builder renders each as its own visual row instead of
1206 // folding it into the reflowed paragraph.
1207 let preserve_soft = self.line_flow == LineFlow::Preserve;
1208 let spliced = match self.editor.dirty_range() {
1209 Some(dirty) => {
1210 let prev = std::mem::take(&mut self.vmap);
1211 let source = &self.source;
1212 let cache = &mut self.block_cache;
1213 let media_rows = &self.media_rows;
1214 let editor = &mut self.editor;
1215 wysiwyg::build_spliced(
1216 prev,
1217 source,
1218 wrap,
1219 preserve_soft,
1220 &top,
1221 dirty,
1222 media_rows,
1223 reveal.clone(),
1224 cache,
1225 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1226 )
1227 }
1228 None => None,
1229 };
1230 self.vmap = spliced.unwrap_or_else(|| {
1231 let source = &self.source;
1232 let cache = &mut self.block_cache;
1233 let media_rows = &self.media_rows;
1234 let editor = &mut self.editor;
1235 wysiwyg::build_cached(
1236 &top,
1237 source,
1238 wrap,
1239 preserve_soft,
1240 media_rows,
1241 reveal,
1242 cache,
1243 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1244 )
1245 });
1246 // Acknowledge the dirty range so the next edit's range starts fresh.
1247 self.editor.clear_dirty();
1248 self.vmap_key = Some(key);
1249 }
1250 self.clamp_caret();
1251 }
1252
1253 fn nodes(&mut self) -> Vec<FlatNode> {
1254 self.editor.nodes().unwrap_or_default()
1255 }
1256
1257 /// The document's top-level blocks for the incremental render. See
1258 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1259 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1260 wysiwyg::top_blocks(&mut self.editor)
1261 }
1262
1263 pub fn format_name(&self) -> &'static str {
1264 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1265 // required. It also covers `Asciidoc`, which twig parses but cannot
1266 // serialize — leaf never opens a document in it (see `Doc::open`).
1267 match self.format {
1268 Format::Djot => "djot",
1269 Format::Markdown => "markdown",
1270 Format::Xml => "xml",
1271 Format::Html => "html",
1272 _ => "unknown",
1273 }
1274 }
1275
1276 /// Whether this document's format offers *any* door in — `false` only for a
1277 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1278 /// a frontend may as well open the file read-only.
1279 ///
1280 /// This is a much weaker claim than the name suggests, and driving per-button
1281 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1282 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1283 /// while a heading, a quote, a list, a task box, a link and a code fence all
1284 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1285 /// [`supports`](Self::supports) — per control.
1286 pub fn authorable(&self) -> bool {
1287 self.format.is_authorable()
1288 }
1289
1290 /// Whether this document's format can spell `gesture`, which is twig's own
1291 /// answer rather than a copy of it: `Format::supports` reads the same
1292 /// `Syntax` table the `Editor` method consults before refusing.
1293 ///
1294 /// It is a fact about the *format*, not about the caret. `true` does not
1295 /// promise the gesture succeeds where it is standing — a link over a table
1296 /// border still fails — only that it will not fail with
1297 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1298 /// will work here".
1299 pub fn supports(&self, gesture: Gesture) -> bool {
1300 self.format.supports(gesture)
1301 }
1302
1303 /// Every control's enabled state in one read — what a toolbar builds itself
1304 /// from when a document opens or its format changes. See [`Capabilities`].
1305 pub fn capabilities(&self) -> Capabilities {
1306 Capabilities::of(self.format)
1307 }
1308
1309 /// Refuse a gesture this document's format cannot spell, saying so in the
1310 /// status line. `true` means the caller must return without calling twig.
1311 ///
1312 /// Most of these refusals duplicate one twig would make anyway, and they are
1313 /// made here regardless because a message naming the *document's* format
1314 /// reads better than one naming twig's internals. Two of them are not
1315 /// duplicates and are the reason this is a guard rather than an error
1316 /// translation:
1317 ///
1318 /// - The table family (see [`table_op`](Self::table_op)) consults no
1319 /// `Syntax` table, so twig does not refuse it at all.
1320 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1321 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1322 /// could not keep.
1323 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1324 self.refuse_unless(what, self.supports(gesture))
1325 }
1326
1327 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1328 /// answers itself — today only [`spells_pipe_tables`].
1329 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1330 if supported {
1331 return false;
1332 }
1333 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1334 true
1335 }
1336
1337 /// The name to show for this document. An untitled one has no file to name
1338 /// it, and both frontends put this straight on screen — an empty path
1339 /// renders as an empty header, so it says so instead.
1340 pub fn file_name(&self) -> String {
1341 if self.is_untitled() {
1342 return "untitled".into();
1343 }
1344 self.path
1345 .file_name()
1346 .map(|s| s.to_string_lossy().into_owned())
1347 .unwrap_or_else(|| self.path.display().to_string())
1348 }
1349
1350 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1351 /// caret and anchor coincide (an empty selection is no selection).
1352 pub fn selection(&self) -> Option<(usize, usize)> {
1353 self.anchor
1354 .map(|a| (a.min(self.caret), a.max(self.caret)))
1355 .filter(|(s, e)| s != e)
1356 }
1357
1358 /// The selected text, or `None` when there's no selection — the source
1359 /// slice a copy/cut hands to the system clipboard.
1360 pub fn selected_text(&self) -> Option<&str> {
1361 self.selection().map(|(s, e)| &self.source[s..e])
1362 }
1363
1364 /// The selection as a quote with a little of what surrounds it — the shape
1365 /// a host that cites, annotates, or searches for a passage wants, cut from
1366 /// the **source** rather than from anything rendered, so the quote is
1367 /// findable in the document again by plain string search.
1368 ///
1369 /// `context` is a count of characters (not bytes) on each side, clipped at
1370 /// the document's edges; the slices land on char boundaries by
1371 /// construction. `None` when nothing is selected.
1372 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1373 let (start, end) = self.selection()?;
1374 let mut before = start;
1375 for _ in 0..context {
1376 match self.source[..before].chars().next_back() {
1377 Some(c) => before -= c.len_utf8(),
1378 None => break,
1379 }
1380 }
1381 let mut after = end;
1382 for _ in 0..context {
1383 match self.source[after..].chars().next() {
1384 Some(c) => after += c.len_utf8(),
1385 None => break,
1386 }
1387 }
1388 Some(Quote {
1389 exact: self.source[start..end].to_string(),
1390 prefix: self.source[before..start].to_string(),
1391 suffix: self.source[end..after].to_string(),
1392 start,
1393 end,
1394 })
1395 }
1396
1397 /// Whether the document refuses to change — see the field.
1398 pub fn read_only(&self) -> bool {
1399 self.read_only
1400 }
1401
1402 /// Turn the read-only gate on or off. A frontend preference like
1403 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1404 /// itself changes, only what may be done to it from here on.
1405 pub fn set_read_only(&mut self, on: bool) {
1406 self.read_only = on;
1407 }
1408
1409 /// The host-painted ranges, sorted by start — see [`Highlight`].
1410 pub fn highlights(&self) -> &[Highlight] {
1411 &self.highlights
1412 }
1413
1414 /// Replace the host-painted ranges wholesale. The whole set each time,
1415 /// rather than add/remove verbs: the host owns the list (it derives it
1416 /// from its own state — annotations, search hits), and a replace can
1417 /// never leave the two disagreeing about what should be on screen.
1418 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1419 highlights.retain(|h| h.start < h.end);
1420 highlights.sort_by_key(|h| (h.start, h.end));
1421 self.highlights = highlights;
1422 }
1423
1424 /// The highlight covering source `offset`, if one does — first by start
1425 /// when several overlap, which makes overlapping washes resolvable rather
1426 /// than undefined. What a frontend asks when the reader activates a spot.
1427 ///
1428 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1429 /// asking the same question per glyph, against a slice they were handed
1430 /// rather than against a `Doc`, and one answer for both is what keeps a
1431 /// wash and an activation agreeing about which range a spot is in.
1432 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1433 Highlight::covering(&self.highlights, offset)
1434 }
1435
1436 /// The AST breadcrumb at the caret (root → deepest), e.g.
1437 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1438 pub fn breadcrumb(&mut self) -> String {
1439 match self.editor.ancestors_at(self.caret) {
1440 Ok(chain) => chain
1441 .iter()
1442 .map(|m| m.kind.as_str())
1443 .collect::<Vec<_>>()
1444 .join(" › "),
1445 Err(_) => String::new(),
1446 }
1447 }
1448
1449 // ── editing ──────────────────────────────────────────────────────────────
1450
1451 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1452 /// after it. The public form of the internal splice — a pixel frontend that
1453 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1454 /// edits through this, the same twig `edit_range` the caret ops use.
1455 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1456 self.splice(start, end, text, EditKind::Other);
1457 }
1458
1459 /// Insert typed `text` at the caret, replacing the selection if there is one.
1460 /// A single typed character coalesces with the run of typing before it; a
1461 /// newline or a multi-character insert is its own undo step.
1462 ///
1463 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1464 pub fn insert(&mut self, text: &str) {
1465 // Typing against a block picture would dissolve it — see
1466 // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1467 // what the caret was standing beside stays a picture.
1468 self.open_paragraph_at_block_media(text);
1469 // Armed sticky marks (⌘b with no selection) turn the next typed text
1470 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1471 // the exception: it takes no mark of its own and keeps the delta armed
1472 // for the character behind it — see `insert_space_with_marks`.
1473 let pending = self.pending_here();
1474 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1475 if text.trim().is_empty() {
1476 self.insert_space_with_marks(self.caret, text, pending);
1477 } else {
1478 self.insert_with_marks(self.caret, text, pending);
1479 }
1480 return;
1481 }
1482 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1483 // anything that would open markup, so a Diaryx user never mints
1484 // formatting by keyboard (it comes from commands instead). The other two
1485 // rungs of the ladder author markup from what you type, which is the
1486 // whole difference between them and this one. Only in the rendered view
1487 // (source view is for typing raw markup) and only where the format has a
1488 // literal spelling at all: escaping is a backslash before a byte from the
1489 // format's own alphabet, and a format with no such alphabet (HTML escapes
1490 // with entities, XML spells nothing) would have `\&` written into it,
1491 // which is two literal characters and not an escape. Marks (⌘b) still
1492 // format — that path returned above; and leaf's own structural inserts go
1493 // through `insert_raw`, never here, so a list marker or quote gutter is
1494 // written as the markup it is.
1495 if !self.markup_mode.authors()
1496 && self.view == View::Wysiwyg
1497 && !text.is_empty()
1498 && self.supports(Gesture::InsertLiteral)
1499 {
1500 self.insert_literal_typed(text);
1501 return;
1502 }
1503 self.insert_raw(text);
1504 }
1505
1506 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1507 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1508 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1509 /// markup by design and must not be escaped.
1510 fn insert_raw(&mut self, text: &str) {
1511 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1512 self.splice(s, e, text, typed_edit_kind(text));
1513 }
1514
1515 /// Open a paragraph for text about to be inserted at one of a block media's
1516 /// two caret stops, and leave the caret standing in it.
1517 ///
1518 /// A block image is a paragraph whose entire content is the picture, and the
1519 /// caret's only homes on it are in front of it and just past it (see
1520 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1521 /// *that* paragraph — and a paragraph holding anything besides the image is
1522 /// no longer a block image but a line of text with an inline one in it. The
1523 /// frontend that was painting a photo there paints a text run instead; the
1524 /// picture is still in the file, and nothing said a word. Those two offsets
1525 /// are also exactly where a click on the picture lands, so the whole accident
1526 /// is one tap and one keystroke.
1527 ///
1528 /// So the break goes in first and the text lands in the new empty paragraph —
1529 /// what pressing Return before typing would have done, which is a habit no
1530 /// one should have to learn from losing a photo. A no-op everywhere else, and
1531 /// over a selection (which is replaced, not joined into).
1532 ///
1533 /// A picture inside a quote or a list leaves its container, because `\n\n`
1534 /// ends the block. The alternative is worse: the `\n> ` / next-item
1535 /// continuation [`newline`](Self::newline) writes stays in the same
1536 /// *paragraph*, which is the thing being prevented.
1537 ///
1538 /// Only in the rendered view. Source view is for typing raw markup, where
1539 /// putting a character against an image is exactly what it looks like.
1540 fn open_paragraph_at_block_media(&mut self, text: &str) {
1541 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1542 return;
1543 }
1544 if self.selection().is_some() {
1545 return;
1546 }
1547 // The map may be a revision behind (nothing has drawn since the last
1548 // edit), and this asks it about offsets — a stale answer would splice a
1549 // break into the wrong place. Free when it is already current, which it
1550 // is whenever a frontend drew a frame between keystrokes.
1551 self.rebuild_map();
1552 let at = self.caret;
1553 let Some((side, _)) = self.vmap.block_media_stop(at) else {
1554 return;
1555 };
1556 if !self.splice(at, at, "\n\n", EditKind::Other) {
1557 return;
1558 }
1559 // The break is part of the keystroke, not an edit of its own: leave the
1560 // run marked as typing so the character about to arrive folds into it and
1561 // one undo puts the document back the way it was found. (A paste, or a
1562 // multi-character insert, is `EditKind::Other` and stays its own step —
1563 // as it would have been anywhere else in the document.)
1564 self.last_edit_kind = Some(EditKind::Insert);
1565 if side == MediaStop::Before {
1566 // The break went in above the picture and the caret rode to the end
1567 // of it — which is still hard against the picture. Step back onto the
1568 // blank line it opened, so the text lands above rather than in front.
1569 self.caret = at;
1570 }
1571 }
1572
1573 /// A delete key pressed at one of a block picture's two caret stops, handled
1574 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1575 /// the key was consumed.
1576 ///
1577 /// The caret rests in front of a block image and just past it, never inside
1578 /// its markup — which the rendered view doesn't show. So the byte a delete
1579 /// key nominally takes there is one the writer cannot see, and taking it
1580 /// leaves the picture as broken markup rather than as anything anyone asked
1581 /// for: Backspace at the stop past `` removes the closing paren, and
1582 /// a photo becomes the literal text `
1585 /// prevents from the typing side, and it cost this repository's own test vault
1586 /// a photo before it was found.
1587 ///
1588 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1589 /// when it is behind the caret, Delete when it is in front — which is what
1590 /// every editor does with an embed, and one undo away. The key aimed *away*
1591 /// from it would otherwise delete the paragraph break and merge a neighbour
1592 /// into the picture's own paragraph, which dissolves it just as surely; it
1593 /// steps the caret over the boundary instead and leaves the
1594 /// next press to delete in the block it has reached — the same "first press
1595 /// steps out of the atom, second press deletes" every delete key here gets,
1596 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1597 /// above, and reaches it on the second press rather than taking the break and
1598 /// the picture with it on the first).
1599 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1600 // The map answers about offsets, so it has to be this revision's — see
1601 // the same call in `open_paragraph_at_block_media`.
1602 self.rebuild_map();
1603 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1604 return false;
1605 };
1606 let aimed_at_it = side
1607 == if forward {
1608 MediaStop::Before
1609 } else {
1610 MediaStop::After
1611 };
1612 if !aimed_at_it {
1613 let over = if forward {
1614 self.vmap.stop_after(self.caret)
1615 } else {
1616 self.vmap.stop_before(self.caret)
1617 };
1618 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1619 self.caret = off;
1620 self.anchor = None;
1621 self.goal_col = None;
1622 }
1623 return true;
1624 }
1625 // Take the break that held the picture apart from its neighbour with it,
1626 // so the delete doesn't leave a blank paragraph standing where the
1627 // picture was. The last arm is a picture that is the whole document.
1628 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1629 (span.start - 2, span.end)
1630 } else if self.source[span.end..].starts_with("\n\n") {
1631 (span.start, span.end + 2)
1632 } else {
1633 (span.start, span.end)
1634 };
1635 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1636 true
1637 }
1638
1639 /// The Hidden-mode typing path: replace any selection, then insert `text`
1640 /// escaped so it stays literal. When it replaces a selection the two edits
1641 /// fold into one undo step, so an overwrite undoes atomically (and restores
1642 /// the selection) exactly as a plain one does.
1643 fn insert_literal_typed(&mut self, text: &str) {
1644 let kind = typed_edit_kind(text);
1645 match self.selection() {
1646 Some((s, e)) => {
1647 if !self.splice(s, e, "", EditKind::Other) {
1648 return;
1649 }
1650 // Typing over a whole marked run takes its delimiters with it
1651 // (the empty content couldn't hold them — see
1652 // `repair_mark_edges`) and leaves its marks armed at the caret.
1653 // The text taking the run's place inherits them, exactly as it
1654 // would have by landing inside a run that survived.
1655 let pending = self.pending_here();
1656 if !pending.is_empty() && !text.trim().is_empty() {
1657 self.insert_with_marks(self.caret, text, pending);
1658 return;
1659 }
1660 self.insert_literal_at(self.caret, text, kind, true);
1661 }
1662 None => {
1663 self.insert_literal_at(self.caret, text, kind, false);
1664 }
1665 }
1666 }
1667
1668 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1669 /// at a collapsed caret, but only while the caret still stands where they
1670 /// were armed and nothing is selected. Empty otherwise, so a stale delta
1671 /// never styles text it wasn't meant for.
1672 fn pending_here(&self) -> InlineMarks {
1673 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1674 self.pending_marks
1675 } else {
1676 InlineMarks::empty()
1677 }
1678 }
1679
1680 /// Drop the armed sticky marks — any caret motion, selection, or edit does
1681 /// this, so "start bold here" only ever applies at the exact spot it was
1682 /// asked for.
1683 fn clear_pending(&mut self) {
1684 self.pending_marks = InlineMarks::empty();
1685 self.pending_at = None;
1686 }
1687
1688 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1689 /// force is wrapped around the freshly typed text; a mark the caret already
1690 /// stands inside is *shed* — the text is inserted past the run's end so it
1691 /// lands unmarked ("type normally again"). The caret comes to rest inside any
1692 /// added runs, so continued typing inherits the marks with no re-wrapping,
1693 /// and the delta is cleared: the marks now live in the document, not here.
1694 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1695 let base = self.mark_spans_at(at);
1696 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1697 // Nothing to shed, and a run of exactly these marks standing just behind
1698 // the caret: carry on writing *that* run rather than opening a second
1699 // one beside it.
1700 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1701 return;
1702 }
1703 // Shed the marks we're turning off: step the insertion point past the
1704 // end of each run the caret sits in, so the new text falls outside it.
1705 let mut ins_at = at;
1706 for (kind, span) in &base {
1707 if marks.contains(*kind) {
1708 ins_at = ins_at.max(span.end);
1709 }
1710 }
1711 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1712 return;
1713 }
1714 // The plain splice inserted exactly `text` at `ins_at`; that byte range
1715 // is the content every added mark wraps.
1716 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1717 for kind in marks.iter() {
1718 if !base_set.contains(kind) {
1719 let (ncs, nce) = self.wrap_span(cs, ce, kind);
1720 cs = ncs;
1721 ce = nce;
1722 }
1723 }
1724 self.caret = ce.min(self.source.len());
1725 self.anchor = None;
1726 self.last_edit_kind = None;
1727 // Realised: the marks are in the document now, and the caret sits inside
1728 // them, so there is no delta left to carry. Arm nothing, but remember the
1729 // spot so a *further* toggle before typing starts a clean delta here.
1730 self.pending_marks = InlineMarks::empty();
1731 self.pending_at = Some(self.caret);
1732 self.clamp_caret();
1733 self.record_caret();
1734 }
1735
1736 /// Carry on the marked run just behind `at` — moving its closing delimiters
1737 /// out past the new text — instead of opening a second run of the same marks
1738 /// beside it. Returns whether it did.
1739 ///
1740 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1741 /// A space typed after a bold word steps the caret out of the run, because
1742 /// `**bold **` is not bold; the next character has to step back *in*, or the
1743 /// writer who typed one bold phrase is left with `**bold** **and**` — two
1744 /// runs that read the same to a reader but spell the file in a way nobody
1745 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1746 /// words it isn't marking), and the marks behind it must be exactly the ones
1747 /// armed — a run of *some* other kind is a neighbour, not this phrase.
1748 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1749 if text.is_empty() || text.trim() != text {
1750 return false;
1751 }
1752 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1753 // Walk in through the delimiters stacked at that point, innermost last:
1754 // `***both*** ` closes two runs with one `***`, and rejoining means
1755 // getting behind all of them.
1756 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1757 while let Some((kind, content_end)) = self
1758 .editor
1759 .ancestors_at(prev_boundary(&self.source, cut))
1760 .unwrap_or_default()
1761 .into_iter()
1762 .filter(|m| m.span.end == cut)
1763 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1764 {
1765 if content_end >= cut {
1766 break; // a mark with no closing delimiter to step behind
1767 }
1768 kinds.insert(kind);
1769 cut = content_end;
1770 }
1771 if cut == gap_at || kinds != marks {
1772 return false;
1773 }
1774 // Re-spell the tail: the gap, then the new text, then the delimiters that
1775 // used to close in front of them — read out of the document rather than
1776 // written from a table, so whatever twig spells them with is what moves.
1777 let tail = format!(
1778 "{}{text}{}",
1779 &self.source[gap_at..at],
1780 &self.source[cut..gap_at]
1781 );
1782 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1783 return false;
1784 }
1785 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1786 self.anchor = None;
1787 self.last_edit_kind = None;
1788 self.pending_marks = InlineMarks::empty();
1789 self.pending_at = Some(self.caret);
1790 self.clamp_caret();
1791 self.record_caret();
1792 true
1793 }
1794
1795 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1796 /// never itself wrapped: a mark around a space draws nothing a reader can
1797 /// see, and in Markdown and Djot it draws its own delimiters instead
1798 /// (`** **`). So the space goes in unmarked — outside any run the armed
1799 /// marks are shedding — and the marks stay armed for the character after it,
1800 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1801 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1802 let base = self.mark_spans_at(at);
1803 // What the *next* character carries: the armed delta resolved against the
1804 // marks in force here, which the space must not quietly drop.
1805 let want = base
1806 .iter()
1807 .map(|(k, _)| *k)
1808 .collect::<InlineMarks>()
1809 .xor(marks);
1810 let mut ins_at = at;
1811 for (kind, span) in &base {
1812 if marks.contains(*kind) {
1813 ins_at = ins_at.max(span.end);
1814 }
1815 }
1816 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1817 return;
1818 }
1819 self.rearm(want);
1820 self.record_caret();
1821 }
1822
1823 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1824 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1825 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1826 /// added split evenly around the content — half the growth on each side.
1827 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1828 match self.editor.toggle_inline(s, e, kind) {
1829 Ok(change) => {
1830 self.last_edit_kind = None;
1831 self.refresh();
1832 self.dirty = self.source != self.clean_source;
1833 let added = (change.new.end - change.new.start).saturating_sub(e - s);
1834 let half = added / 2;
1835 (change.new.start + half, change.new.end - half)
1836 }
1837 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1838 // rather than lose the keystroke.
1839 Err(e2) => {
1840 self.status = Some(format!("{kind:?}: {e2}"));
1841 (s, e)
1842 }
1843 }
1844 }
1845
1846 /// The safe offset to splice a block-level break at, given a caret that may
1847 /// sit exactly between an inline mark's content and its own closing
1848 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1849 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1850 /// with nothing following it on the line: the closing `**` renders no
1851 /// glyph of its own, so the caret's "end of line" offset lands right
1852 /// before it). Splicing a paragraph/list/quote break at `off` itself would
1853 /// sever the delimiter from its content, stranding it alone on the new
1854 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1855 /// nested marks closing at the same point (`**_x_**`) all clear together.
1856 /// A no-op everywhere else — mid-run, or past real trailing content, no
1857 /// mark's `content_span` ends exactly at `off`.
1858 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1859 let off = off.min(self.source.len());
1860 self.editor
1861 .ancestors_at(off)
1862 .unwrap_or_default()
1863 .into_iter()
1864 .filter(|m| inline_kind(&m.kind).is_some())
1865 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1866 .map(|m| m.span.end)
1867 .max()
1868 .unwrap_or(off)
1869 }
1870
1871 /// The offset a *delete* aimed at the character before `off` should stop at,
1872 /// when `off` is the start of a run's text and the bytes behind it are that
1873 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1874 /// byte behind the caret at the start of a bold word is not a character the
1875 /// writer can see, let alone one they aimed Backspace at: taking it leaves
1876 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1877 /// delete steps over the whole delimiter to the visible character in front of
1878 /// it instead. Walks out to the *outermost* mark opening there, so
1879 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1880 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1881 let off = off.min(self.source.len());
1882 self.editor
1883 .ancestors_at(off)
1884 .unwrap_or_default()
1885 .into_iter()
1886 .filter(|m| inline_kind(&m.kind).is_some())
1887 .filter(|m| {
1888 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
1889 })
1890 .map(|m| m.span.start)
1891 .min()
1892 .unwrap_or(off)
1893 }
1894
1895 /// `off` moved *inside* the run whose closing delimiters end there — the
1896 /// other offset the rich view draws in the same place, since a `**` renders
1897 /// no glyph of its own. `**bold**` has a caret home on each side of its
1898 /// closing delimiter, one column apart on screen and eight bytes and a whole
1899 /// run apart in the file, and a plain ← lands on the outer one whenever a
1900 /// space follows the phrase. The inner one is what the writer is pointing at
1901 /// there: the end of their bold word. Walks in through every mark closing at
1902 /// that point, innermost last, so `***both***` lands inside both. A no-op
1903 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1904 fn step_inside_close_delims(&mut self, off: usize) -> usize {
1905 let mut off = off.min(self.source.len());
1906 loop {
1907 let inner = self
1908 .editor
1909 .ancestors_at(prev_boundary(&self.source, off))
1910 .unwrap_or_default()
1911 .into_iter()
1912 .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1913 .filter_map(|m| m.content_span.clone().map(|c| c.end))
1914 .filter(|&end| end < off)
1915 .max();
1916 match inner {
1917 Some(end) => off = end,
1918 None => return off,
1919 }
1920 }
1921 }
1922
1923 /// The mirror at the opening edge: `off` moved inside the run whose
1924 /// delimiters *start* there, onto the first character of its text. See
1925 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1926 fn step_inside_open_delims(&mut self, off: usize) -> usize {
1927 let mut off = off.min(self.source.len());
1928 loop {
1929 let inner = self
1930 .editor
1931 .ancestors_at(off)
1932 .unwrap_or_default()
1933 .into_iter()
1934 .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
1935 .filter_map(|m| m.content_span.clone().map(|c| c.start))
1936 .filter(|&start| start > off)
1937 .min();
1938 match inner {
1939 Some(start) => off = start,
1940 None => return off,
1941 }
1942 }
1943 }
1944
1945 /// The inline mark kinds whose span covers `off`, each with that span — the
1946 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
1947 /// ids instead. Used to shed a mark by stepping past the end of its run.
1948 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
1949 let off = off.min(self.source.len());
1950 self.editor
1951 .ancestors_at(off)
1952 .unwrap_or_default()
1953 .into_iter()
1954 .filter(|m| off < m.span.end)
1955 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
1956 .collect()
1957 }
1958
1959 /// Insert clipboard `text` at the caret, replacing the selection if there is
1960 /// one — always its own undo step, whatever its length.
1961 ///
1962 /// Provenance is the whole point, and only the caller has it. `insert` reads
1963 /// a lone character as a keystroke and folds it into the run around it,
1964 /// which is right for typing and wrong for a one-character paste: that paste
1965 /// would vanish mid-run on an undo it was never part of, and the characters
1966 /// the user actually typed would go with it. Length can't tell the two
1967 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
1968 /// caller comes through is what says which happened.
1969 pub fn paste(&mut self, text: &str) {
1970 // Pasting against a block picture dissolves it exactly as typing does,
1971 // and for the same reason — see `open_paragraph_at_block_media`.
1972 self.open_paragraph_at_block_media(text);
1973 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1974 self.splice(s, e, text, EditKind::Other);
1975 }
1976
1977 /// Replace `[start, end)` with `text` as one step of an IME composition —
1978 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
1979 /// folds into a single undo.
1980 ///
1981 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
1982 /// dozen calls here, each replacing the last one's provisional bytes, and an
1983 /// undo step per call means undoing a word means pressing ⌘Z until the reading
1984 /// unspools backwards through kana — the intermediate states were never text
1985 /// the user wrote. Only the frontend knows a call is provisional (the bytes
1986 /// look like any other edit), so the door the caller comes through is what
1987 /// says so, exactly as it is for [`paste`](Self::paste) versus
1988 /// [`insert`](Self::insert).
1989 ///
1990 /// Pair with [`end_composition`](Self::end_composition), or the *next*
1991 /// composition folds into this one.
1992 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
1993 self.splice(start, end, text, EditKind::Compose);
1994 }
1995
1996 /// Close the open composition run, so the next one is its own undo step.
1997 /// Call when the IME commits or withdraws a composition.
1998 ///
1999 /// Only clears a *composition* run: a frontend that reports an end it never
2000 /// began (some IMEs unmark unprompted) would otherwise split the run of
2001 /// typing around it into two undo steps for no reason the user can see.
2002 pub fn end_composition(&mut self) {
2003 if self.last_edit_kind == Some(EditKind::Compose) {
2004 self.last_edit_kind = None;
2005 }
2006 }
2007
2008 // ── the clipboard's rich flavor ──────────────────────────────────────────
2009
2010 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2011 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2012 /// nothing is selected, or when the selection doesn't render (the caller
2013 /// still has [`selected_text`](Self::selected_text), which is what to publish
2014 /// as `text/plain` either way).
2015 ///
2016 /// **The fragment is a source substring, and that is the honest limit here.**
2017 /// It's parsed standalone, so a selection whose meaning depends on its
2018 /// surroundings converts as what it literally says rather than what it looks
2019 /// like on screen: half a list item is a paragraph, a row torn out of a table
2020 /// is the text of a row, the `**` of a bold run selected without its closing
2021 /// `**` is two asterisks. Every one of those still *renders* — there's no
2022 /// error to report — it just renders as the fragment and not as the document.
2023 /// Widening the range to whole blocks would publish text the user didn't
2024 /// select, which is a worse lie than a fragment being a fragment; the plain
2025 /// flavor has the same substring, so the two flavors at least agree.
2026 pub fn selection_html(&mut self) -> Option<String> {
2027 let (start, end) = self.selection()?;
2028 let inline = self.selection_is_inline(start, end);
2029 let html = html::render_fragment(&self.source[start..end], self.format)?;
2030 Some(match inline {
2031 true => html::strip_sole_paragraph(html),
2032 false => html,
2033 })
2034 }
2035
2036 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2037 /// format first. Its own undo step, like any [`paste`](Self::paste).
2038 ///
2039 /// Returns whether it landed. `false` means the HTML didn't convert to
2040 /// anything worth pasting — the caller should fall back to the plain flavor
2041 /// rather than treat it as an error. The `html` module has the full list of
2042 /// what that covers: a table twig won't build, markup it doesn't recognise,
2043 /// an empty result.
2044 pub fn paste_html(&mut self, html: &str) -> bool {
2045 match html::parse_fragment(html, self.format) {
2046 Some(source) => {
2047 self.paste(&source);
2048 true
2049 }
2050 None => false,
2051 }
2052 }
2053
2054 /// Does the selection live *inside* a single top-level block?
2055 ///
2056 /// The question [`selection_html`](Self::selection_html) needs and the
2057 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2058 /// whether the user selected one word of a sentence or a whole paragraph, and
2059 /// only the document knows which. Selecting a word and pasting into Docs
2060 /// should extend the line you paste into; selecting the paragraph should make
2061 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2062 /// is an artifact of standalone parsing), and one that covers a whole block —
2063 /// or spans two — keeps its structure.
2064 ///
2065 /// Reads the block from twig rather than guessing from the bytes:
2066 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2067 /// block containing an offset, and two ends inside the same one cannot have
2068 /// crossed a block boundary.
2069 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2070 // The last *character*, not `end - 1`: the selection's end is exclusive
2071 // and may sit mid-codepoint's-worth of bytes past the last char.
2072 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2073 return false;
2074 };
2075 let (Some(head), Some(tail)) =
2076 (self.top_block_span(start), self.top_block_span(start + off))
2077 else {
2078 return false;
2079 };
2080 head == tail && !(start <= head.start && end >= head.end)
2081 }
2082
2083 /// The byte span of the top-level block containing `offset`, or `None` at an
2084 /// offset that belongs to no block (the blank line between two of them).
2085 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2086 self.editor
2087 .ancestors_at(offset)
2088 .ok()?
2089 .get(1)
2090 .map(|m| m.span.clone())
2091 }
2092
2093 // ── indentation ──────────────────────────────────────────────────────────
2094
2095 /// One indent level.
2096 ///
2097 /// Two spaces, not the four both frontends type for Tab today, because in a
2098 /// markdown document four columns isn't a width — it's a *meaning*. Four
2099 /// spaces at the head of a line is markdown's indented-code-block marker, so
2100 /// one Tab on a paragraph would reparse it into code and style it as such;
2101 /// two cannot, and the line stays the prose it was. Two is also exactly
2102 /// where a `- ` bullet's content starts, so an indented line lands under its
2103 /// parent item's text instead of beside it — the column a list-aware indent
2104 /// has to hit anyway, which keeps this width from being relitigated later.
2105 const INDENT: &'static str = " ";
2106
2107 /// Indent the selected lines — or the caret's line, with no selection — by
2108 /// one level (Tab).
2109 pub fn indent(&mut self) {
2110 self.reindent(true);
2111 // Nesting changes an ordered list's numbering (the nested item restarts,
2112 // its old siblings resume) — keep the source markers in step.
2113 self.renumber_here();
2114 // Nesting an empty `-` item under a text line reparses that text as a
2115 // setext heading; swap the dash for a `*` before it can (a no-op unless
2116 // the collapse actually happened).
2117 self.avoid_setext_collapse();
2118 }
2119
2120 /// Take one indent level back off the selected lines, or the caret's line
2121 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2122 ///
2123 /// A line with *less* than a full level gives back what it has rather than
2124 /// refusing: outdent's job is to walk a line left, and real documents — hand
2125 /// written, or reflowed by some other editor — are full of indentation that
2126 /// was never a clean multiple of anything. Refusing there would strand the
2127 /// line at a depth Shift+Tab couldn't undo.
2128 pub fn outdent(&mut self) {
2129 self.reindent(false);
2130 self.renumber_here();
2131 }
2132
2133 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2134 ///
2135 /// One splice across the whole line range, never one per line: a Tab is one
2136 /// thing the user did, so it has to be one undo step and one reparse. Per
2137 /// line, twig would reparse the document once per line and leave a stack of
2138 /// steps that Shift+⌘Z walks back one line at a time.
2139 fn reindent(&mut self, add: bool) {
2140 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2141 let start = source_line_range(&self.source, sel_start).start;
2142 let end = source_line_range(&self.source, sel_end).end;
2143 let region = self.source[start..end].to_string();
2144 let lines: Vec<&str> = region.split('\n').collect();
2145 // A blank line has no text to move, and padding it would leave nothing
2146 // but trailing whitespace — but Tab on a blank line *is* a request for
2147 // indentation to type into, so the skip only applies where the op has
2148 // other lines to do real work on.
2149 let skip_blank = add && lines.len() > 1;
2150
2151 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2152 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2153 let mut line_off = start;
2154 for (i, full) in lines.iter().enumerate() {
2155 if i > 0 {
2156 out.push('\n');
2157 }
2158 // A list item moves by having its whole leading prefix *replaced*,
2159 // never by having spaces pushed in front of the line. twig spells
2160 // both prefixes, so the quote markers, the parent's indent and an
2161 // ordered marker's extra column all come out right without leaf
2162 // measuring any of them — and a line that only looks like an item
2163 // (a Djot continuation) reports no marker and is left to the plain
2164 // path, where a Tab is just a Tab.
2165 let marker = self.list_marker_on_line(line_off);
2166 let own = marker
2167 .as_ref()
2168 .map(|m| m.marker_start - m.line_start)
2169 .unwrap_or(0);
2170 let delta = if add {
2171 if skip_blank && full.trim().is_empty() {
2172 out.push_str(full);
2173 0
2174 } else if marker.is_some() && self.first_item_of_list(line_off) {
2175 // The first item of a list has no preceding sibling to nest
2176 // under, so a Tab here can't spell a sub-list — twig would
2177 // reparse the shoved-over marker as the same list, only
2178 // indented, which Shift+Tab then can't cleanly undo. Leave the
2179 // item where it is, the way every list editor refuses to
2180 // over-indent a list's first line.
2181 out.push_str(full);
2182 0
2183 } else if marker.is_some() {
2184 // Nesting means standing where a *continuation* of this line
2185 // would stand: past the parent's marker, inside its content
2186 // column. That is `continuation_prefix`, less a checkbox.
2187 let new = self.nesting_prefix_at(line_off);
2188 let delta = new.len() as isize - own as isize;
2189 out.push_str(&new);
2190 out.push_str(&full[own..]);
2191 delta
2192 } else {
2193 out.push_str(Self::INDENT);
2194 out.push_str(full);
2195 Self::INDENT.len() as isize
2196 }
2197 } else if marker.is_some() {
2198 // Unnesting is the mirror: stand where the parent item's own
2199 // line starts, which drops exactly the level it contributed.
2200 let new = self.outdent_prefix_at(line_off);
2201 let delta = new.len() as isize - own as isize;
2202 out.push_str(&new);
2203 out.push_str(&full[own..]);
2204 delta
2205 } else {
2206 // A plain line gives back the ordinary step.
2207 let strip = outdent_width(full, Self::INDENT.len());
2208 out.push_str(&full[strip..]);
2209 -(strip as isize)
2210 };
2211 deltas.push(delta);
2212 line_off += full.len() + 1;
2213 }
2214 // Nothing to give back. Returning before the splice keeps an outdent at
2215 // column zero from spending an undo step on a document it never changed.
2216 if deltas.iter().all(|d| *d == 0) {
2217 return;
2218 }
2219
2220 // Every line's text keeps its offset *within the line*, so the caret is
2221 // remapped by its column, not by its byte offset — which the prefixes on
2222 // the lines above it have already invalidated.
2223 let remap = |off: usize| -> usize {
2224 let (mut old_ls, mut new_ls) = (start, start);
2225 for (line, delta) in lines.iter().zip(&deltas) {
2226 let old_le = old_ls + line.len();
2227 let new_len = (line.len() as isize + delta) as usize;
2228 if off <= old_le {
2229 let col = (off - old_ls) as isize;
2230 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2231 }
2232 old_ls = old_le + 1;
2233 new_ls += new_len + 1;
2234 }
2235 start + out.len()
2236 };
2237 let placed = match self.selection() {
2238 // Keep the rewritten region selected, the way a container toggle
2239 // keeps its own: it leaves a second Tab aimed at the same lines
2240 // rather than at whatever the shifted offsets now happen to cover.
2241 Some(_) => (start + out.len(), Some(start)),
2242 None => (remap(self.caret), None),
2243 };
2244
2245 // A rolled-back splice leaves the old source in place, where every offset
2246 // computed above addresses text that was never written.
2247 if !self.splice(start, end, &out, EditKind::Other) {
2248 return;
2249 }
2250 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2251 // rewrite is the last line's end — nowhere the caret was. Place it, then
2252 // re-record the caret so this is the state redo restores, not the one
2253 // `splice` left behind from the `Change`.
2254 self.caret = placed.0.min(self.source.len());
2255 self.anchor = placed.1;
2256 self.clamp_caret();
2257 self.record_caret();
2258 }
2259
2260 /// The Enter key.
2261 ///
2262 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2263 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2264 /// caret is in decides what actually gets written.
2265 ///
2266 /// - paragraph → twig's [`Editor::split_block`], which parts the
2267 /// block at the caret and reopens its container
2268 /// - list item → likewise: the next item, its indent, quote
2269 /// prefix and `[ ]` box all reproduced by twig —
2270 /// except an *empty* item, which exits the list
2271 /// - block quote → likewise: a new paragraph inside the quote
2272 /// - heading → a new *paragraph*, not another heading
2273 /// - code block → a literal newline (stay in the block)
2274 /// - blank line → a literal newline (one Backspace undoes it)
2275 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2276 /// visible line
2277 ///
2278 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2279 /// and it is better at it: it drops the whitespace the caret was sitting in
2280 /// front of instead of stranding it at the head of the second half, and it
2281 /// knows continuations leaf's marker scan never covered — a checklist item
2282 /// continues as an *unchecked* checklist item rather than a plain bullet.
2283 ///
2284 /// The exceptions above are exceptions because `split_block` is either wrong
2285 /// there or refuses: parting a fence yields two fences with the code split
2286 /// between them, parting a heading yields a second heading where every editor
2287 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2288 /// table all report an error rather than a split.
2289 pub fn newline(&mut self) {
2290 if self.view == View::Source {
2291 self.insert_raw("\n");
2292 return;
2293 }
2294 // Enter over a selection replaces it with a paragraph break.
2295 if let Some((s, e)) = self.selection() {
2296 self.splice(s, e, "\n\n", EditKind::Other);
2297 return;
2298 }
2299 // A caret resting exactly between an inline mark's content and its own
2300 // closing delimiter (`**bold**` with nothing after it on the line —
2301 // the WYSIWYG caret's natural end-of-line position) must not splice a
2302 // block break there: every path below eventually does via
2303 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2304 // delimiter would strand it alone on the new line.
2305 self.caret = self.skip_trailing_close_delims(self.caret);
2306 // The block the caret is in. `block_offset_for_caret` nudges off a line
2307 // end (where the caret sits at the doc level); on a bare line (e.g. an
2308 // empty list item) fall back to the caret so the enclosing list/quote is
2309 // still visible in the ancestors.
2310 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2311 let kinds: Vec<Kind> = self
2312 .editor
2313 .ancestors_at(off)
2314 .map(|c| c.into_iter().map(|m| m.kind).collect())
2315 .unwrap_or_default();
2316 let has = |k: Kind| kinds.contains(&k);
2317
2318 if has(Kind::CodeBlock) {
2319 self.insert_raw("\n");
2320 return;
2321 }
2322 // An *empty* list item exits the list — the standard double-Enter — which
2323 // `split_block` reports as an error rather than a split (there is no
2324 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2325 // the AST gate — it answers from the tree, so a `- ` that reads as a
2326 // marker byte-for-byte but opens no item (a setext underline, a Djot
2327 // continuation line) never reaches here.
2328 if let Some(marker) = self.list_marker_on_line(self.caret)
2329 && self.item_is_empty(&marker)
2330 {
2331 self.exit_list(&marker);
2332 return;
2333 }
2334 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2335 // not another full paragraph break — so it moves down one line and one
2336 // Backspace undoes it, not two. (`split_block` errors here too.)
2337 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2338 let line_end = self.source[self.caret..]
2339 .find('\n')
2340 .map_or(self.source.len(), |i| self.caret + i);
2341 if self.source[line_start..line_end].trim().is_empty() {
2342 self.insert_raw("\n");
2343 return;
2344 }
2345 // In `Preserve` flow a soft break is a *visible* line the author means to
2346 // make, so Enter writes a single `\n` and typing continues the same
2347 // paragraph on the next line — the behaviour of an ordinary text editor.
2348 // A second Enter then lands on the blank line above and takes the
2349 // empty-line branch, so double-Enter still promotes to a full paragraph
2350 // break; and Backspace, which deletes a lone `\n` over a soft break,
2351 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2352 // render as an invisible space, so Enter keeps making the paragraph break
2353 // that actually shows.
2354 //
2355 // Only in running prose. A list or a quote has a continuation of its own
2356 // to write, and a `\n` there is not a soft line but a lost container.
2357 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2358 if self.line_flow == LineFlow::Preserve && !in_container {
2359 self.insert_raw("\n");
2360 return;
2361 }
2362 // A heading gets a *paragraph*, never a second heading: Enter at the end
2363 // of a title is how every editor is asked for the body under it, and
2364 // `split_block` would repeat the `#` instead. Whitespace at the split
2365 // point goes with the break rather than opening the new paragraph, which
2366 // is what `split_block` does everywhere else.
2367 if has(Kind::Heading) {
2368 let mut end = self.caret;
2369 while self.source.as_bytes().get(end) == Some(&b' ') {
2370 end += 1;
2371 }
2372 self.splice(self.caret, end, "\n\n", EditKind::Other);
2373 return;
2374 }
2375 self.split_block_here();
2376 }
2377
2378 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2379 /// the caret in the second half.
2380 ///
2381 /// twig reopens whatever the first half was inside of — the bullet with its
2382 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2383 /// reason this replaced the markup leaf used to spell from the line's bytes.
2384 /// It renumbers nothing, though: a new item mid-list is written with its
2385 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2386 /// behind it, folded into the same undo step.
2387 ///
2388 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2389 /// shape still moves the caret down rather than swallowing the keystroke.
2390 fn split_block_here(&mut self) {
2391 match self.editor.split_block(self.caret) {
2392 Ok(change) => {
2393 self.last_edit_kind = None;
2394 self.refresh();
2395 self.anchor = None;
2396 self.caret = change.new.end;
2397 self.dirty = self.source != self.clean_source;
2398 self.status = None;
2399 self.clamp_caret();
2400 self.record_caret();
2401 // Aimed at the new block's *start*: the caret twig leaves is one
2402 // past the marker it wrote, where there is no list in reach.
2403 self.renumber_at(change.new.start);
2404 }
2405 Err(_) => self.insert_raw("\n\n"),
2406 }
2407 }
2408
2409 /// Whether the item on the marker's line carries no content — the shape
2410 /// double-Enter reads as "I'm done with this list."
2411 fn item_is_empty(&self, line: &ListMarker) -> bool {
2412 let content_start = line.content_start().min(self.source.len());
2413 let line_end = self.source[self.caret..]
2414 .find('\n')
2415 .map(|i| self.caret + i)
2416 .unwrap_or(self.source.len());
2417 self.source[content_start..line_end.max(content_start)]
2418 .trim()
2419 .is_empty()
2420 }
2421
2422 /// Leave the list: replace the empty item's marker with a blank line, so the
2423 /// caret lands in a fresh paragraph below it.
2424 ///
2425 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2426 /// quote), and the caret's new line keeps the `> ` it was already behind —
2427 /// leaving the list without also leaving the quote.
2428 fn exit_list(&mut self, line: &ListMarker) {
2429 let prefix = self.quote_prefix_at(line.marker_start);
2430 let blank = prefix.trim_end();
2431 self.splice(
2432 line.line_start,
2433 self.caret,
2434 &format!("{blank}\n{prefix}"),
2435 EditKind::Other,
2436 );
2437 }
2438
2439 /// What a line continuing the containers at `off` has to open with — the
2440 /// quote markers reproduced, each enclosing item's marker as its width in
2441 /// spaces. Also the column a nested item's marker stands in, which is what
2442 /// makes it Tab's answer.
2443 fn continuation_prefix_at(&mut self, off: usize) -> String {
2444 self.editor
2445 .document()
2446 .and_then(|mut d| d.continuation_prefix(off))
2447 .map(|p| p.text)
2448 .unwrap_or_default()
2449 }
2450
2451 /// The column a *nested list* may open at inside the item at `off` — which
2452 /// is not always where the item's own text continues.
2453 ///
2454 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2455 /// it is markup a rich view hides, and the item's own wrapped text does
2456 /// stand past it. But a nested list may only open at the *list* marker's
2457 /// column, and four columns further in is an indented continuation of the
2458 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2459 /// So the box's own width goes back.
2460 ///
2461 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2462 /// reports the list marker's column apart from the box; `checked` is what
2463 /// says a box is there at all, so only its width is being measured here.
2464 fn nesting_prefix_at(&mut self, off: usize) -> String {
2465 let cont = self.continuation_prefix_at(off);
2466 let Some(item) = self.innermost_list_item(off) else {
2467 return cont;
2468 };
2469 if item.checked.is_none() {
2470 return cont;
2471 }
2472 let box_width = item
2473 .marker_span
2474 .and_then(|m| self.source.get(m))
2475 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2476 .unwrap_or(0);
2477 // The trailing columns are the ones the item's own marker contributed,
2478 // so trimming from the end leaves any quote prefix standing.
2479 cont[..cont.len().saturating_sub(box_width)].to_string()
2480 }
2481
2482 /// Where the line of the item *containing* the item at `off` begins — the
2483 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2484 /// parent contributed. The quote prefix alone for a top-level item, which
2485 /// has no level left to give.
2486 fn outdent_prefix_at(&mut self, off: usize) -> String {
2487 let items: Vec<usize> = self
2488 .editor
2489 .document()
2490 .and_then(|mut d| d.ancestors_at_caret(off))
2491 .map(|c| {
2492 c.into_iter()
2493 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2494 .map(|m| m.span.start)
2495 .collect()
2496 })
2497 .unwrap_or_default();
2498 // The second-innermost item is the parent; its own line's indent is the
2499 // target. `list_marker_on_line` gives that line's prefix directly.
2500 let parent = items.len().checked_sub(2).map(|i| items[i]);
2501 match parent.and_then(|p| self.list_marker_on_line(p)) {
2502 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2503 None => self.quote_prefix_at(off),
2504 }
2505 }
2506
2507 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2508 /// inside one, `"> > "` inside two.
2509 ///
2510 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2511 /// the `>` and the space after it are twig's spelling rather than leaf's.
2512 /// The whole line prefix can't answer this: it also carries the indent of
2513 /// whatever the quote holds, which a blank separator line must *not* repeat.
2514 fn quote_prefix_at(&mut self, off: usize) -> String {
2515 let Ok(chain) = self
2516 .editor
2517 .document()
2518 .and_then(|mut d| d.ancestors_at_caret(off))
2519 else {
2520 return String::new();
2521 };
2522 let quotes: Vec<usize> = chain
2523 .iter()
2524 .filter(|m| m.kind == Kind::BlockQuote)
2525 .map(|m| m.node_id as usize)
2526 .collect();
2527 let Ok(nodes) = self.editor.nodes() else {
2528 return String::new();
2529 };
2530 quotes
2531 .iter()
2532 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2533 .filter_map(|s| self.source.get(s))
2534 .collect()
2535 }
2536
2537 /// Whether the item at `off` sits inside another one — the test Backspace
2538 /// uses to choose between outdenting and dropping the marker.
2539 ///
2540 /// Counted from the AST rather than from the line's leading whitespace,
2541 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2542 fn item_is_nested(&mut self, off: usize) -> bool {
2543 self.editor
2544 .document()
2545 .and_then(|mut d| d.ancestors_at_caret(off))
2546 .map(|c| {
2547 c.into_iter()
2548 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2549 .count()
2550 > 1
2551 })
2552 .unwrap_or(false)
2553 }
2554
2555 /// The innermost list item containing `probe`, under twig's **caret**
2556 /// containment rule — a block's end is inside it.
2557 ///
2558 /// Half-open containment can't answer this. An empty item's span is exactly
2559 /// its marker, so the caret sitting after `- ` is one past the end and the
2560 /// item it is plainly in tests as out of reach; that is the shape
2561 /// double-Enter has to recognise to leave the list.
2562 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2563 let chain = self
2564 .editor
2565 .document()
2566 .and_then(|mut d| d.ancestors_at_caret(probe))
2567 .ok()?;
2568 let id = chain
2569 .iter()
2570 .rev()
2571 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2572 .node_id as usize;
2573 self.editor.nodes().ok()?.get(id).cloned()
2574 }
2575
2576 /// The list marker opening `off`'s line, per twig — `None` when that line
2577 /// opens no list item.
2578 ///
2579 /// [`Document::line_prefix`] is the whole hidden run from the line start:
2580 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
2581 /// and it is `None` on a *continuation* line, which opens nothing. That last
2582 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
2583 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2584 /// a paragraph and ` - b` is literal text — identical bytes, and only the
2585 /// parser knows which document it is looking at.
2586 ///
2587 /// The item's own marker is separated out via its
2588 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2589 /// the containers around it contribute and what the item does.
2590 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2591 let off = off.min(self.source.len());
2592 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2593 // The prefix belongs to a list only when an item's marker closes it —
2594 // a heading's `# ` or a bare quote's `> ` is a prefix too.
2595 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2596 let marker = item.marker_span.clone()?;
2597 if marker.end != prefix.end {
2598 return None;
2599 }
2600 Some(ListMarker {
2601 line_start: prefix.start,
2602 marker_start: marker.start,
2603 text: self.source.get(prefix)?.to_string(),
2604 })
2605 }
2606
2607 /// Whether the list item on `line_start`'s line is the **first item** of its
2608 /// list — the one Tab must not nest, because nesting needs a preceding
2609 /// sibling to become the new parent and a first item has none. `false` for a
2610 /// line that isn't a list item, and for an item with a sibling above it (the
2611 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2612 /// the same in a setext underline that opens no list at all.
2613 fn first_item_of_list(&mut self, line_start: usize) -> bool {
2614 let Some(marker) = self.list_marker_on_line(line_start) else {
2615 return false;
2616 };
2617 // Probe just inside the marker, where the item's own node is in reach —
2618 // the marker offset itself can resolve to the enclosing list, not the
2619 // `list_item`, whose span starts at the marker.
2620 let probe = marker.content_start().min(self.source.len());
2621 let Some(item) = self.innermost_list_item(probe) else {
2622 return false;
2623 };
2624 let Ok(nodes) = self.editor.nodes() else {
2625 return false;
2626 };
2627 match item.parent {
2628 // First when the parent list opens with this very item.
2629 Some(pid) => nodes
2630 .get(pid.0 as usize)
2631 .is_some_and(|p| p.first_child == Some(item.id)),
2632 // A parentless item is trivially the first (and only) one.
2633 None => true,
2634 }
2635 }
2636
2637 pub fn backspace(&mut self) {
2638 if let Some((s, e)) = self.selection() {
2639 self.splice(s, e, "", EditKind::Other);
2640 return;
2641 }
2642 // WYSIWYG: Backspace at the very start of a list item's content is a
2643 // structural key, not a character delete — it walks the "un-indent, then
2644 // un-list" ladder every list editor gives that keystroke (outdent a
2645 // nested item, strip a top-level one's marker to a paragraph). In source
2646 // view the `- ` is visible text the user is deleting a byte of, so it
2647 // keeps its literal meaning there, like Enter does.
2648 if self.view != View::Source && self.backspace_list_start() {
2649 return;
2650 }
2651 // WYSIWYG: and the same at the start of a heading's content — the `# `
2652 // there is markup the rich view hides, not text the user typed.
2653 if self.view != View::Source && self.backspace_heading_start() {
2654 return;
2655 }
2656 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2657 // the markup apart under a caret that cannot see it — see
2658 // `delete_around_block_media`.
2659 if self.view != View::Source && self.delete_around_block_media(false) {
2660 return;
2661 }
2662 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2663 // stop, not a single newline. On a line with no text of its own, the byte
2664 // before the caret is a `\n` that spells part of a block boundary — the gap
2665 // between two blocks, drawn but never a caret home. Removing just it strands
2666 // the caret in that gap and leaves an odd blank line the eye reads as one
2667 // separator but the caret can't land on: the "extra newline" left behind
2668 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2669 // Deleting to the previous stop instead collapses the whole break at once,
2670 // landing the caret at the end of the block above. Two blank lines in a row
2671 // are one stop apart, so this still removes exactly one — the lone-Enter /
2672 // lone-Backspace symmetry the empty-line case is built on is untouched.
2673 if self.view != View::Source
2674 && self.caret > self.caret_floor()
2675 && self.caret_on_blank_line()
2676 && let Some(stop) = self.vmap.stop_before(self.caret)
2677 {
2678 let stop = stop.max(self.caret_floor());
2679 if stop < self.caret {
2680 self.splice(stop, self.caret, "", EditKind::Delete);
2681 return;
2682 }
2683 }
2684 if self.caret > self.caret_floor() {
2685 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2686 // takes the whole tag — a single-byte step would leave a broken `<br`
2687 // showing in the cell. Rich view only (source view edits the literal).
2688 if self.view != View::Source
2689 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2690 {
2691 let start = start.max(self.caret_floor());
2692 if start < end {
2693 self.splice(start, end, "", EditKind::Delete);
2694 return;
2695 }
2696 }
2697 // Aim the delete at the character the writer can *see* behind the
2698 // caret, never at a delimiter the rich view drew nothing for. Two
2699 // steps, and either can apply: from the far side of a run's closing
2700 // `**` step back into the run (the caret is drawn at the end of its
2701 // word), and at the start of a run's text step out past its opening
2702 // `**` to the character in front of it, leaving the run standing.
2703 // Without them a plain Backspace unspells the phrase it is editing
2704 // and leaves a literal asterisk on screen.
2705 let end = if self.view == View::Source {
2706 self.caret
2707 } else {
2708 let inside = self.step_inside_close_delims(self.caret);
2709 self.skip_leading_open_delims(inside)
2710 .max(self.caret_floor())
2711 };
2712 // Never delete back across the floor — that would eat hidden
2713 // frontmatter the WYSIWYG caret can't even see.
2714 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2715 // Take a hidden escape backslash with the char it escapes: the rich
2716 // view draws `\*` as a single `*`, so Backspace over it must delete
2717 // both bytes, never strand the `\` as a lone visible backslash (the
2718 // mirror of the Hidden-mode typing that wrote the escape). Source view
2719 // shows the `\`, so there it is an ordinary character.
2720 if self.view != View::Source
2721 && prev > self.caret_floor()
2722 && self.is_hidden_escape(prev - 1)
2723 {
2724 prev -= 1;
2725 }
2726 if prev < end {
2727 self.splice(prev, end, "", EditKind::Delete);
2728 }
2729 }
2730 }
2731
2732 /// Whether the caret's own source line holds nothing but whitespace — an
2733 /// empty paragraph, or the blank line a block boundary is spelled with. The
2734 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2735 /// no text of its own, so the newline before the caret belongs to the gap
2736 /// between blocks rather than to any word the caret is editing.
2737 fn caret_on_blank_line(&self) -> bool {
2738 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2739 let line_end = self.source[self.caret..]
2740 .find('\n')
2741 .map_or(self.source.len(), |i| self.caret + i);
2742 self.source[line_start..line_end].trim().is_empty()
2743 }
2744
2745 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2746 /// `edge` side — the byte range to delete whole. A table row is one source
2747 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2748 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2749 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2750 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2751 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2752 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
2753 /// apart — no ancestor walk needed. Rich view only; source view shows the
2754 /// literal tag and deletes it a byte at a time.
2755 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2756 let caret = self.caret;
2757 let nodes = self.nodes();
2758 let src = self.source.as_bytes();
2759 nodes
2760 .iter()
2761 .find(|n| {
2762 n.kind == Kind::HardBreak
2763 && n.span.start < n.span.end
2764 && src.get(n.span.start) == Some(&b'<')
2765 && match edge {
2766 BreakEdge::Backward => n.span.end == caret,
2767 BreakEdge::Forward => n.span.start == caret,
2768 }
2769 })
2770 .map(|n| (n.span.start, n.span.end))
2771 }
2772
2773 /// Whether the source byte at `off` is a backslash twig consumed as an escape
2774 /// (hidden in the rich view), as against a literal backslash (drawn). A
2775 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2776 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2777 /// round-trip needed.
2778 fn is_hidden_escape(&self, off: usize) -> bool {
2779 let b = self.source.as_bytes();
2780 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2781 }
2782
2783 /// Backspace's list behaviour: when the caret sits exactly at the start of a
2784 /// list item's content (right after its marker), outdent the item if it's
2785 /// nested, else strip the marker so it becomes a paragraph. Returns whether
2786 /// it acted — `false` leaves Backspace its ordinary character delete.
2787 fn backspace_list_start(&mut self) -> bool {
2788 let Some(marker) = self.list_marker_on_line(self.caret) else {
2789 return false;
2790 };
2791 // Only right after the marker. That the line opens a real item is
2792 // already settled: `list_marker_on_line` answers from the tree.
2793 if self.caret != marker.content_start() {
2794 return false;
2795 }
2796 if self.item_is_nested(marker.marker_start) {
2797 // Nested: give back one level, keeping the marker and carrying the
2798 // caret with it.
2799 self.outdent();
2800 } else {
2801 // Top level: drop the marker, leaving a paragraph, then renumber the
2802 // siblings the removed item was counted among. Only the marker goes —
2803 // a quote prefix in front of it still has a quote to hold up.
2804 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2805 self.renumber_here();
2806 }
2807 true
2808 }
2809
2810 /// Backspace's heading behaviour: with the caret exactly at the start of an
2811 /// ATX heading's content — right after the `#` marker the rich view hides —
2812 /// strip the marker so the line becomes a paragraph. The peer of
2813 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2814 /// reasoning: hidden block markup is structure, so the keystroke over it is
2815 /// structural.
2816 ///
2817 /// Without this the ordinary delete takes the space out of `# Title` and
2818 /// leaves `#Title`, which is no longer a heading at all — the hash the view
2819 /// had been hiding surfaces as literal text the user has to delete a second
2820 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2821 /// other end) goes with the marker for the same reason.
2822 ///
2823 /// Returns whether it acted; `false` leaves Backspace its character delete.
2824 fn backspace_heading_start(&mut self) -> bool {
2825 let caret = self.caret;
2826 // The heading whose content opens exactly at the caret. A bare `#` has no
2827 // content span at all — its content starts (and ends) where the line does.
2828 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2829 let (start, end) = match &n.content_span {
2830 Some(c) => (c.start, c.end),
2831 None => (n.span.end, n.span.end),
2832 };
2833 (n.kind == Kind::Heading && start == caret)
2834 .then(|| (n.span.clone(), end, n.marker_span.clone()))
2835 }) else {
2836 return false;
2837 };
2838 // twig reports the marker's own extent, so there is nothing to walk back
2839 // over and no `#` in this file. A setext heading has no marker — its
2840 // content opens the line — so it falls through to the ordinary delete,
2841 // as does anything else sitting at a content start.
2842 // `m.end == caret` is what excludes a setext heading, whose marker is the
2843 // underline *after* the content rather than a prefix before it.
2844 let Some(marker) = marker.filter(|m| m.end == caret) else {
2845 return false;
2846 };
2847 let start = marker.start;
2848 // A closing `#` sequence is hidden too, so it can't be left behind. Only
2849 // when the tail really is one: trailing spaces alone are nothing to strip.
2850 let tail = &self.source[content_end..span.end];
2851 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2852 let kept = self.source[caret..content_end].to_string();
2853 self.splice(start, span.end, &kept, EditKind::Other);
2854 // The splice leaves the caret past the text it re-wrote; the caret
2855 // belongs where the content now starts, which is where it already was.
2856 self.caret = start;
2857 self.record_caret();
2858 } else {
2859 self.splice(start, caret, "", EditKind::Other);
2860 }
2861 true
2862 }
2863
2864 pub fn delete_forward(&mut self) {
2865 if let Some((s, e)) = self.selection() {
2866 self.splice(s, e, "", EditKind::Other);
2867 } else if self.caret < self.source.len() {
2868 // The mirror of Backspace's: forward-delete in front of a picture
2869 // would eat the `!` off its markup and leave a link where a photo was.
2870 if self.view != View::Source && self.delete_around_block_media(true) {
2871 return;
2872 }
2873 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2874 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2875 // strands a broken `<br` in the cell.
2876 if self.view != View::Source
2877 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2878 {
2879 self.splice(start, end, "", EditKind::Delete);
2880 return;
2881 }
2882 // The mirror of Backspace's two steps: from in front of a run's
2883 // opening `**` step into it, onto the first letter of its text, and
2884 // at the end of a run's text step out past its closing `**` to the
2885 // character beyond. Either way Delete takes the character it looks
2886 // like it is pointing at, and never a delimiter drawn as nothing.
2887 // The caret then settles back inside the run it was standing in —
2888 // see `settle_inside_close_delims`.
2889 let from = if self.view == View::Source {
2890 self.caret
2891 } else {
2892 let inside = self.step_inside_open_delims(self.caret);
2893 self.skip_trailing_close_delims(inside)
2894 };
2895 let next = next_boundary(&self.source, from);
2896 if from < next {
2897 self.splice(from, next, "", EditKind::Delete);
2898 }
2899 }
2900 }
2901
2902 /// Delete from the caret back to the start of the previous word (⌥⌫ /
2903 /// Ctrl+⌫). Deletes the selection instead when one is active.
2904 pub fn delete_word_back(&mut self) {
2905 if let Some((s, e)) = self.selection() {
2906 self.splice(s, e, "", EditKind::Other);
2907 } else {
2908 // A word back from just past a picture is a word *of its markup*, and
2909 // a word back from in front of one runs through the paragraph break
2910 // into the prose above — dissolving the picture either way. See
2911 // `delete_around_block_media`.
2912 if self.view != View::Source && self.delete_around_block_media(false) {
2913 return;
2914 }
2915 let start = self.word_left_from(self.caret).max(self.caret_floor());
2916 if start < self.caret {
2917 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2918 self.splice(s, e, "", EditKind::Delete);
2919 }
2920 }
2921 }
2922
2923 /// Delete from the caret forward to the end of the next word (⌥⌦ /
2924 /// Ctrl+Del). Deletes the selection instead when one is active.
2925 pub fn delete_word_forward(&mut self) {
2926 if let Some((s, e)) = self.selection() {
2927 self.splice(s, e, "", EditKind::Other);
2928 } else {
2929 // The mirror: a word forward from in front of a picture is its markup.
2930 if self.view != View::Source && self.delete_around_block_media(true) {
2931 return;
2932 }
2933 let end = self.word_right_from(self.caret);
2934 if end > self.caret {
2935 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2936 self.splice(s, e, "", EditKind::Delete);
2937 }
2938 }
2939 }
2940
2941 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
2942 /// selection instead when one is active, as every other delete here does.
2943 ///
2944 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
2945 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
2946 /// stops at the first character and this takes the indentation with it, the
2947 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
2948 /// leave an indent behind that nothing can then ask to delete, where a caret
2949 /// left at column 0 is one press of Home away from either.
2950 pub fn delete_to_line_start(&mut self) {
2951 if let Some((s, e)) = self.selection() {
2952 self.splice(s, e, "", EditKind::Other);
2953 return;
2954 }
2955 // Never back across the floor: hidden frontmatter isn't on this line, or
2956 // on any line the WYSIWYG caret can see.
2957 let (start, _) = self.line_span();
2958 let start = start.max(self.caret_floor());
2959 if start < self.caret {
2960 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2961 self.splice(s, e, "", EditKind::Delete);
2962 }
2963 }
2964
2965 /// Kill from the caret to the end of its line (^K). Deletes the selection
2966 /// instead when one is active.
2967 ///
2968 /// At the end of the line it does nothing, rather than pulling the line
2969 /// below up into this one. Joining has no meaning to give it in both views
2970 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
2971 /// there is nothing there to delete, while the newline a *source* line ends
2972 /// with is only half of the blank line that separates two paragraphs —
2973 /// deleting one leaves a soft break, which is not the join it looks like.
2974 /// The views agreeing is worth more than emacs' second press, and Delete is
2975 /// already the key that joins.
2976 pub fn delete_to_line_end(&mut self) {
2977 if let Some((s, e)) = self.selection() {
2978 self.splice(s, e, "", EditKind::Other);
2979 return;
2980 }
2981 let (_, end) = self.line_span();
2982 if end > self.caret {
2983 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2984 self.splice(s, e, "", EditKind::Delete);
2985 }
2986 }
2987
2988 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
2989 ///
2990 /// A glyph-space range covers what the user can see, which for `**bold**` is
2991 /// the word and never the delimiters around it — so deleting the word on its
2992 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
2993 /// word, and the styling was the word's; the two go together. Only the
2994 /// node's delimiters are taken, and those are hidden here anyway, so nothing
2995 /// visible outside the range is lost.
2996 ///
2997 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
2998 /// the strong, and only then is the strong empty too.
2999 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3000 if self.view == View::Source {
3001 return (start, end);
3002 }
3003 let nodes = self.nodes();
3004 let (mut s, mut e) = (start, end);
3005 loop {
3006 let mut grew = false;
3007 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3008 let Some(text) = inline_content_span(n, &self.source) else {
3009 continue;
3010 };
3011 // Some of its text survives, so the node still has a job.
3012 if text.start < s || text.end > e {
3013 continue;
3014 }
3015 if n.span.start < s || n.span.end > e {
3016 s = s.min(n.span.start);
3017 e = e.max(n.span.end);
3018 grew = true;
3019 }
3020 }
3021 if !grew {
3022 return (s, e);
3023 }
3024 }
3025 }
3026
3027 /// One splice of document text, keeping the **mark-edge rule**: an inline
3028 /// mark's content never begins or ends with whitespace. In Markdown and Djot
3029 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3030 /// is four literal asterisks around a word, and a rich view drawing the
3031 /// document faithfully has no choice but to show them. That is correct
3032 /// rendering of what the file says, and nobody typing a space after a bold
3033 /// word meant to say it.
3034 ///
3035 /// So the space goes *outside* the run instead — `**bold** ` — which is the
3036 /// same document to a reader and a live one to a parser. The caret follows it
3037 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3038 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3039 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3040 ///
3041 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3042 /// through here, so the rule holds however the whitespace arrives at the
3043 /// edge. The repair is decided *after* the plain edit, by asking whether the
3044 /// mark actually died: a code span's backticks aren't whitespace-sensitive
3045 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3046 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3047 let fix = self.mark_edge_fix(start, end, text);
3048 if !self.splice_exact(start, end, text, kind) {
3049 return false;
3050 }
3051 if let Some(fix) = fix {
3052 self.repair_mark_edges(fix);
3053 }
3054 if text.is_empty() && end > start {
3055 self.settle_inside_close_delims();
3056 }
3057 true
3058 }
3059
3060 /// After a delete, take a caret left standing past a run's closing delimiters
3061 /// back inside the run.
3062 ///
3063 /// A delete leaves the caret where the deleted bytes began, and when those
3064 /// bytes were the last thing after a marked phrase — the space the mark-edge
3065 /// rule pushed out of `**bold** `, say — that spot is the far side of the
3066 /// closing `**`. The rich view has nothing to draw there: the delimiters are
3067 /// hidden, so the caret shows at the end of the word either way, and the two
3068 /// offsets are one place on screen with two different meanings. Typing at the
3069 /// outer one lands past the run, so the writer who backspaced a space out of
3070 /// their bold phrase watches the next character come out plain, and the
3071 /// toolbar button go dark, with the caret never appearing to move.
3072 ///
3073 /// The end of the run's text is the caret's home there — a delete that took
3074 /// away everything after a phrase leaves the caret at the end of that phrase,
3075 /// which is inside it — so it settles onto that
3076 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3077 /// walk, through every mark closing at the point): the word stays bold, the
3078 /// button stays lit, and the next character carries on the phrase.
3079 ///
3080 /// Rich view only, and only where a mark really closes at the caret — mid-run
3081 /// or in plain prose no span ends there and the caret stays put. The opening
3082 /// edge is left alone on purpose: a caret in front of a run inherits from the
3083 /// text on its left, which is the plain text outside.
3084 fn settle_inside_close_delims(&mut self) {
3085 if self.view != View::Wysiwyg {
3086 return;
3087 }
3088 let at = self.step_inside_close_delims(self.caret);
3089 if at != self.caret {
3090 self.caret = at;
3091 self.clear_pending();
3092 self.record_caret();
3093 }
3094 }
3095
3096 /// The splice exactly as asked, with no mark-edge repair — for the callers
3097 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3098 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3099 /// the bytes they inserted.
3100 ///
3101 /// One `edit_range` through twig, then re-anchor the caret from the returned
3102 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3103 /// Markdown/Djot) leaves the document untouched and reports.
3104 ///
3105 /// Returns whether the edit landed — for a caller that has offsets of its
3106 /// own to place afterwards, which a rolled-back splice would leave pointing
3107 /// into text that never came to exist.
3108 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3109 // The read-only gate, for every edit at once — see the field.
3110 if self.read_only {
3111 return false;
3112 }
3113 // twig records an undo step for every edit; when this one continues a
3114 // run of the same kind (typing, deleting), tell twig to fold it into the
3115 // step before it so the whole run undoes at once.
3116 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3117 // Hand twig the pre-edit caret before the splice, so the undo step it
3118 // retires carries where the caret was standing.
3119 self.record_caret();
3120 match self.editor.edit_range(start, end, text) {
3121 Ok(change) => {
3122 if coalesce {
3123 let _ = self.editor.coalesce_last_undo();
3124 }
3125 self.last_edit_kind = Some(kind);
3126 self.refresh();
3127 self.caret = change.new.end;
3128 self.anchor = None;
3129 self.goal_col = None;
3130 self.clear_pending();
3131 self.dirty = self.source != self.clean_source;
3132 self.status = None;
3133 // And the post-edit caret, so a later redo restores it.
3134 self.record_caret();
3135 true
3136 }
3137 // The edit was rolled back, so twig's history did not move and
3138 // neither may ours: pushing here would leave a step with no edit
3139 // under it and shift every later undo onto the wrong caret.
3140 Err(e) => {
3141 self.status = Some(format!("edit: {e}"));
3142 false
3143 }
3144 }
3145 }
3146
3147 /// The re-spelling that would keep the mark-edge rule for the edit
3148 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3149 /// against a delimiter and the plain splice is already right. Computed
3150 /// *before* the edit, while the run's spans and delimiters can still be read
3151 /// off the document; applied afterwards, and only if the mark really died —
3152 /// see [`repair_mark_edges`](Self::repair_mark_edges).
3153 ///
3154 /// Rich view only. Source view is for typing raw markup, where a space put
3155 /// against a `**` is exactly the character it looks like.
3156 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3157 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3158 return None;
3159 }
3160 // Every inline mark standing over the edit, outermost first, with the
3161 // content span that says where its delimiters are.
3162 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3163 .editor
3164 .ancestors_at(start)
3165 .unwrap_or_default()
3166 .into_iter()
3167 .filter_map(|m| {
3168 let kind = inline_kind(&m.kind)?;
3169 let content = m.content_span.clone()?;
3170 Some((kind, m.span.clone(), content))
3171 })
3172 .collect();
3173 // The innermost run whose *content* holds the whole edit: the one whose
3174 // text is being changed, rather than one the edit merely sits under.
3175 let (kind, span, content) = chain
3176 .iter()
3177 .rev()
3178 .find(|(_, _, c)| c.start <= start && end <= c.end)?
3179 .clone();
3180 // What that content becomes. Whitespace at either end of it is what
3181 // would put out the mark.
3182 let body = format!(
3183 "{}{text}{}",
3184 &self.source[content.start..start],
3185 &self.source[end..content.end]
3186 );
3187 let (lead, trail) = if body.trim().is_empty() {
3188 // Nothing but whitespace left: there is no content to mark at all,
3189 // and the delimiters go with it rather than closing on a space.
3190 (body.len(), 0)
3191 } else {
3192 (
3193 body.len() - body.trim_start().len(),
3194 body.len() - body.trim_end().len(),
3195 )
3196 };
3197 // Nothing against a delimiter, and something still between them: the
3198 // plain edit stands. An emptied run is broken just as surely (`**b**`
3199 // with the `b` deleted is the literal `****`) and is re-spelt as the
3200 // nothing it now says.
3201 if lead == 0 && trail == 0 && !body.is_empty() {
3202 return None;
3203 }
3204 // Marks that open or close exactly where this one does — `***both***` is
3205 // two runs sharing an edge — spell their delimiters as one run of bytes,
3206 // so the whitespace has to clear all of them together.
3207 let (mut open_at, mut close_at) = (span.start, span.end);
3208 for _ in 0..chain.len() {
3209 match chain.iter().find(|(_, _, c)| c.start == open_at) {
3210 Some((_, s, _)) => open_at = s.start,
3211 None => break,
3212 }
3213 }
3214 for _ in 0..chain.len() {
3215 match chain.iter().find(|(_, _, c)| c.end == close_at) {
3216 Some((_, s, _)) => close_at = s.end,
3217 None => break,
3218 }
3219 }
3220 let open = &self.source[open_at..content.start];
3221 let close = &self.source[content.end..close_at];
3222 let core = &body[lead..body.len() - trail];
3223 let respelt = if core.is_empty() {
3224 body.clone()
3225 } else {
3226 format!(
3227 "{}{open}{core}{close}{}",
3228 &body[..lead],
3229 &body[body.len() - trail..]
3230 )
3231 };
3232 // The caret sits just past the inserted text within the new content —
3233 // which, when that lands in the whitespace, is now outside the delimiters.
3234 let pos = (start - content.start) + text.len();
3235 let caret = if core.is_empty() || pos <= lead {
3236 open_at + pos
3237 } else if pos >= lead + core.len() {
3238 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3239 } else {
3240 open_at + lead + open.len() + (pos - lead)
3241 };
3242 Some(MarkEdgeFix {
3243 kind,
3244 probe: content.start,
3245 start: open_at,
3246 end: close_at + text.len() - (end - start),
3247 text: respelt,
3248 caret,
3249 // The marks in force here, resolved against any armed sticky delta —
3250 // what the writer is typing in, and so what has to still be true on
3251 // the far side of the delimiter the caret just stepped over.
3252 want: chain
3253 .iter()
3254 .filter(|(_, s, _)| start < s.end)
3255 .map(|(k, _, _)| *k)
3256 .collect::<InlineMarks>()
3257 .xor(self.pending_here()),
3258 })
3259 }
3260
3261 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3262 /// did break the mark. Whether whitespace at a delimiter is fatal is the
3263 /// format's business, not leaf's: `**bold **` is no longer strong, while
3264 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3265 /// don't care either. Asking the parser afterwards settles it for every kind
3266 /// and format at once, and costs a re-spelling only where one is due.
3267 ///
3268 /// The repair rides along with the edit that caused it — one undo step puts
3269 /// back what the writer typed, not a delimiter shuffle they never saw.
3270 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3271 if fix.end > self.source.len() {
3272 return;
3273 }
3274 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3275 return; // still a mark: these delimiters don't mind the whitespace
3276 }
3277 let resumed = self.last_edit_kind;
3278 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3279 return;
3280 }
3281 let _ = self.editor.coalesce_last_undo();
3282 // The keystroke owns the undo step, so the run of typing it belongs to
3283 // keeps coalescing over the repair rather than breaking in two here.
3284 self.last_edit_kind = resumed;
3285 self.caret = fix.caret.min(self.source.len());
3286 self.anchor = None;
3287 self.goal_col = None;
3288 self.rearm(fix.want);
3289 self.clamp_caret();
3290 self.record_caret();
3291 }
3292
3293 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3294 /// writer is typing in, carried across an edit that moved the caret out of
3295 /// the run holding them. Arms nothing when the caret already stands in
3296 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3297 /// a clean delta here (see [`toggle`](Self::toggle)).
3298 fn rearm(&mut self, want: InlineMarks) {
3299 let here: InlineMarks = self
3300 .marks_at(self.caret)
3301 .into_iter()
3302 .map(|(k, _)| k)
3303 .collect();
3304 self.pending_marks = want.xor(here);
3305 self.pending_at = Some(self.caret);
3306 }
3307
3308 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3309 /// which backslash-escapes any character that would otherwise open markup in
3310 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3311 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3312 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3313 /// a collapsed point — a selection is deleted by the caller first, since
3314 /// `insert_literal` inserts rather than replaces.
3315 fn insert_literal_at(
3316 &mut self,
3317 at: usize,
3318 text: &str,
3319 kind: EditKind,
3320 force_coalesce: bool,
3321 ) -> bool {
3322 // `force_coalesce` folds this into the immediately preceding edit (the
3323 // selection-delete of an overwrite) so the pair is one undo step; else it
3324 // coalesces only when it continues a run of the same-kind typing.
3325 let coalesce =
3326 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3327 // The mark-edge rule holds for typed text however it is spelled — see
3328 // `splice`. Only an insert twig passed through unchanged can use it,
3329 // since a fix is measured in the bytes that actually land, and an escape
3330 // adds bytes this couldn't have counted.
3331 let fix = self.mark_edge_fix(at, at, text);
3332 self.record_caret();
3333 match self.editor.insert_literal(at, text) {
3334 Ok(change) => {
3335 if coalesce {
3336 let _ = self.editor.coalesce_last_undo();
3337 }
3338 self.last_edit_kind = Some(kind);
3339 self.refresh();
3340 self.caret = change.new.end;
3341 self.anchor = None;
3342 self.goal_col = None;
3343 self.clear_pending();
3344 self.dirty = self.source != self.clean_source;
3345 self.status = None;
3346 self.record_caret();
3347 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3348 self.repair_mark_edges(fix);
3349 }
3350 true
3351 }
3352 Err(e) => {
3353 self.status = Some(format!("edit: {e}"));
3354 false
3355 }
3356 }
3357 }
3358
3359 /// After a structural list edit (a new item, a nest/unnest), renumber the
3360 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3361 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3362 /// renumber as its own edit; fold it into the edit that triggered it so the
3363 /// two undo as one, and only when it actually changed the source (a no-op or
3364 /// a caret outside any ordered list must not coalesce the real edit into the
3365 /// step before it).
3366 fn renumber_here(&mut self) {
3367 self.renumber_at(self.caret);
3368 }
3369
3370 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3371 /// caret — for an edit that leaves the caret one past the item it just wrote,
3372 /// where twig resolves no list to renumber.
3373 fn renumber_at(&mut self, off: usize) {
3374 let before = self.source.clone();
3375 if self.editor.renumber_ordered_lists(off).is_err() {
3376 return; // not inside an ordered list — nothing to renumber
3377 }
3378 self.refresh();
3379 if self.source != before {
3380 let _ = self.editor.coalesce_last_undo();
3381 self.dirty = self.source != self.clean_source;
3382 self.clamp_caret();
3383 self.record_caret();
3384 }
3385 }
3386
3387 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3388 /// sub-item written directly beneath a text line reparses that text as a
3389 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
3390 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3391 /// bullets can't underline anything, so swap the dash for a `*`: the item
3392 /// stays an empty nested bullet, the parent stays prose, and the source
3393 /// round-trips instead of hiding a heading the user never asked for. Folded
3394 /// into the triggering edit's undo step, the way renumbering is.
3395 ///
3396 /// Gated on the collapse having actually happened (the swapped dash was
3397 /// swallowed into a `heading`), so a real setext heading the author wrote —
3398 /// or a `- x` with content, which can't underline anything — is never
3399 /// touched. This has to live in the *edit*, not the renderer: leaving the
3400 /// hazardous bytes on disk and only painting over them would ship a file
3401 /// every other CommonMark tool reads as a heading.
3402 ///
3403 /// This one keeps its own byte scan, and has to: the hazard is precisely
3404 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3405 /// which asks twig which lines open an item — reports nothing here. There is
3406 /// no node to ask about. It is also the last Markdown spelling leaf writes on
3407 /// purpose rather than for want of an answer; once twig spells continuations
3408 /// itself, avoiding the trap becomes twig's, and this goes.
3409 ///
3410 /// [`list_marker_on_line`]: Self::list_marker_on_line
3411 fn avoid_setext_collapse(&mut self) {
3412 let caret = self.caret.min(self.source.len());
3413 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3414 let bytes = self.source.as_bytes();
3415 let mut dash = line_start;
3416 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3417 dash += 1;
3418 }
3419 // A dash bullet is the only marker that doubles as a setext underline.
3420 if bytes.get(dash) != Some(&b'-') {
3421 return;
3422 }
3423 // Only an *empty* item is a bare underline; `- x` carries content and
3424 // can't fold the line above into a heading.
3425 let line_end = self.source[dash..]
3426 .find('\n')
3427 .map_or(self.source.len(), |i| dash + i);
3428 if !self.source[dash + 1..line_end].trim().is_empty() {
3429 return;
3430 }
3431 // The tell: that dash was swallowed into a `heading`. A properly nested
3432 // empty item sits under a `list_item`, with no heading in reach. Probe
3433 // the dash byte itself (well inside the heading), not the caret, whose
3434 // end-of-line offset can fall on the half-open span boundary.
3435 let collapsed = self
3436 .editor
3437 .ancestors_at(dash)
3438 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3439 .unwrap_or(false);
3440 if !collapsed {
3441 return;
3442 }
3443 let caret = self.caret;
3444 if self.splice(dash, dash + 1, "*", EditKind::Other) {
3445 // Same width, so the caret keeps its column; fold into the edit that
3446 // triggered this so Tab stays one undo step.
3447 let _ = self.editor.coalesce_last_undo();
3448 self.caret = caret.min(self.source.len());
3449 self.clamp_caret();
3450 self.record_caret();
3451 }
3452 }
3453
3454 fn snapshot(&self) -> CaretState {
3455 CaretState {
3456 caret: self.caret,
3457 anchor: self.anchor,
3458 }
3459 }
3460
3461 /// Hand twig the current caret and selection as the blob for the live
3462 /// document state. Called before an edit — so the step twig retires records
3463 /// where the caret was, and undo can restore it — and again once the op has
3464 /// placed the caret, so redo restores where the edit left it.
3465 ///
3466 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3467 /// caret through its own history, so coalescing falls out for free (folding
3468 /// two twig steps into one drops the intermediate blob, keeping the run's
3469 /// first) and the parallel stacks that had to march in lockstep — and could
3470 /// silently drift out of it — are gone.
3471 fn record_caret(&mut self) {
3472 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3473 }
3474
3475 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3476 /// the toggled region selected so a second press cleanly reverses it.
3477 pub fn toggle(&mut self, kind: InlineKind) {
3478 // Ahead of the no-selection branch below: arming a mark for text not yet
3479 // typed is a promise `insert` cannot keep in a format with no delimiters
3480 // to spell it with. Per *kind*, not per format — Markdown spells three
3481 // of the eight marks, djot all eight, HTML seven.
3482 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3483 return;
3484 }
3485 let Some((s, e)) = self.selection() else {
3486 // No selection: arm the mark for the next text typed here, the way a
3487 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3488 // in the flow of typing without ever selecting anything — the delta
3489 // is realised onto the freshly typed text by `insert`. A fresh caret
3490 // position starts the delta over from the marks actually in force.
3491 if self.pending_at != Some(self.caret) {
3492 self.pending_marks = InlineMarks::empty();
3493 self.pending_at = Some(self.caret);
3494 }
3495 self.pending_marks.flip(kind);
3496 self.status = None;
3497 return;
3498 };
3499 // Whitespace at the edge of a selection is not part of what was chosen —
3500 // a double-click takes the space after the word with it — and a mark
3501 // cannot close against one anyway: `**word **` is four literal asterisks
3502 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3503 let picked = &self.source[s..e];
3504 let (s, e) = (
3505 s + (picked.len() - picked.trim_start().len()),
3506 e - (picked.len() - picked.trim_end().len()),
3507 );
3508 if s >= e {
3509 self.status = Some(format!("{kind:?}: nothing selected to mark"));
3510 return;
3511 }
3512 // Styling a selection is a one-shot act, not a sticky mode.
3513 self.clear_pending();
3514 self.record_caret();
3515 match self.editor.toggle_inline(s, e, kind) {
3516 Ok(change) => {
3517 self.last_edit_kind = None; // structural edit is its own undo step
3518 self.refresh();
3519 self.anchor = Some(change.new.start);
3520 self.caret = change.new.end;
3521 self.dirty = self.source != self.clean_source;
3522 self.status = None;
3523 self.record_caret();
3524 }
3525 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3526 }
3527 }
3528
3529 /// Convert the block at the caret to a heading level or paragraph.
3530 pub fn set_block(&mut self, kind: BlockKind) {
3531 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3532 return;
3533 }
3534 self.record_caret();
3535 // A blank line has no node to convert, and twig opens a block there
3536 // rather than declining — so the caret's own offset is the right thing
3537 // to hand it when `block_offset_for_caret` finds nothing.
3538 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3539 match self.editor.set_block(offset, kind) {
3540 Ok(change) => {
3541 self.last_edit_kind = None;
3542 self.refresh();
3543 // Opening a block on a blank line writes a marker the caret
3544 // belongs *after*; converting an existing one moves nothing.
3545 self.caret = self.caret.max(change.new.end);
3546 self.clamp_caret();
3547 self.anchor = None;
3548 self.dirty = self.source != self.clean_source;
3549 self.status = None;
3550 self.record_caret();
3551 }
3552 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3553 }
3554 }
3555
3556 /// Whether `off` is inside a text block (paragraph, heading, code block…).
3557 fn has_block_at(&mut self, off: usize) -> bool {
3558 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3559 chain
3560 .iter()
3561 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3562 })
3563 }
3564
3565 /// The offset to hand twig's `set_block`: the caret when it is already inside
3566 /// a block, otherwise nudged onto the previous character (a caret at a line
3567 /// end sits at the doc level, outside the block). `None` when the caret is on
3568 /// a blank line — a new paragraph with no block node to convert.
3569 fn block_offset_for_caret(&mut self) -> Option<usize> {
3570 let caret = self.caret.min(self.source.len());
3571 if self.has_block_at(caret) {
3572 return Some(caret);
3573 }
3574 // Nudge to the previous character — but never across a newline: that would
3575 // target the previous block, and a blank line genuinely has no block.
3576 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3577 && ch != '\n'
3578 && self.has_block_at(i)
3579 {
3580 return Some(i);
3581 }
3582 None
3583 }
3584
3585 /// The heading level of the text block at the caret, or `None` when that
3586 /// block is not a heading.
3587 pub fn current_heading_level(&mut self) -> Option<u32> {
3588 let caret = self.caret;
3589 self.nodes()
3590 .into_iter()
3591 .filter(|n| n.kind == Kind::Heading)
3592 .find(|n| n.span.start <= caret && caret <= n.span.end)
3593 .and_then(|n| n.level)
3594 }
3595
3596 /// The inline marks in force at the caret (or over the selection) — what a
3597 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3598 /// inline counterpart. Cheap enough to call every frame: one twig
3599 /// `ancestors_at` query per caret (two with a selection), each walking root
3600 /// → deepest node at one offset. It never snapshots the tree the way
3601 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3602 /// the only allocation is twig's own small ancestor `Vec`.
3603 ///
3604 /// **A selection reports a mark only when the mark covers *all* of it.**
3605 /// That's what every real toolbar means by an active button — Bold lit over
3606 /// a half-bold selection would claim a press turns bold *off*, when
3607 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3608 /// Whole-coverage is asked as "is the same mark node standing over both the
3609 /// first and the last character?": inline nodes are contiguous, so one node
3610 /// covering both ends covers every byte between them. Two touching runs
3611 /// (`**a****b**`) are two nodes, and correctly light nothing.
3612 ///
3613 /// At a bare caret a mark is active when the caret stands inside the mark's
3614 /// span — `span.start <= caret < span.end`, delimiters included, which is
3615 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3616 /// opening `*` (2) through the last byte of the closing `**` (9) are all
3617 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3618 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3619 /// typing would actually land inside the marked run. The offset one past the
3620 /// mark (10) is the text after it and reports nothing, at the end of the
3621 /// buffer exactly as in the middle.
3622 pub fn active_inline_marks(&mut self) -> InlineMarks {
3623 let Some((start, end)) = self.selection() else {
3624 // The marks actually in force at the caret, flipped by any armed
3625 // sticky delta — so `⌘b` at a bare caret lights the Bold button
3626 // immediately, before a single character is typed.
3627 let base: InlineMarks = self
3628 .marks_at(self.caret)
3629 .into_iter()
3630 .map(|(k, _)| k)
3631 .collect();
3632 return base.xor(self.pending_here());
3633 };
3634 // The selection's *last character*, not its exclusive end: `end` is the
3635 // offset one past the selection, which for a selection ending exactly at
3636 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3637 // entirely bold, but offset 10 is the space after).
3638 let last = prev_boundary(&self.source, end);
3639 let head = self.marks_at(start);
3640 let tail = self.marks_at(last);
3641 head.into_iter()
3642 .filter(|m| tail.contains(m))
3643 .map(|(k, _)| k)
3644 .collect()
3645 }
3646
3647 /// The inline marks whose span covers `off`, each with the id of the node
3648 /// carrying it — the id is what lets a selection tell one mark node from
3649 /// another of the same kind.
3650 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3651 let off = off.min(self.source.len());
3652 self.editor
3653 .ancestors_at(off)
3654 .unwrap_or_default()
3655 .into_iter()
3656 // `span.end` is the offset one *past* the mark, so it isn't in it.
3657 // twig already resolves a boundary to whatever starts there — in
3658 // `**bold** x` offset 8 is the following text, not the strong — but
3659 // when nothing follows, the tie has nobody to break for and the
3660 // chain still ends at the mark. That would make the answer at the
3661 // last offset of the document depend on whether the file happens to
3662 // end in a newline; the rule is `span.start <= off < span.end`, and
3663 // it's the same rule at the end of a buffer as in the middle.
3664 .filter(|m| off < m.span.end)
3665 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3666 .collect()
3667 }
3668
3669 /// Toggle a heading at the caret: if the block is already this heading level,
3670 /// revert it to a paragraph; otherwise convert it to this heading level.
3671 /// This gives the heading commands the same toggle feel as bold/italic/code —
3672 /// re-applying a heading a line already has turns it back into body text.
3673 pub fn toggle_heading(&mut self, level: u32) {
3674 if self.current_heading_level() == Some(level) {
3675 self.set_block(BlockKind::Paragraph);
3676 } else {
3677 self.set_block(BlockKind::Heading(level));
3678 }
3679 }
3680
3681 /// Toggle a block quote around the selection, or around the block at the
3682 /// caret — the toolbar's Quote button.
3683 pub fn toggle_blockquote(&mut self) {
3684 self.toggle_container(BlockContainerKind::BlockQuote);
3685 }
3686
3687 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3688 /// over the block at the caret — one op with the kind as a flag, the way
3689 /// `toggle_heading` takes its level, so a frontend needs no twig type to
3690 /// name the two buttons.
3691 ///
3692 /// Pressing the *other* list's button while in a list converts in place
3693 /// rather than nesting, so the pair reads as one three-state control
3694 /// (bulleted / numbered / neither) rather than two independent wrappers.
3695 pub fn toggle_list(&mut self, ordered: bool) {
3696 self.toggle_container(if ordered {
3697 BlockContainerKind::OrderedList
3698 } else {
3699 BlockContainerKind::BulletList
3700 });
3701 }
3702
3703 // ── Task list items ──────────────────────────────────────────────────────
3704 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3705 // inline content of the item's first paragraph rather than part of its
3706 // marker, so adding or removing one must leave the item's continuation
3707 // indentation alone, and an item inside a quote is found past the quote
3708 // markers. leaf names the gesture and the offset; the spelling is twig's.
3709
3710 /// Whether the list item at the caret carries a checkbox, and which way it
3711 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3712 /// item or no item at all. What a toolbar reads to light its checkbox button.
3713 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3714 self.task_checked_at(self.caret)
3715 }
3716
3717 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3718 /// offset — what a frontend asks before deciding a click landed on a box.
3719 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3720 self.innermost_list_item(offset.min(self.source.len()))?
3721 .checked
3722 }
3723
3724 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3725 /// A no-op with a reported reason when the caret is in no task item — minting
3726 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3727 pub fn toggle_task_checked(&mut self) {
3728 self.toggle_task_at(self.caret);
3729 }
3730
3731 /// Tick or untick the task item covering `offset` — what a *click* on a
3732 /// rendered checkbox is. Separate from the caret form because a click carries
3733 /// its own offset and must not first move the caret there: ticking a box
3734 /// three paragraphs away should not take the cursor with it.
3735 pub fn toggle_task_at(&mut self, offset: usize) {
3736 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3737 return;
3738 }
3739 let offset = offset.min(self.source.len());
3740 self.record_caret();
3741 match self.editor.toggle_task_checked(offset) {
3742 Ok(_) => self.after_task_edit(),
3743 Err(e) => self.status = Some(format!("task: {e}")),
3744 }
3745 }
3746
3747 /// Give the list item at the caret a checkbox, or take its checkbox away —
3748 /// the gesture that converts between a plain bullet and a task. A new box
3749 /// arrives unticked.
3750 pub fn toggle_task_item(&mut self) {
3751 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3752 return;
3753 }
3754 let caret = self.caret.min(self.source.len());
3755 self.record_caret();
3756 match self.editor.toggle_task_item(caret) {
3757 Ok(_) => self.after_task_edit(),
3758 Err(e) => self.status = Some(format!("task: {e}")),
3759 }
3760 }
3761
3762 /// Settle after a task gesture. The caret rides its old byte offset and is
3763 /// clamped back in: a box is three or four bytes on the item's first line, so
3764 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3765 /// real stop either way.
3766 fn after_task_edit(&mut self) {
3767 self.last_edit_kind = None;
3768 self.refresh();
3769 self.anchor = None;
3770 self.dirty = self.source != self.clean_source;
3771 self.status = None;
3772 self.clamp_caret();
3773 self.record_caret();
3774 }
3775
3776 // ── Tables ───────────────────────────────────────────────────────────────
3777 // A table is a grid, and twig edits it as one — add/remove/move a row or
3778 // column, set a column's alignment — re-spelling the whole table in a single
3779 // splice. Every gesture is anchored at the caret's cell. leaf just names the
3780 // gesture and re-reads the result; the whole table's numbering, borders, and
3781 // delimiter are twig's to keep straight.
3782
3783 /// Whether the caret is inside a table — what a frontend asks to enable or
3784 /// disable its table controls.
3785 ///
3786 /// An HTML `<table>` still answers `true`: the caret really is in a table,
3787 /// and the reason the grid controls stay dark there is
3788 /// [`Capabilities::table`], which is a fact about the document's format
3789 /// rather than about the caret. A frontend needs both.
3790 pub fn caret_in_table(&mut self) -> bool {
3791 let caret = self.caret.min(self.source.len());
3792 self.editor
3793 .ancestors_at(caret)
3794 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3795 .unwrap_or(false)
3796 }
3797
3798 /// One grid op, guarded and settled — the shared body of the seven below.
3799 ///
3800 /// The guard is why this exists rather than seven copies of the same three
3801 /// lines, and it is the one guard leaf cannot delegate to twig. The table
3802 /// editor is the gesture family that consults no `Syntax` table (it spells a
3803 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3804 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3805 /// grid as a *pipe table* and reports success, swapping the element out for
3806 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3807 /// downstream could tell that from a successful edit — the splice is real,
3808 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3809 /// stopping at the door rather than detecting after the fact. See
3810 /// [`spells_pipe_tables`].
3811 fn table_op(
3812 &mut self,
3813 what: &str,
3814 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3815 ) {
3816 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3817 return;
3818 }
3819 self.record_caret();
3820 let at = self.caret;
3821 let r = op(&mut self.editor, at);
3822 self.apply_table(r, what);
3823 }
3824
3825 /// Insert an empty row below (`below`) or above the caret's row.
3826 pub fn table_insert_row(&mut self, below: bool) {
3827 self.table_op("table row", |e, at| e.table_insert_row(at, below));
3828 }
3829
3830 /// Delete the caret's row (not the header, not the last body row).
3831 pub fn table_delete_row(&mut self) {
3832 self.table_op("table row", |e, at| e.table_delete_row(at));
3833 }
3834
3835 /// Insert an empty column right (`right`) or left of the caret's column.
3836 pub fn table_insert_column(&mut self, right: bool) {
3837 self.table_op("table column", |e, at| e.table_insert_column(at, right));
3838 }
3839
3840 /// Delete the caret's column (unless it is the only one).
3841 pub fn table_delete_column(&mut self) {
3842 self.table_op("table column", |e, at| e.table_delete_column(at));
3843 }
3844
3845 /// Set the caret's column to `alignment`.
3846 pub fn table_set_alignment(&mut self, alignment: Alignment) {
3847 self.table_op("table alignment", |e, at| {
3848 e.table_set_alignment(at, alignment)
3849 });
3850 }
3851
3852 /// Move the caret's row one place down (`down`) or up, within the body rows.
3853 pub fn table_move_row(&mut self, down: bool) {
3854 self.table_op("table row", |e, at| e.table_move_row(at, down));
3855 }
3856
3857 /// Move the caret's column one place right (`right`) or left.
3858 pub fn table_move_column(&mut self, right: bool) {
3859 self.table_op("table column", |e, at| e.table_move_column(at, right));
3860 }
3861
3862 /// Settle the caret and document flags after a table op (or report its
3863 /// error). twig re-spells the whole table, so the caret rides its old byte
3864 /// offset and is clamped back into the rebuilt bytes — near enough to where
3865 /// it was, since the op preserves the cells' content and order around it.
3866 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3867 match result {
3868 Ok(()) => {
3869 self.last_edit_kind = None;
3870 self.refresh();
3871 self.anchor = None;
3872 self.clamp_caret();
3873 self.dirty = self.source != self.clean_source;
3874 self.status = None;
3875 self.record_caret();
3876 }
3877 Err(e) => self.status = Some(format!("{what}: {e}")),
3878 }
3879 }
3880
3881 /// One `toggle_block_container` over the block-level target.
3882 ///
3883 /// leaf says *where*; twig decides everything else — which blocks the range
3884 /// covers, whether that means wrapping, unwrapping, nesting or converting,
3885 /// and how this document's format spells the prefix. The rule that a
3886 /// container only comes off when the range covers every block it holds is
3887 /// what the re-anchoring below is built around.
3888 fn toggle_container(&mut self, kind: BlockContainerKind) {
3889 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3890 return;
3891 }
3892 let selected = self.selection();
3893 // A blank line holds no block, and twig opens an *empty* container on one
3894 // — since 3.2.0; it used to decline the range with `NotFound`, which is
3895 // why this used to lend it a scratch paragraph to wrap. Worth knowing
3896 // here because the line-for-line caret mapping below cannot describe it:
3897 // opening one under a paragraph writes the blank line the format needs
3898 // above the marker too, so the rewritten region has a line the old one
3899 // didn't, and "the same line, the same distance from its end" lands on
3900 // that new blank instead of in the container.
3901 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
3902 // Without a selection the target is the caret's own block, resolved the
3903 // way `set_block` resolves it — a caret at a line end sits at the doc
3904 // level and has to be nudged back onto the block it looks like it's in.
3905 // An empty range is enough: twig widens to the whole lines it touches.
3906 let (start, end) = match selected {
3907 Some(range) => range,
3908 None => {
3909 let off = self.block_offset_for_caret().unwrap_or(self.caret);
3910 (off, off)
3911 }
3912 };
3913 self.record_caret();
3914 match self.editor.toggle_block_container(start, end, kind) {
3915 Ok(change) => {
3916 // Read the caret's place out of the *pre-edit* source, before
3917 // `refresh` swaps that source out from under it.
3918 let place = (selected.is_none() && !opened_empty)
3919 .then(|| self.caret_line_tail(&change.old));
3920 self.last_edit_kind = None; // structural edit is its own undo step
3921 self.refresh();
3922 match place {
3923 // Both land the caret at the far end of what twig wrote, and
3924 // differ only in what they leave selected.
3925 //
3926 // From a selection: select what the container now holds, the
3927 // way `toggle` keeps its marked region selected — and for a
3928 // stronger reason than symmetry: a container comes *off* only
3929 // a range covering every block it holds, so a selection left
3930 // on its old bytes (now short by a prefix per line) would nest
3931 // on the second press instead of reversing the first.
3932 //
3933 // From a blank line: nothing to select, and the end of the
3934 // region is exactly past the bare `> ` / `- ` twig wrote —
3935 // the caret standing inside the container that was asked for.
3936 None => {
3937 self.anchor = (!opened_empty).then_some(change.new.start);
3938 self.caret = change.new.end;
3939 }
3940 Some(place) => {
3941 self.anchor = None;
3942 self.caret = self.line_tail_offset(&change.new, place);
3943 }
3944 }
3945 self.dirty = self.source != self.clean_source;
3946 self.status = None;
3947 self.clamp_caret();
3948 self.record_caret();
3949 }
3950 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3951 }
3952 }
3953
3954 /// The caret's place inside the region a container toggle is rewriting, in
3955 /// the only terms the rewrite preserves: which of the region's lines it sits
3956 /// on, and how many bytes of that line lie ahead of it.
3957 ///
3958 /// A container's markup goes in at column 0 and never touches what follows
3959 /// on the line, so that pair survives the edit exactly where a byte offset
3960 /// does not — a caret left on its old offset slides back by one prefix per
3961 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
3962 /// `> ` it just asked for.
3963 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
3964 let caret = self.caret.clamp(old.start, old.end);
3965 let line = self.source[old.start..caret].matches('\n').count();
3966 let end = self.source[caret..old.end]
3967 .find('\n')
3968 .map_or(old.end, |i| caret + i);
3969 (line, end - caret)
3970 }
3971
3972 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
3973 /// region: the offset `tail` bytes back from the end of the region's `line`.
3974 ///
3975 /// Both walks are clamped rather than trusted, because the one op that does
3976 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
3977 /// the items back apart with blank lines between them — and a caret landing
3978 /// on the nearest line of the right item beats one landing out of the region
3979 /// entirely.
3980 fn line_tail_offset(
3981 &self,
3982 new: &std::ops::Range<usize>,
3983 (line, tail): (usize, usize),
3984 ) -> usize {
3985 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3986 let mut start = 0;
3987 for _ in 0..line {
3988 match region[start..].find('\n') {
3989 Some(i) => start += i + 1,
3990 None => break,
3991 }
3992 }
3993 let end = region[start..]
3994 .find('\n')
3995 .map_or(region.len(), |i| start + i);
3996 new.start + end.saturating_sub(tail).max(start)
3997 }
3998
3999 /// Link the selection to `destination` — the toolbar's Link button. With no
4000 /// selection it acts at the caret, which re-points a link the caret is
4001 /// already standing in (twig replaces an existing link's destination and
4002 /// keeps its text) and otherwise spells a link that has no text of its own:
4003 /// an autolink (`<https://x.dev>`) where the destination is one, and
4004 /// `[destination](destination)` where it isn't.
4005 ///
4006 /// `destination` reaches twig raw. Escaping it is format knowledge and the
4007 /// two formats genuinely disagree — Markdown ends a destination at the first
4008 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4009 /// itself — so the side holding the document is the side that gets to spell
4010 /// it. A destination twig can't carry at all (one with a newline) comes back
4011 /// as an error rather than a quietly rewritten URL.
4012 pub fn insert_link(&mut self, destination: &str) {
4013 if self.refuse_unsupported("link", Gesture::InsertLink) {
4014 return;
4015 }
4016 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4017 self.record_caret();
4018 match self.editor.insert_link(start, end, destination) {
4019 Ok(change) => {
4020 self.last_edit_kind = None;
4021 self.refresh();
4022 match self.link_text_span(change.new.start) {
4023 // A link with text of its own: select it, so typing replaces
4024 // a `[dest](dest)`'s stand-in label and a second press
4025 // re-points what the first one linked.
4026 Some(text) => {
4027 self.anchor = (text.start != text.end).then_some(text.start);
4028 self.caret = text.end;
4029 }
4030 // An autolink is finished the moment it's written — its text
4031 // *is* the URL. Leaving it selected would aim the next press
4032 // at the one shape twig still wraps instead of re-points.
4033 None => {
4034 self.anchor = None;
4035 self.caret = change.new.end;
4036 }
4037 }
4038 self.dirty = self.source != self.clean_source;
4039 self.status = None;
4040 self.clamp_caret();
4041 self.record_caret();
4042 }
4043 Err(e) => self.status = Some(format!("link: {e}")),
4044 }
4045 }
4046
4047 /// Insert a block-level image at the caret: ``. Any
4048 /// selection becomes the alt text (so "select a caption, insert image" labels
4049 /// it); with no selection, `alt` is used — empty for none. The caret lands
4050 /// just past the inserted image.
4051 ///
4052 /// Both halves go through twig (`insert_literal` for the alt text,
4053 /// `insert_image` for the image), so neither is spelled here. That used to be a
4054 /// `format!`, and it was wrong the first time an app inserted a real filename:
4055 /// Markdown ends a destination at the first space, so `` is
4056 /// not an image at all — and the fix is per-format, since moving into the
4057 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4058 pub fn insert_image(&mut self, destination: &str, alt: &str) {
4059 if self.refuse_unsupported("image", Gesture::InsertImage) {
4060 return;
4061 }
4062 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4063 self.record_caret();
4064 // With no selection and an explicit `alt`, the alt text has to exist in the
4065 // document before it can be the image's — and it is raw caller input, so
4066 // it goes in through `insert_literal`, which escapes it for the format
4067 // rather than letting a `]` in someone's caption close the image early.
4068 let (start, end) = if start == end && !alt.is_empty() {
4069 match self.editor.insert_literal(start, alt) {
4070 Ok(change) => (change.new.start, change.new.end),
4071 Err(e) => {
4072 self.status = Some(format!("image: {e}"));
4073 return;
4074 }
4075 }
4076 } else {
4077 (start, end)
4078 };
4079 match self.editor.insert_image(start, end, destination) {
4080 Ok(change) => {
4081 self.last_edit_kind = None;
4082 self.refresh();
4083 // Just past the image, nothing selected — where a caret belongs
4084 // after inserting one.
4085 self.anchor = None;
4086 self.caret = change.new.end;
4087 self.dirty = self.source != self.clean_source;
4088 self.status = None;
4089 self.clamp_caret();
4090 self.record_caret();
4091 }
4092 Err(e) => self.status = Some(format!("image: {e}")),
4093 }
4094 }
4095
4096 /// Insert a block-level image, video, or audio at the caret. The image case
4097 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
4098 /// HTML elements, which is the only spelling Markdown and Djot have for them:
4099 ///
4100 /// ```text
4101 /// <video src="clip.mp4" controls>alt</video>
4102 /// <audio src="take.mp3" controls>alt</audio>
4103 /// ```
4104 ///
4105 /// HTML rather than a `::video{…}` directive deliberately. A directive means
4106 /// something only to an app that knows the vocabulary, so the document would
4107 /// read as literal punctuation everywhere else; `<video>` is what every other
4108 /// renderer already understands, and what leaf's own reader picks back up
4109 /// through `html_elements` promotion (see [`parse_extensions`]).
4110 ///
4111 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
4112 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
4113 /// `html_elements`. Before that only the multi-line form parsed as a block at
4114 /// all, and this wrote three lines to work around it.
4115 ///
4116 /// `controls` is always written: a player with no transport is a still frame
4117 /// the reader can't do anything with. Any selection becomes the element's
4118 /// fallback text, exactly as it becomes an image's alt.
4119 ///
4120 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
4121 /// applies, and bites harder here: a `"` in `destination` closes the
4122 /// attribute. A frontend taking these from a file picker is fine; one taking
4123 /// them from free text should keep them tame.
4124 ///
4125 /// [`MediaInfo`]: crate::MediaInfo
4126 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
4127 if kind == MediaKind::Image {
4128 return self.insert_image(destination, alt);
4129 }
4130 // Gated on the *image* gesture, not on one of its own — there isn't one,
4131 // since the bytes below are spelled here rather than by twig, and an HTML
4132 // document would in fact parse them. The button is one control with three
4133 // kinds behind it, and two of them working in a format where the third
4134 // cannot is a worse surface than three that agree — especially as
4135 // `insert_image` is the kind anyone reaches for first.
4136 if self.refuse_unsupported("media", Gesture::InsertImage) {
4137 return;
4138 }
4139 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4140 let alt_text = self
4141 .selected_text()
4142 .map(str::to_string)
4143 .unwrap_or_else(|| alt.to_string());
4144 let tag = match kind {
4145 MediaKind::Audio => "audio",
4146 _ => "video",
4147 };
4148 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
4149 self.edit(start, end, &markup);
4150 }
4151
4152 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
4153 /// button. Spelling and placement are both twig's; leaf used to write `---`
4154 /// itself, which was the Markdown spelling in a djot document too.
4155 ///
4156 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
4157 /// mid-paragraph and lands it after the caret's whole block. To get a rule
4158 /// *at* the caret — the paragraph parted in two around it, which is what a
4159 /// rule button is understood to do — the paragraph is first divided with
4160 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
4161 /// the offset `split_block` returns puts the rule after the *second* half
4162 /// instead, which is a rule in the right document and the wrong place.
4163 ///
4164 /// Only a plain paragraph is split. Everywhere else the rule simply lands
4165 /// after the block, which is both twig's own answer and the better one:
4166 /// splitting a fenced code block would leave two fences with a rule between
4167 /// them, and splitting a list item would mint an item nobody asked for on the
4168 /// way to a rule that lands after the list regardless. A table and a setext
4169 /// heading refuse the split outright, so they take the same path by
4170 /// themselves.
4171 pub fn insert_thematic_break(&mut self) {
4172 if self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak) {
4173 return;
4174 }
4175 self.caret = self.skip_trailing_close_delims(self.caret);
4176 // A selection is replaced by the rule, so collapse it first and let the
4177 // split-and-rule below run from the caret it leaves behind.
4178 if let Some((s, e)) = self.selection() {
4179 self.splice(s, e, "", EditKind::Other);
4180 }
4181 self.anchor = None;
4182 self.record_caret();
4183 let at = self.caret;
4184 if self.caret_in_bare_paragraph() {
4185 // A failure here is not fatal: the rule still lands after the block,
4186 // which is exactly what this call was trying to improve on.
4187 let _ = self.editor.split_block(at);
4188 }
4189 match self.editor.insert_thematic_break(at) {
4190 Ok(change) => {
4191 self.last_edit_kind = None;
4192 self.refresh();
4193 self.anchor = None;
4194 self.caret = change.new.end;
4195 self.dirty = self.source != self.clean_source;
4196 self.status = None;
4197 self.clamp_caret();
4198 self.record_caret();
4199 }
4200 Err(e) => self.status = Some(format!("thematic break: {e}")),
4201 }
4202 }
4203
4204 /// Whether the caret sits in a paragraph and nothing else — no list item, no
4205 /// quote, no fence, no table. The one shape where parting the block around
4206 /// the caret is unambiguously what a rule button means; see
4207 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4208 /// container is left to take the rule after itself.
4209 fn caret_in_bare_paragraph(&mut self) -> bool {
4210 let caret = self.caret.min(self.source.len());
4211 let Ok(chain) = self.editor.ancestors_at(caret) else {
4212 return false;
4213 };
4214 let mut in_para = false;
4215 for m in chain {
4216 match m.kind {
4217 Kind::Para => in_para = true,
4218 Kind::ListItem
4219 | Kind::TaskListItem
4220 | Kind::BlockQuote
4221 | Kind::CodeBlock
4222 | Kind::Table => return false,
4223 _ => {}
4224 }
4225 }
4226 in_para
4227 }
4228
4229 /// The destination of the link under the caret — what a Link prompt shows so
4230 /// ⌘K on an existing link edits its URL instead of asking for it again.
4231 /// `None` when the caret stands in no link.
4232 ///
4233 /// An autolink carries no separate destination: its text *is* the URL, so
4234 /// that's what comes back for one.
4235 pub fn link_destination_at_caret(&mut self) -> Option<String> {
4236 self.link_destination_at(self.caret)
4237 }
4238
4239 /// The destination of the link at `off`.
4240 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4241 /// the caret isn't.
4242 ///
4243 /// The offset form exists for the same reason
4244 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4245 /// the document somewhere else — a footnote's text in a popover, say — has
4246 /// rows and runs but no caret in them, and still needs to know which of those
4247 /// runs a reader can follow.
4248 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4249 self.nodes()
4250 .into_iter()
4251 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4252 .filter(|n| n.span.start <= off && off < n.span.end)
4253 .max_by_key(|n| n.span.start)
4254 .and_then(|n| n.destination.or(n.text))
4255 }
4256
4257 /// Where the locator `id` lands in this document — the `#v2` half of a
4258 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4259 /// answers to it.
4260 ///
4261 /// The other end of a link, and the reason this exists: without it a
4262 /// destination has only file granularity, so following a citation into a
4263 /// chapter drops the reader at the top of it to hunt for the verse. Which is
4264 /// also why it is a *document* query rather than a caret one — the document
4265 /// being asked is usually not the one the reader is in.
4266 ///
4267 /// Three readings, tried in order, because the same `#some-heading` is
4268 /// written three ways across the formats leaf opens:
4269 ///
4270 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4271 /// the auto-ids djot mints for its headings. The only exact answer, so it
4272 /// goes first — a document that says `{#v1}` has settled the question.
4273 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4274 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
4275 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
4276 /// authored anywhere land on a djot heading.
4277 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4278 /// none and `{#custom}` is literal text in a Markdown heading — so for
4279 /// the format most vaults are written in, the heading's own words are the
4280 /// only thing a fragment can name. This is the rule every Markdown
4281 /// renderer already follows, which is what makes `#a-heading` mean in
4282 /// diaryx what it means on the web.
4283 ///
4284 /// Ties go to the earliest match, then to the widest: a duplicated id is the
4285 /// document's mistake and the first one is the answer every anchor
4286 /// implementation gives, while preferring the wider span picks the section
4287 /// over the heading that opens it — more for a peek to show, same place to
4288 /// land.
4289 pub fn locate(&mut self, id: &str) -> Option<Landing> {
4290 let id = id.trim();
4291 if id.is_empty() {
4292 return None;
4293 }
4294 let nodes = self.nodes();
4295
4296 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4297 // is picking, among nodes that start together, the one that ends last.
4298 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4299 matches
4300 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4301 .map(|n| Landing {
4302 start: n.span.start,
4303 end: n.span.end,
4304 })
4305 };
4306
4307 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4308 return Some(landing);
4309 }
4310 let want = slug(id);
4311 if want.is_empty() {
4312 return None;
4313 }
4314 if let Some(landing) = pick(
4315 &mut nodes
4316 .iter()
4317 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4318 ) {
4319 return Some(landing);
4320 }
4321
4322 // A heading by its words. Its span is one line, so the end comes from
4323 // where the *section* it opens gives out — the next heading that is not
4324 // under it, or the end of the document. A Markdown heading has no
4325 // section node to ask (twig only builds those for djot), and a peek that
4326 // showed the heading alone would answer "what does that say" with the
4327 // title of the thing it says.
4328 let heading = nodes
4329 .iter()
4330 .filter(|n| n.kind == Kind::Heading)
4331 .filter(|n| {
4332 n.content_span
4333 .clone()
4334 .and_then(|s| self.source.get(s))
4335 .is_some_and(|text| slug(text) == want)
4336 })
4337 .min_by_key(|n| n.span.start)?;
4338 let level = heading.level.unwrap_or(u32::MAX);
4339 let end = nodes
4340 .iter()
4341 .filter(|n| n.kind == Kind::Heading)
4342 .filter(|n| n.span.start > heading.span.start)
4343 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4344 .map(|n| n.span.start)
4345 .min()
4346 .unwrap_or(self.source.len());
4347 Some(Landing {
4348 start: heading.span.start,
4349 end,
4350 })
4351 }
4352
4353 /// Write a footnote at the caret — the toolbar's Footnote button, and the
4354 /// one gesture in the footnote story that *authors* rather than follows.
4355 ///
4356 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4357 /// the `[^1]:` definition at the end of the document. Half a footnote is not
4358 /// a footnote — a bare reference with nothing defining it renders as literal
4359 /// brackets — so a single button that wrote only the reference would leave
4360 /// the author to hand-spell the other half in a document that had just
4361 /// stopped showing them what the first half meant. One edit also means one
4362 /// undo takes both back.
4363 ///
4364 /// The definition's body is left empty and **the caret lands in it**, which
4365 /// is the whole point of pressing the button: nobody wants a reference to a
4366 /// note they have not written yet. Getting back to where they were writing
4367 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4368 /// the same return leg a reader following a reference already uses, so the
4369 /// author is left standing on the near end of a round trip that works.
4370 ///
4371 /// A selection collapses to its *end* rather than being replaced: a
4372 /// reference annotates the words before it, so "select the claim, add a
4373 /// footnote" should mark that claim, not consume it.
4374 pub fn insert_footnote(&mut self) {
4375 if self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4376 return;
4377 }
4378 let at = self.selection().map_or(self.caret, |(_, end)| end);
4379 self.anchor = None;
4380 self.caret = at;
4381 self.record_caret();
4382 let label = self.next_footnote_label();
4383 match self.editor.insert_footnote(at, &label) {
4384 Ok(change) => {
4385 self.last_edit_kind = None;
4386 self.refresh();
4387 self.anchor = None;
4388 // `change.new` runs from the reference to the end of the
4389 // document, so its start is the `[^1]` just written and
4390 // `footnote_at` resolves it to the note the same way a reader's
4391 // tap does — and to the note's *body*, which is already a caret
4392 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4393 // and has none), so this needs no snap on top. The fallback is
4394 // the reference's own offset: a format that spelled the pair some
4395 // way leaf can't read back should still leave the caret on the
4396 // edit rather than at the far end of a document it just grew.
4397 self.caret = self
4398 .footnote_at(change.new.start)
4399 .and_then(|note| note.offset)
4400 .unwrap_or(change.new.start);
4401 self.dirty = self.source != self.clean_source;
4402 self.status = None;
4403 self.clamp_caret();
4404 self.record_caret();
4405 }
4406 Err(e) => self.status = Some(format!("footnote: {e}")),
4407 }
4408 }
4409
4410 /// The label to give a footnote the author has not named: the lowest counting
4411 /// number no footnote in the document is already wearing.
4412 ///
4413 /// twig takes the label rather than minting one, because it holds no opinion
4414 /// about what a document's footnotes should be called — and it is right not
4415 /// to. Numbering them is what every author of a numbered note expects, and
4416 /// re-using a taken number would silently point the new reference at somebody
4417 /// else's note (twig reuses an existing definition rather than appending a
4418 /// second one, which is the right rule for citing a note twice on purpose and
4419 /// exactly the wrong accident to have by default).
4420 ///
4421 /// *References* are counted alongside definitions, not just definitions: a
4422 /// document carrying a dangling `[^2]` has a 2 that means something to
4423 /// whoever wrote it, and minting a definition for it here would answer a
4424 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4425 /// count entirely — they take no number, so they block none.
4426 fn next_footnote_label(&mut self) -> String {
4427 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4428 .into_iter()
4429 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4430 .filter_map(|label| label.parse().ok())
4431 .collect();
4432 taken.extend(
4433 self.nodes()
4434 .into_iter()
4435 .filter(|n| n.kind == Kind::FootnoteReference)
4436 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4437 .filter_map(|label| label.parse::<u32>().ok()),
4438 );
4439 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4440 }
4441
4442 /// The footnote reference under the caret, resolved to the note it names.
4443 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4444 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4445 self.footnote_at(self.caret)
4446 }
4447
4448 /// The footnote reference at `off`, resolved to the note it names — what a
4449 /// frontend shows when a reader activates a `[^1]`.
4450 ///
4451 /// A reference is not a link node, so
4452 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4453 /// (and should not) answer for one: a link names a destination to leave for,
4454 /// a reference names a note that is already in this document. Following one
4455 /// is a move within the page, which is why this hands back an `offset`
4456 /// rather than something to open.
4457 ///
4458 /// Offset-based rather than caret-only because the gesture that wants this
4459 /// most is the one that must not move the caret: a pointer hovering a `[1]`
4460 /// asks what note it names without disturbing where the reader was typing.
4461 /// The caret is just the offset a click already placed —
4462 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4463 ///
4464 /// `None` when `off` stands in no reference. A reference whose note the
4465 /// document never defines is *not* `None` — it answers with the label it
4466 /// looked for and no text, which is what lets a frontend say so instead of
4467 /// silently doing nothing.
4468 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4469 // Innermost-wins by latest start, the rule its link sibling uses.
4470 let span = self
4471 .nodes()
4472 .into_iter()
4473 .filter(|n| n.kind == Kind::FootnoteReference)
4474 .filter(|n| n.span.start <= off && off < n.span.end)
4475 .max_by_key(|n| n.span.start)?
4476 .span;
4477 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4478
4479 // The note itself. Definitions are roots beside `doc` rather than
4480 // children of it, so they're asked for directly — see
4481 // `wysiwyg::footnote_definitions`.
4482 let note = wysiwyg::footnote_definitions(&mut self.editor)
4483 .into_iter()
4484 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4485 let Some(note) = note else {
4486 return Some(FootnoteRef {
4487 label,
4488 text: None,
4489 offset: None,
4490 end: None,
4491 });
4492 };
4493 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4494 Some(FootnoteRef {
4495 label,
4496 text: body
4497 .clone()
4498 .and_then(|b| self.source.get(b))
4499 .map(str::to_string),
4500 // The body's start, not the definition's — see `FootnoteRef::offset`.
4501 offset: body.clone().map(|b| b.start),
4502 end: body.map(|b| b.end),
4503 })
4504 }
4505
4506 /// The footnote *definition* the caret stands in, and where the reference
4507 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4508 /// at the caret's offset.
4509 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4510 self.footnote_definition_at(self.caret)
4511 }
4512
4513 /// The footnote definition spanning `off`, and where the reference that
4514 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4515 ///
4516 /// The mirror image, deliberately: the same gesture that takes a reader from
4517 /// `[1]` down to the note takes them from the note back up to `[1]`, so
4518 /// following a footnote is a round trip rather than a fall. It needs no
4519 /// memory of how the reader arrived — the document says where the reference
4520 /// is — which is what makes it work for a reader who scrolled to the notes
4521 /// themselves, and what keeps it right after an edit moves either end.
4522 ///
4523 /// `None` when `off` stands in no definition. A definition nothing cites is
4524 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4525 /// its label and no offset, so a frontend can say "nothing refers to this"
4526 /// rather than offer a jump that goes nowhere.
4527 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4528 // Definitions are roots beside `doc`, so `nodes()` — which walks the
4529 // document body — never reports one. They're asked for directly, the way
4530 // `footnote_at` asks for the note it resolves to.
4531 //
4532 // Closed at the end, unlike the half-open test its neighbours use. A
4533 // definition's span stops at its last content byte — the newline ending
4534 // the line is outside it — so `span.end` is the caret stop at the end of
4535 // the note's own row, not the first byte of anything after. Excluding it
4536 // meant the one caret an author is guaranteed to have, the one left
4537 // sitting at the end of the note they just typed, was in no definition at
4538 // all: writing a note and then asking to go back to its reference
4539 // answered nothing. Two definitions in a row still can't both match —
4540 // there is a blank line between them — and `max_by_key` decides anyway.
4541 let note = wysiwyg::footnote_definitions(&mut self.editor)
4542 .into_iter()
4543 .filter(|m| m.span.start <= off && off <= m.span.end)
4544 .max_by_key(|m| m.span.start)?;
4545 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4546
4547 // The earliest reference carrying this label. `min` rather than a `find`,
4548 // because `nodes()` reports a flattened walk whose order is twig's
4549 // business, not document order. Bound first: the walk needs `&mut self`
4550 // and reading the labels back out needs `&self.source`.
4551 let nodes = self.nodes();
4552 let offset = nodes
4553 .into_iter()
4554 .filter(|n| n.kind == Kind::FootnoteReference)
4555 .filter(|n| {
4556 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4557 })
4558 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4559 .map(|n| n.span.start + 2)
4560 .min();
4561 Some(FootnoteDef { label, offset })
4562 }
4563
4564 /// The destination of the image under the caret — what an image prompt shows
4565 /// so editing an existing image starts from its current URL instead of blank,
4566 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4567 /// `None` when the caret stands in no image. A caret resting just after a
4568 /// block image (its trailing stop) is still "in" it — the half-open span test
4569 /// excludes that offset, which is the intended precision: past the image is
4570 /// past it.
4571 pub fn image_destination_at_caret(&mut self) -> Option<String> {
4572 let off = self.caret;
4573 self.nodes()
4574 .into_iter()
4575 .filter(|n| n.kind == Kind::Image)
4576 .filter(|n| n.span.start <= off && off < n.span.end)
4577 .max_by_key(|n| n.span.start)
4578 .and_then(|n| n.destination)
4579 }
4580
4581 /// The language of the fenced code block the caret stands in — what a
4582 /// language prompt shows so editing it starts from the current value rather
4583 /// than blank. `None` when the caret is in no code block, or in one whose
4584 /// fence carries no language (or an indented block, which has no fence).
4585 pub fn code_language_at_caret(&mut self) -> Option<String> {
4586 let start = self.code_block_start_at_caret()?;
4587 wysiwyg::code_language(&self.source, start)
4588 }
4589
4590 /// Whether the caret stands in a fenced code block — the one a language
4591 /// prompt could edit. A frontend gates its "set language" affordance on this
4592 /// (an indented block, which can't carry a language, reports `false`).
4593 pub fn caret_in_fenced_code(&mut self) -> bool {
4594 self.code_block_start_at_caret()
4595 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4596 }
4597
4598 /// Set (or clear, with `""`) the language of the fenced code block the caret
4599 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4600 /// block, and a reported error for a language the format's fence cannot
4601 /// carry.
4602 ///
4603 /// twig rewrites the info string, so the fence's own width — measured
4604 /// against a body neither side touches — is kept, and a language holding a
4605 /// space, a line end or the fence character is refused rather than written
4606 /// out to reparse as something else. Leaf used to splice over the info span
4607 /// itself and `trim()` the input, which handled the one bad case it had
4608 /// thought of.
4609 pub fn set_code_language(&mut self, lang: &str) {
4610 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4611 return;
4612 }
4613 if self.code_block_start_at_caret().is_none() {
4614 return;
4615 }
4616 let lang = lang.trim();
4617 // `None` clears the info string; `Some("")` asks for an empty one. Both
4618 // write a bare fence, and the prompt's empty value means "clear".
4619 let want = (!lang.is_empty()).then_some(lang);
4620 self.record_caret();
4621 match self.editor.set_code_language(self.caret, want) {
4622 Ok(_) => {
4623 self.last_edit_kind = None;
4624 self.refresh();
4625 self.anchor = None;
4626 self.dirty = self.source != self.clean_source;
4627 self.status = None;
4628 self.clamp_caret();
4629 self.record_caret();
4630 }
4631 Err(e) => self.status = Some(format!("code language: {e}")),
4632 }
4633 }
4634
4635 /// The `span.start` of the code block covering the caret — the anchor
4636 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4637 /// in none.
4638 fn code_block_start_at_caret(&mut self) -> Option<usize> {
4639 let off = self.caret;
4640 self.nodes()
4641 .into_iter()
4642 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4643 .max_by_key(|n| n.span.start)
4644 .map(|n| n.span.start)
4645 }
4646
4647 /// The source range of the text inside the link covering `off` — what sits
4648 /// between its `[` and `]`. `None` when twig reports no link there.
4649 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4650 self.nodes()
4651 .into_iter()
4652 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4653 // the other's `span.start`; the link that starts latest at or before
4654 // `off` is the one `off` is actually in.
4655 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4656 .max_by_key(|n| n.span.start)
4657 .and_then(|n| n.content_span)
4658 }
4659
4660 // ── undo / redo ───────────────────────────────────────────────────────────
4661 // twig owns the history of *bytes* (it owns the buffer) and now carries the
4662 // caret through it too: `record_caret` stashes each state's caret in twig's
4663 // opaque per-step blob, and undo/redo hand it back with the source they
4664 // restore. So leaf keeps no history of its own — no parallel stacks to march
4665 // in lockstep and silently drift out of it.
4666
4667 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4668 /// where they were when that step began.
4669 pub fn undo(&mut self) {
4670 if self.read_only {
4671 return;
4672 }
4673 match self.editor.undo() {
4674 Ok(Some(change)) => self.after_history(change),
4675 Ok(None) => self.status = Some("nothing to undo".into()),
4676 Err(e) => self.status = Some(format!("undo: {e}")),
4677 }
4678 }
4679
4680 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4681 /// selection back where that step originally left them.
4682 pub fn redo(&mut self) {
4683 if self.read_only {
4684 return;
4685 }
4686 match self.editor.redo() {
4687 Ok(Some(change)) => self.after_history(change),
4688 Ok(None) => self.status = Some("nothing to redo".into()),
4689 Err(e) => self.status = Some(format!("redo: {e}")),
4690 }
4691 }
4692
4693 /// Refresh the cached source and put the caret back where the step being
4694 /// undone/redone had it, clearing any active run.
4695 ///
4696 /// The caret comes from twig's blob for the restored state (what
4697 /// `record_caret` stored). `change` is only the fallback for a state with no
4698 /// blob — a caret at the end of the restored text, which is where this always
4699 /// landed before the blobs were kept. It is the edit site, not where the user
4700 /// was standing, so it's a floor and not the behaviour: undoing should hand
4701 /// back the document *and* the place you were working, which for an edit made
4702 /// anywhere but under the caret are two different places.
4703 fn after_history(&mut self, change: Change) {
4704 self.refresh();
4705 match self
4706 .editor
4707 .caret_blob()
4708 .ok()
4709 .and_then(|b| CaretState::from_blob(&b))
4710 {
4711 Some(state) => {
4712 self.caret = state.caret.min(self.source.len());
4713 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4714 }
4715 None => {
4716 self.caret = change.new.end.min(self.source.len());
4717 self.anchor = None;
4718 }
4719 }
4720 self.goal_col = None;
4721 self.last_edit_kind = None;
4722 self.dirty = self.source != self.clean_source;
4723 self.status = None;
4724 self.clamp_caret();
4725 }
4726
4727 // ── the file ──────────────────────────────────────────────────────────────
4728
4729 #[cfg(feature = "fs")]
4730 pub fn save(&mut self) {
4731 if self.is_untitled() {
4732 // No path to write and no name to invent: ⌘S on an untitled document
4733 // is a Save As, and only a frontend has a picker to ask with. Say so
4734 // rather than failing at the filesystem with an empty path.
4735 self.status = Some("untitled — save as…".into());
4736 return;
4737 }
4738 let path = self.path.clone();
4739 if self.write(&path) {
4740 self.mark_saved();
4741 }
4742 }
4743
4744 /// Save As: write the document to `path` and *move* it there — `self.path`
4745 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4746 /// what Save As means; a copy would leave the user editing a document whose
4747 /// name is no longer where their keystrokes go.
4748 ///
4749 /// The move only happens if the bytes actually landed. A failed write leaves
4750 /// the path, `dirty`, and the disk watermark exactly as they were, with the
4751 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4752 /// must never come away believing it was saved.
4753 ///
4754 /// An existing `path` is overwritten, and the caller is the one that knows
4755 /// whether to ask first: a Save As picker has already run that prompt, and a
4756 /// second confirmation from down here would be the same question twice.
4757 ///
4758 /// `format` does **not** follow the new extension. The buffer is parsed as
4759 /// the format it was opened with, and re-reading it as another one is a
4760 /// conversion — a different, lossy operation that would throw away the undo
4761 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4762 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4763 /// until it's reopened.
4764 #[cfg(feature = "fs")]
4765 pub fn save_as(&mut self, path: PathBuf) {
4766 if !self.write(&path) {
4767 return;
4768 }
4769 self.path = path;
4770 self.mark_saved();
4771 }
4772
4773 /// Put `source` on disk at `path`, reporting whether it got there. The one
4774 /// place leaf writes a document, so a save and a Save As can't disagree
4775 /// about what a failure looks like.
4776 #[cfg(feature = "fs")]
4777 fn write(&mut self, path: &Path) -> bool {
4778 match std::fs::write(path, self.source.as_bytes()) {
4779 Ok(()) => true,
4780 Err(e) => {
4781 self.status = Some(format!("save failed: {e}"));
4782 false
4783 }
4784 }
4785 }
4786
4787 /// Re-base the document's saved watermark to the current bytes: clears
4788 /// `dirty`, records `source` as the new clean state (so undoing back to here
4789 /// clears the flag again), and re-stamps the on-disk hash.
4790 ///
4791 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4792 /// the hook a **filesystem-free host** calls itself once it has persisted
4793 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4794 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4795 /// are already where that host wants them, and this just tells the model they
4796 /// are safe.
4797 pub fn mark_saved(&mut self) {
4798 self.clean_source = self.source.clone();
4799 self.dirty = false;
4800 // The bytes on disk are now ours, so this is the new watermark: without
4801 // re-stamping it, every save would report its own work as an external
4802 // change forever after.
4803 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4804 self.status = Some(format!("saved {}", self.file_name()));
4805 }
4806
4807 /// What the file looks like now against the bytes leaf last read or wrote.
4808 ///
4809 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4810 /// so this is a filesystem round-trip, not a per-frame question — ask it
4811 /// when a window regains focus, on a timer, or before a save.
4812 ///
4813 /// This *only* reports the file. Whether the document also has unsaved edits
4814 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4815 /// [`DiskState::Changed`] means a save overwrites someone's work and a
4816 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4817 /// it has no way to ask — so it hands a frontend both halves and lets it put
4818 /// the question to the person who can answer it.
4819 #[cfg(feature = "fs")]
4820 pub fn disk_state(&self) -> DiskState {
4821 let Some(want) = self.disk_hash else {
4822 return DiskState::Untitled;
4823 };
4824 match std::fs::read(&self.path) {
4825 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4826 Ok(_) => DiskState::Changed,
4827 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4828 Err(_) => DiskState::Unreadable,
4829 }
4830 }
4831
4832 /// Re-read the file and replace the document with what's there — the other
4833 /// answer to a [`DiskState::Changed`].
4834 ///
4835 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
4836 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
4837 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
4838 /// document shouldn't have to argue with a guard.
4839 ///
4840 /// **The undo history survives, and the reload is one step in it.** The
4841 /// whole buffer is spliced with the file's bytes through the same door every
4842 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
4843 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
4844 /// swapped the document out from under a reader gives them back what they
4845 /// were looking at, marked dirty, and ^Z again carries on into whatever they
4846 /// had done before it. This used to build a fresh parse and drop the stack,
4847 /// on the reasoning that twig's history belongs to the buffer and these are
4848 /// different bytes; that is true of *rebasing* a step onto them and not of
4849 /// recording the swap itself as one, which is all this is. A splice twig
4850 /// won't take falls back to the fresh parse, and only that path still costs
4851 /// the history.
4852 ///
4853 /// The caret keeps its byte offset, clamped to the new length; the selection
4854 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4855 /// file changed, so it can't know where the caret "still" is. Clamping keeps
4856 /// it where the user left it in the common case (a change further down the
4857 /// file, or none in the text they're sitting in), and never puts it
4858 /// somewhere invalid. A selection has two such offsets and no such excuse —
4859 /// silently reinterpreting one over changed bytes would arm the *next*
4860 /// keystroke to delete something the user never selected.
4861 ///
4862 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4863 /// document alone with a status.
4864 #[cfg(feature = "fs")]
4865 pub fn reload(&mut self) {
4866 if self.is_untitled() {
4867 self.status = Some("no file to reload".into());
4868 return;
4869 }
4870 let bytes = match std::fs::read(&self.path) {
4871 Ok(b) => b,
4872 Err(e) => {
4873 self.status = Some(format!("reload failed: {e}"));
4874 return;
4875 }
4876 };
4877 let Ok(source) = String::from_utf8(bytes) else {
4878 self.status = Some("reload failed: file is not UTF-8".into());
4879 return;
4880 };
4881 // Already these bytes — someone saved a file back unchanged, or leaf's
4882 // own write is being read back. Re-baseline against it and stop: a
4883 // splice of the text onto itself would put an undo step on the stack for
4884 // something nobody did.
4885 if source == self.source {
4886 self.disk_hash = Some(hash_bytes(source.as_bytes()));
4887 self.clean_source = source;
4888 self.dirty = false;
4889 self.status = Some(format!("reloaded {}", self.file_name()));
4890 return;
4891 }
4892 let caret = self.caret;
4893 // The pre-reload caret, so undoing the swap puts it back where the
4894 // reader was standing — the same bracketing `splice_exact` does.
4895 self.record_caret();
4896 if self
4897 .editor
4898 .edit_range(0, self.source.len(), &source)
4899 .is_ok()
4900 {
4901 self.refresh();
4902 } else {
4903 // twig wouldn't take the splice. Start over from the bytes, which is
4904 // what this always did, and is the one path that still costs the
4905 // history — `format` is the format this document *is*, not what the
4906 // (unchanged) name now says, see `save_as`.
4907 match new_editor(source.as_bytes(), self.format) {
4908 Ok(editor) => {
4909 self.editor = editor;
4910 self.source = source.clone();
4911 // Not going through `refresh`, so the revision has to move
4912 // here or every frontend keeps painting the old file from
4913 // cache.
4914 self.revision += 1;
4915 }
4916 Err(e) => {
4917 self.status = Some(format!("reload failed: {e}"));
4918 return;
4919 }
4920 }
4921 }
4922 self.disk_hash = Some(hash_bytes(source.as_bytes()));
4923 self.clean_source = self.source.clone();
4924 self.caret = caret.min(self.source.len());
4925 self.anchor = None;
4926 self.goal_col = None;
4927 self.last_edit_kind = None;
4928 self.dirty = false;
4929 self.status = Some(format!("reloaded {}", self.file_name()));
4930 self.clamp_caret();
4931 // And the post-reload caret, so a redo restores it.
4932 self.record_caret();
4933 }
4934
4935 /// Re-read the source from twig after it has changed the document. The one
4936 /// funnel every edit, undo, and redo comes through — so it's where the
4937 /// revision moves, and anything cached against the text dies here.
4938 fn refresh(&mut self) {
4939 if let Ok(s) = self.editor.source_str() {
4940 self.source = s;
4941 }
4942 self.revision += 1;
4943 self.clamp_caret();
4944 }
4945
4946 // ── caret movement ─────────────────────────────────────────────────────────
4947 // `extend` grows the selection (Shift+motion): it pins the anchor on the
4948 // first extended step and moves only the caret; an un-extended motion drops
4949 // the selection.
4950
4951 /// Place the caret at byte `offset` (clamped to a char boundary), extending
4952 /// the selection when `extend` is set. The public form of `move_to`, for a
4953 /// frontend that hit-tests pixels straight to a source offset.
4954 pub fn place_caret(&mut self, offset: usize, extend: bool) {
4955 self.goal_col = None;
4956 let before = self.caret;
4957 // A pixel hit-test can land between the visible caret stops — in the
4958 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
4959 // Snap to the nearest real stop so the caret can't come to rest where it
4960 // would draw in one place and type in another. The `(row, col)` click
4961 // path (`click`) already snaps this way through `offset_of_pos`; the
4962 // source view reaches every byte, so it snaps to nothing.
4963 let target = match self.view {
4964 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
4965 // The source view reaches every byte, so there is no stop to snap
4966 // to — but "every byte" still means every *character* boundary. A
4967 // caret resting inside a multi-byte character draws nowhere real
4968 // and panics the next time anything slices there.
4969 View::Source => self.char_boundary_at_or_before(offset),
4970 };
4971 self.move_to(target, extend);
4972 self.clamp_caret();
4973 self.debug_assert_on_a_stop(before);
4974 }
4975
4976 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
4977 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
4978 /// grab the metadata) while the source view still selects the literal whole.
4979 pub fn select_all(&mut self) {
4980 self.anchor = Some(self.caret_floor());
4981 self.caret = self.source.len();
4982 self.goal_col = None;
4983 self.last_edit_kind = None;
4984 self.status = None;
4985 }
4986
4987 /// Select the word (or whitespace / punctuation run) at `offset` — the
4988 /// double-click gesture. Anchors on the run's start with the caret at its
4989 /// end so a following Shift-motion extends from the far edge.
4990 pub fn select_word_at(&mut self, offset: usize) {
4991 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
4992 self.anchor = Some(s);
4993 self.caret = e;
4994 self.goal_col = None;
4995 self.last_edit_kind = None;
4996 self.status = None;
4997 self.clamp_caret();
4998 }
4999
5000 /// Select the whole enclosing text block (paragraph, heading, list item's
5001 /// text…) at `offset` — the triple-click gesture. Reads the range straight
5002 /// from the AST (twig's `content_span`), so it selects the entire *logical*
5003 /// paragraph even when that paragraph soft-wraps across several visual rows —
5004 /// where a visual-row-based select breaks down, because one source offset at
5005 /// a wrap boundary belongs to two rows at once.
5006 pub fn select_block_at(&mut self, offset: usize) {
5007 let off = offset.min(self.source.len());
5008 let range = self
5009 .editor
5010 .ancestors_at(off)
5011 .ok()
5012 .and_then(|chain| {
5013 // Ancestors run root → deepest; the deepest node that is neither
5014 // an inline span nor a multi-block container is the text block
5015 // the caret sits in (a paragraph, a heading, a code block…).
5016 chain
5017 .into_iter()
5018 .rev()
5019 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5020 .map(|m| m.content_span.unwrap_or(m.span))
5021 })
5022 .unwrap_or_else(|| source_line_range(&self.source, off));
5023 self.anchor = Some(range.start.min(self.source.len()));
5024 self.caret = range.end.min(self.source.len());
5025 self.goal_col = None;
5026 self.last_edit_kind = None;
5027 self.status = None;
5028 self.clamp_caret();
5029 }
5030
5031 /// Select the exact source range `[start, end)` — anchor at `start`, caret
5032 /// at `end` — without snapping either end to a visible caret stop.
5033 ///
5034 /// The one caret verb that takes a range it was *handed* rather than one it
5035 /// worked out, for a host that already knows the bytes it means: a search
5036 /// hit, an annotation's footprint, a quote re-anchored through
5037 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
5038 /// wrong tool for that, and not by a little — it snaps to the nearest
5039 /// *visible* stop, and where a range butts up against a hidden delimiter
5040 /// the nearest stop is the one before it, so selecting the "needle" of
5041 /// `**needle**` comes back with "needl" and an edit against it strands the
5042 /// "e".
5043 ///
5044 /// What `place_caret` does that is bookkeeping rather than snapping still
5045 /// happens here, because a host handing in a range is not asking to opt out
5046 /// of the invariants:
5047 ///
5048 /// - both ends are clamped into the document and up to
5049 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
5050 /// frontmatter is hidden, and a caret parked in it draws nowhere and
5051 /// types into the metadata;
5052 /// - both land on character boundaries, so nothing slices a `é` in half;
5053 /// - the sticky vertical goal column is dropped, and any armed inline mark
5054 /// disarmed, since a range from outside inherits neither.
5055 ///
5056 /// An empty range is a caret rather than a selection —
5057 /// [`selection`](Self::selection) reports `None` for it, as it does for any
5058 /// anchor that has met the caret.
5059 pub fn select_range(&mut self, start: usize, end: usize) {
5060 let floor = self.caret_floor();
5061 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
5062 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
5063 self.anchor = Some(anchor);
5064 self.caret = caret;
5065 self.goal_col = None;
5066 self.status = None;
5067 self.last_edit_kind = None;
5068 self.clear_pending();
5069 }
5070
5071 /// `offset` itself if it is a character boundary, else the boundary before
5072 /// it. An offset that isn't one draws nowhere real and panics the next time
5073 /// anything slices there.
5074 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
5075 let mut o = offset.min(self.source.len());
5076 while o > 0 && !self.source.is_char_boundary(o) {
5077 o -= 1;
5078 }
5079 o
5080 }
5081
5082 /// The lowest source offset the caret may occupy in the active view. In
5083 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
5084 /// the first rendered offset; the source view reaches everything, so it's 0.
5085 fn caret_floor(&self) -> usize {
5086 match self.view {
5087 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
5088 View::Source => 0,
5089 }
5090 }
5091
5092 /// Land in a table cell with its whole content selected — the anchor at the
5093 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
5094 /// like tabbing into a form field: the text comes up selected, so typing
5095 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
5096 /// end`) collapses to a plain caret home (an empty selection is no selection).
5097 fn select_cell(&mut self, start: usize, end: usize) {
5098 self.select_range(start, end);
5099 }
5100
5101 fn move_to(&mut self, offset: usize, extend: bool) {
5102 if extend {
5103 if self.anchor.is_none() {
5104 self.anchor = Some(self.caret);
5105 }
5106 } else {
5107 self.anchor = None;
5108 }
5109 self.caret = offset.min(self.source.len()).max(self.caret_floor());
5110 self.status = None;
5111 // A caret move ends the current typing/deletion run, so the next edit
5112 // starts a fresh undo group rather than coalescing across the gap.
5113 self.last_edit_kind = None;
5114 // Moving away disarms any sticky mark — "start bold" applies only where
5115 // it was asked for, not wherever the caret next lands.
5116 self.clear_pending();
5117 }
5118
5119 // In the source view, motion walks source bytes / source lines. In the
5120 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
5121 // what steps the caret cleanly over hidden delimiters.
5122
5123 pub fn move_left(&mut self, extend: bool) {
5124 self.goal_col = None;
5125 if !extend && let Some((s, _e)) = self.selection() {
5126 self.move_to(s, false);
5127 return;
5128 }
5129 let target = match self.view {
5130 View::Source => {
5131 if self.caret > 0 {
5132 prev_boundary(&self.source, self.caret)
5133 } else {
5134 0
5135 }
5136 }
5137 // Walks caret *stops*, not columns: decoration (a table border, a
5138 // cell's padding) is stepped over in one press, and a hidden
5139 // delimiter never holds the caret up.
5140 View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
5141 };
5142 let before = self.caret;
5143 self.move_to(target, extend);
5144 self.debug_assert_on_a_stop(before);
5145 }
5146
5147 pub fn move_right(&mut self, extend: bool) {
5148 self.goal_col = None;
5149 if !extend && let Some((_s, e)) = self.selection() {
5150 self.move_to(e, false);
5151 return;
5152 }
5153 let target = match self.view {
5154 View::Source => {
5155 if self.caret < self.source.len() {
5156 next_boundary(&self.source, self.caret)
5157 } else {
5158 self.caret
5159 }
5160 }
5161 View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
5162 };
5163 let before = self.caret;
5164 self.move_to(target, extend);
5165 self.debug_assert_on_a_stop(before);
5166 }
5167
5168 /// Move to the start of the previous word (⌥← / Ctrl+←).
5169 pub fn move_word_left(&mut self, extend: bool) {
5170 self.goal_col = None;
5171 let before = self.caret;
5172 let target = self.word_left_from(self.caret);
5173 self.move_to(target, extend);
5174 self.debug_assert_on_a_stop(before);
5175 }
5176
5177 /// Move to the end of the next word (⌥→ / Ctrl+→).
5178 pub fn move_word_right(&mut self, extend: bool) {
5179 self.goal_col = None;
5180 let before = self.caret;
5181 let target = self.word_right_from(self.caret);
5182 self.move_to(target, extend);
5183 self.debug_assert_on_a_stop(before);
5184 }
5185
5186 // Word boundaries are found in the space the *view* is in. The source view
5187 // walks the source, because there the source is what's rendered. WYSIWYG
5188 // walks the rendered text instead: `**` is invisible to the user, so it has
5189 // to be invisible to word motion too — a caret parked inside one draws in
5190 // the column after `bold` and types two bytes earlier, and a word-delete
5191 // that stops there shreds the markup into `a ** c`.
5192
5193 /// The word boundary to the left of `off` in the active view's space.
5194 fn word_left_from(&self, off: usize) -> usize {
5195 match self.view {
5196 View::Source => prev_word(&self.source, off),
5197 View::Wysiwyg => self.glyph_word_left(off),
5198 }
5199 }
5200
5201 /// The word boundary to the right of `off` in the active view's space.
5202 fn word_right_from(&self, off: usize) -> usize {
5203 match self.view {
5204 View::Source => next_word(&self.source, off),
5205 View::Wysiwyg => self.glyph_word_right(off),
5206 }
5207 }
5208
5209 /// The character class of the glyph drawn at stop `off`.
5210 ///
5211 /// Read from the source, because a stop points at the source byte its glyph
5212 /// came from — the source *is* where the rendered character is written. What
5213 /// makes the walk glyph space rather than source space is that it only ever
5214 /// visits stops, and the hidden bytes between them have none.
5215 fn class_at(&self, off: usize) -> Class {
5216 self.source
5217 .get(off..)
5218 .and_then(|s| s.chars().next())
5219 .map_or(Class::Space, classify)
5220 }
5221
5222 /// [`next_word`] in glyph space: skip any leading separators, then consume
5223 /// the following word run, with the stop table standing in for the source's
5224 /// characters.
5225 fn glyph_word_right(&self, from: usize) -> usize {
5226 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
5227 return from;
5228 };
5229 let mut in_word = false;
5230 loop {
5231 match self.class_at(off) {
5232 Class::Word => in_word = true,
5233 _ if in_word => return off,
5234 _ => {}
5235 }
5236 match self.vmap.stop_after(off) {
5237 Some(next) => off = next,
5238 None => return off,
5239 }
5240 }
5241 }
5242
5243 /// [`prev_word`] in glyph space: skip separators walking left, then consume
5244 /// the preceding word run.
5245 fn glyph_word_left(&self, from: usize) -> usize {
5246 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
5247 return from;
5248 };
5249 let mut in_word = false;
5250 while let Some(prev) = self.vmap.stop_before(off) {
5251 match self.class_at(prev) {
5252 Class::Word => in_word = true,
5253 _ if in_word => return off,
5254 _ => {}
5255 }
5256 off = prev;
5257 }
5258 off
5259 }
5260
5261 /// After a motion that walks the visual map, the caret must be *on* the map.
5262 /// A stop is the only offset where the caret draws and edits in the same
5263 /// place, and it's the invariant both a caret parked inside an emoji and one
5264 /// parked inside a `**` were quietly breaking.
5265 ///
5266 /// Only when the caret actually moved: a walk with nowhere to go leaves it
5267 /// where it was, which is wherever the floor or a frontend put it rather
5268 /// than somewhere this motion chose.
5269 fn debug_assert_on_a_stop(&self, before: usize) {
5270 debug_assert!(
5271 self.view != View::Wysiwyg
5272 || self.vmap.num_rows() == 0
5273 || self.caret == before
5274 || self.vmap.is_stop(self.caret),
5275 "motion left the caret at {}, which is not a caret stop: it would draw in \
5276 one place and type in another",
5277 self.caret
5278 );
5279 }
5280
5281 // Up and Down run off the ends of the document rather than stopping dead at
5282 // them: Up from the first row lands at the document's start, Down from the
5283 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5284 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5285 // reaching the end of the text is what a reader means by it.
5286 //
5287 // The views used to disagree here by accident rather than by decision: the
5288 // source view fell into the edge behaviour through `row_col_to_offset`
5289 // clamping an out-of-range row to the end of the string, while WYSIWYG had
5290 // no row below to walk to and did nothing at all. They share the rule now,
5291 // each in its own space — the source view reaches every byte, WYSIWYG only
5292 // the offsets it draws.
5293
5294 pub fn move_up(&mut self, extend: bool) {
5295 let (row, col) = self.caret_pos();
5296 let goal = self.goal_col.unwrap_or(col);
5297 let target = match self.view {
5298 View::Source => match row.checked_sub(1) {
5299 Some(r) => row_col_to_offset(&self.source, r, goal),
5300 None => self.reachable_start(),
5301 },
5302 // A table's border rules are drawn but hold no caret, so Up steps
5303 // over them to the row that does.
5304 View::Wysiwyg => match self.vmap.navigable_above(row) {
5305 Some(r) => self.row_target(r, goal),
5306 None => self.reachable_start(),
5307 },
5308 };
5309 self.step_vertical(target, goal, extend);
5310 }
5311
5312 pub fn move_down(&mut self, extend: bool) {
5313 let (row, col) = self.caret_pos();
5314 let goal = self.goal_col.unwrap_or(col);
5315 let target = match self.view {
5316 View::Source => match self.source_row_below(row) {
5317 Some(r) => row_col_to_offset(&self.source, r, goal),
5318 None => self.reachable_end(),
5319 },
5320 View::Wysiwyg => match self.vmap.navigable_below(row) {
5321 Some(r) => self.row_target(r, goal),
5322 None => self.reachable_end(),
5323 },
5324 };
5325 self.step_vertical(target, goal, extend);
5326 }
5327
5328 /// Land a vertical motion at `target`, latching the `goal` column it aimed
5329 /// with so the rest of the run keeps aiming there.
5330 ///
5331 /// A motion with nowhere to go changes *nothing*, the goal column included:
5332 /// the latch used to run before the early return at the top of the document,
5333 /// so an Up that did nothing still armed a column, and the next Down aimed
5334 /// at one the caret had never been in.
5335 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5336 let before = self.caret;
5337 if target == before {
5338 return;
5339 }
5340 self.goal_col = Some(goal);
5341 self.move_to(target, extend);
5342 self.debug_assert_on_a_stop(before);
5343 }
5344
5345 /// The source line below `row`, or `None` when `row` is the last one. Lines
5346 /// are counted by newline, so a trailing one leaves a real, empty last line
5347 /// for the caret to sit on — the document ends below it, not on it.
5348 fn source_row_below(&self, row: usize) -> Option<usize> {
5349 let last = self.source.bytes().filter(|&b| b == b'\n').count();
5350 (row < last).then_some(row + 1)
5351 }
5352
5353 /// Where a vertical motion aiming at the `goal` column lands on visual row
5354 /// `r`: the column clamped to the row, mapped to its offset, then held
5355 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5356 /// column belongs to the row below, and a gutter's column 0 points at the
5357 /// block rather than at this row.
5358 fn row_target(&self, r: usize, goal: usize) -> usize {
5359 let (start, end) = self.row_bounds(r);
5360 self.vmap
5361 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5362 .clamp(start, end)
5363 }
5364
5365 /// The first and last offsets the caret can reach in the active view.
5366 ///
5367 /// Not the same span in both: the source view shows every byte, so it can
5368 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5369 /// sits below the first stop, and a document's trailing newline is drawn
5370 /// nowhere and so sits past the last.
5371 fn reachable_start(&self) -> usize {
5372 match self.view {
5373 View::Source => 0,
5374 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5375 }
5376 }
5377
5378 fn reachable_end(&self) -> usize {
5379 match self.view {
5380 View::Source => self.source.len(),
5381 View::Wysiwyg => self
5382 .vmap
5383 .stop_at_or_before(self.source.len())
5384 .unwrap_or(self.caret),
5385 }
5386 }
5387
5388 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5389 /// including the space a soft wrap ate off its end, which is drawn on this
5390 /// row however much the offset past it belongs to the next one.
5391 fn row_span(&self, r: usize) -> (usize, usize) {
5392 let start = self
5393 .vmap
5394 .row_start(r)
5395 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5396 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5397 (start.min(end), end)
5398 }
5399
5400 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5401 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5402 /// this row's last position is the one before it — the offset before the
5403 /// space the wrap ate, where the caret draws just past the row's last word
5404 /// and types there too.
5405 ///
5406 /// Aiming at the shared offset instead is what stalled End: it is the row's
5407 /// last *column*, so End pressed on the row reached it and then read back as
5408 /// the row below's start, where a second press ran on to that row's end and
5409 /// the next to the one after — End walking down the paragraph a row a press.
5410 fn row_bounds(&self, r: usize) -> (usize, usize) {
5411 let (start, end) = self.row_span(r);
5412 let wraps = self
5413 .vmap
5414 .navigable_below(r)
5415 .and_then(|b| self.vmap.row_start(b))
5416 .is_some_and(|off| off == end);
5417 match wraps {
5418 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5419 false => (start, end),
5420 }
5421 }
5422
5423 /// The `[start, end]` of the line Home and End aim at: the visual row in
5424 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5425 ///
5426 /// A soft-wrapped row is a line here, because it is one to the eye and the
5427 /// eye is what these keys are aimed by — a reader pressing End means the end
5428 /// of the line they can see. (`select_block_at` wants the opposite and reads
5429 /// the AST for it: a triple-click grabs the whole paragraph, however many
5430 /// rows it folds into.)
5431 fn line_bounds(&self) -> (usize, usize) {
5432 let (row, _) = self.caret_pos();
5433 match self.view {
5434 View::Source => {
5435 let start = line_start(&self.source, row);
5436 (start, line_end_from(&self.source, start))
5437 }
5438 View::Wysiwyg => self.row_bounds(row),
5439 }
5440 }
5441
5442 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5443 /// *drawn* — what a kill takes.
5444 ///
5445 /// The two part only at a soft wrap, over the space the wrap ate: the caret
5446 /// can't stand after it (that offset opens the row below, and End stopping
5447 /// there would walk), but it is on this row, and a kill that spared it would
5448 /// leave a double space behind where the row's text had been. Deleting it
5449 /// joins nothing — a wrap is drawn, not written.
5450 fn line_span(&self) -> (usize, usize) {
5451 let (row, _) = self.caret_pos();
5452 match self.view {
5453 View::Source => self.line_bounds(),
5454 View::Wysiwyg => self.row_span(row),
5455 }
5456 }
5457
5458 /// The first offset in `[start, end]` holding something other than
5459 /// whitespace, or `end` when the line holds nothing else — where Home aims.
5460 ///
5461 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5462 /// so a hidden delimiter is never taken for the line's first character (nor
5463 /// landed on), and the source view steps the source it is showing.
5464 fn first_non_space(&self, start: usize, end: usize) -> usize {
5465 let mut off = start;
5466 while off < end {
5467 if self.class_at(off) != Class::Space {
5468 return off;
5469 }
5470 off = match self.view {
5471 View::Source => next_boundary(&self.source, off),
5472 View::Wysiwyg => match self.vmap.stop_after(off) {
5473 Some(next) => next,
5474 None => return end,
5475 },
5476 };
5477 }
5478 end
5479 }
5480
5481 /// Home: to the first character on the line, or to column 0 when the caret
5482 /// is already on it — the two-press toggle every editor spells this way.
5483 /// The indentation is somewhere the caret has to be able to reach and almost
5484 /// never where a reader is headed, so it costs the second press.
5485 pub fn move_home(&mut self, extend: bool) {
5486 self.goal_col = None;
5487 let (start, end) = self.line_bounds();
5488 let text = self.first_non_space(start, end);
5489 let target = if self.caret == text { start } else { text };
5490 let before = self.caret;
5491 self.move_to(target, extend);
5492 self.debug_assert_on_a_stop(before);
5493 }
5494
5495 /// End: to the end of the line.
5496 pub fn move_end(&mut self, extend: bool) {
5497 self.goal_col = None;
5498 let (_, end) = self.line_bounds();
5499 let before = self.caret;
5500 self.move_to(end, extend);
5501 self.debug_assert_on_a_stop(before);
5502 }
5503
5504 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5505 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5506 /// when the caret isn't in a table, or is already in the last/first cell — the
5507 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5508 /// meaning everywhere else.
5509 pub fn cell_hop(&mut self, forward: bool) -> bool {
5510 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5511 return false;
5512 };
5513 // Flatten to document (row-major) order and step one cell either way.
5514 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5515 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5516 let next = if forward {
5517 i.checked_add(1)
5518 } else {
5519 i.checked_sub(1)
5520 };
5521 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5522 return false; // at the table's edge; leave Tab to the frontend
5523 };
5524 self.select_cell(start, end);
5525 true
5526 }
5527
5528 /// Move the caret to the cell directly above (`down == false`) or below in
5529 /// the same column, landing with the cell's whole content selected (see
5530 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5531 /// when the caret isn't in a table), so the frontend can fall through — the
5532 /// vertical counterpart of [`Self::cell_hop`].
5533 ///
5534 /// A ragged row that is short a column clamps to its last cell, so Down never
5535 /// falls out of the table over a gap the row above happened to have.
5536 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5537 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5538 return false;
5539 };
5540 let target = match down {
5541 true => r + 1,
5542 false if r == 0 => return false,
5543 false => r - 1,
5544 };
5545 let Some(row) = grid.get(target) else {
5546 return false;
5547 };
5548 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5549 return false;
5550 };
5551 self.select_cell(start, end);
5552 true
5553 }
5554
5555 /// The table containing `off` as a row-major grid of `(start, end)` cell
5556 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5557 /// isn't in a table. Read straight off the visual map's laid-out grid, so
5558 /// every cell (an empty one included, whose derived home twig gives no
5559 /// `content_span` for) is present and in the order Tab walks them.
5560 // Grid, row, column — three returns that only ever travel together, and a
5561 // named type for the pair of them would be read at one call site.
5562 #[allow(clippy::type_complexity)]
5563 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5564 for t in &self.vmap.tables {
5565 let mut pos = None;
5566 let grid: Vec<Vec<(usize, usize)>> = t
5567 .grid
5568 .iter()
5569 .enumerate()
5570 .map(|(r, row)| {
5571 row.cells
5572 .iter()
5573 .enumerate()
5574 .map(|(c, cell)| {
5575 if pos.is_none() && off >= cell.start && off <= cell.end {
5576 pos = Some((r, c));
5577 }
5578 (cell.start, cell.end)
5579 })
5580 .collect()
5581 })
5582 .collect();
5583 if let Some((r, c)) = pos {
5584 return Some((grid, r, c));
5585 }
5586 }
5587 None
5588 }
5589
5590 // ── table key policy ──────────────────────────────────────────────────────
5591 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5592 // as one policy every frontend shares, rather than each re-deriving it. Each
5593 // reports whether it acted *as a table key*; a `false` hands the key back to
5594 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5595 // everywhere else.
5596
5597 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5598 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5599 /// back and simply stays put at the very first cell. `false` when the caret
5600 /// isn't in a table.
5601 pub fn cell_tab(&mut self, forward: bool) -> bool {
5602 if !self.caret_in_table() {
5603 return false;
5604 }
5605 if self.cell_hop(forward) {
5606 return true;
5607 }
5608 // Off the last cell: grow the table by a row and step into its first
5609 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5610 if forward {
5611 self.append_row_and_enter(0);
5612 }
5613 true
5614 }
5615
5616 /// Return inside a table: drop to the cell below in the same column,
5617 /// appending a new row when the caret is already in the last one. `false`
5618 /// when the caret isn't in a table, so the frontend inserts a newline.
5619 pub fn cell_return(&mut self) -> bool {
5620 if !self.caret_in_table() {
5621 return false;
5622 }
5623 if self.cell_move_vertical(true) {
5624 return true;
5625 }
5626 // Already on the last row: grow one below and drop into the same column.
5627 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5628 self.append_row_and_enter(col);
5629 true
5630 }
5631
5632 /// Append a row below the caret's (last) row and land in `col` of it. The
5633 /// caret is in the last row, so twig's "insert below" makes the fresh row the
5634 /// table's new last — but twig re-spells the whole table, moving every byte,
5635 /// so the destination is read back from the rebuilt grid by the table's
5636 /// position (stable across a row insert), not from the pre-edit caret.
5637 fn append_row_and_enter(&mut self, col: usize) {
5638 let table = self.caret_table_index();
5639 self.table_insert_row(true);
5640 self.rebuild_map();
5641 let Some((start, end)) = table
5642 .and_then(|ti| self.vmap.tables.get(ti))
5643 .and_then(|t| t.grid.last())
5644 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5645 .map(|cell| (cell.start, cell.end))
5646 else {
5647 return;
5648 };
5649 self.select_cell(start, end);
5650 }
5651
5652 /// The index, among the document's tables, of the one the caret sits in —
5653 /// `None` when it's in none. Used to re-find a table after an edit re-spells
5654 /// it (a row insert leaves the table order unchanged).
5655 fn caret_table_index(&self) -> Option<usize> {
5656 let off = self.caret;
5657 self.vmap.tables.iter().position(|t| {
5658 t.grid
5659 .iter()
5660 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5661 })
5662 }
5663
5664 /// Shift+Return inside a table: insert a hard line break *within* the current
5665 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5666 /// table, so the frontend inserts an ordinary line break.
5667 ///
5668 /// A table row is a single source line, so the newline-spelled hard break
5669 /// can't live in a cell. twig spells the in-cell break the format's way
5670 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5671 /// break round-trips as structure the renderer reads back as a line — not the
5672 /// opaque raw HTML the old raw-splice left behind.
5673 ///
5674 /// Djot has no idiomatic in-cell break, so twig refuses it
5675 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5676 /// would render as the literal text `<br>`. The gesture is still *consumed*
5677 /// there — returning `false` would let the frontend insert a real newline,
5678 /// which splits the one-line row — it just leaves the cell unchanged and says
5679 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5680 ///
5681 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5682 /// have to be read together: djot is not the only `false`, and naming it in
5683 /// the message was already a guess that HTML — which spells the break as its
5684 /// own `<br>` — would have made wrong.
5685 pub fn cell_line_break(&mut self) -> bool {
5686 if !self.caret_in_table() {
5687 return false;
5688 }
5689 self.record_caret();
5690 match self.editor.insert_line_break(self.caret) {
5691 Ok(change) => {
5692 self.last_edit_kind = None;
5693 self.refresh();
5694 self.caret = change.new.end;
5695 self.anchor = None;
5696 self.goal_col = None;
5697 self.clamp_caret();
5698 self.dirty = self.source != self.clean_source;
5699 self.status = None;
5700 self.record_caret();
5701 }
5702 Err(twig::Error::UnsupportedFormat) => {
5703 self.status = Some(format!(
5704 "in-cell line breaks aren't supported in {}",
5705 self.format_name()
5706 ));
5707 }
5708 Err(_) => {}
5709 }
5710 true
5711 }
5712
5713 /// Rebuild the visual map at the width the last build used. A structural edit
5714 /// bumps the revision and swaps the source in, but leaves the *map* stale;
5715 /// when a single gesture edits and then moves over the result (Tab appending
5716 /// a row, then stepping into it), the move needs the map to already show the
5717 /// edit rather than waiting for the frontend's next frame.
5718 fn rebuild_map(&mut self) {
5719 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5720 self.build_map(wrap);
5721 }
5722
5723 /// Move the caret to the very start of the document (⌘↑ on macOS,
5724 /// Ctrl+Home on Windows/Linux).
5725 pub fn move_doc_start(&mut self, extend: bool) {
5726 self.goal_col = None;
5727 self.move_to(0, extend);
5728 }
5729
5730 /// Move the caret to the very end of the document (⌘↓ on macOS,
5731 /// Ctrl+End on Windows/Linux).
5732 pub fn move_doc_end(&mut self, extend: bool) {
5733 self.goal_col = None;
5734 let end = self.source.len();
5735 self.move_to(end, extend);
5736 }
5737
5738 /// Point the caret at the body cell `(row, col)` the mouse landed on —
5739 /// `col` being a cell of the terminal grid, which is what a display column
5740 /// is. A click on the far cell of a wide character lands at that
5741 /// character's start; the mapping's own doc-comments carry the rule.
5742 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5743 self.goal_col = None;
5744 let target = match self.view {
5745 View::Source => row_col_to_offset(&self.source, row, col),
5746 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5747 };
5748 let before = self.caret;
5749 self.move_to(target, extend);
5750 self.debug_assert_on_a_stop(before);
5751 }
5752
5753 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5754 /// screen if it has moved since the last frame, and never scroll past the
5755 /// last of `rows`.
5756 ///
5757 /// Only if it has *moved* — that's the whole point. Revealing the caret on
5758 /// every frame ties the viewport to it, and a scroll wheel that fights the
5759 /// caret for the viewport loses: the view snaps back the instant it tries to
5760 /// pass the caret's row, so the document can't be scrolled beyond what's
5761 /// already on screen. A caret move is the frontend's cue to follow; a scroll
5762 /// with the caret sitting still is the reader's cue to leave it alone.
5763 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5764 if self.drawn_caret != Some(self.caret) {
5765 if caret_row < self.scroll {
5766 self.scroll = caret_row;
5767 } else if height > 0 && caret_row >= self.scroll + height {
5768 self.scroll = caret_row + 1 - height;
5769 }
5770 self.drawn_caret = Some(self.caret);
5771 }
5772 self.scroll = self.scroll.min(rows.saturating_sub(1));
5773 }
5774
5775 /// The caret's screen position `(row, col)` in the active view's grid, with
5776 /// `col` a display column: the cell to draw the caret in, which on a line of
5777 /// `你好` or emoji is not the count of characters before it.
5778 pub fn caret_pos(&self) -> (usize, usize) {
5779 match self.view {
5780 View::Source => offset_to_row_col(&self.source, self.caret),
5781 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5782 }
5783 }
5784
5785 fn clamp_caret(&mut self) {
5786 if self.caret > self.source.len() {
5787 self.caret = self.source.len();
5788 }
5789 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5790 // any selection anchor) to the first rendered offset.
5791 let floor = self.caret_floor();
5792 if self.caret < floor {
5793 self.caret = floor;
5794 }
5795 if let Some(a) = self.anchor
5796 && a < floor
5797 {
5798 self.anchor = Some(floor);
5799 }
5800 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5801 self.caret -= 1;
5802 }
5803 }
5804}
5805
5806// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5807
5808// Left/right motion and backspace/delete step by *grapheme cluster*, not
5809// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5810// marks moves and deletes as the single character a user sees. Grapheme
5811// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5812
5813/// How an insert of `text` groups for undo: a single typed character folds into
5814/// the run of typing around it, while a newline or a multi-character insert is a
5815/// step of its own.
5816fn typed_edit_kind(text: &str) -> EditKind {
5817 if text.chars().take(2).count() == 1 && text != "\n" {
5818 EditKind::Insert
5819 } else {
5820 EditKind::Other
5821 }
5822}
5823
5824fn prev_boundary(s: &str, i: usize) -> usize {
5825 let mut cursor = GraphemeCursor::new(i, s.len(), true);
5826 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5827}
5828
5829fn next_boundary(s: &str, i: usize) -> usize {
5830 let mut cursor = GraphemeCursor::new(i, s.len(), true);
5831 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5832}
5833
5834// ── word boundaries ──────────────────────────────────────────────────────────
5835// The shared primitive behind word-wise motion, word deletion, and
5836// double-click-to-select-a-word. A "word" is a maximal run of one character
5837// class; whitespace and punctuation are their own classes, so motion skips
5838// cleanly between them the way native text fields do.
5839
5840#[derive(PartialEq, Eq, Clone, Copy)]
5841enum Class {
5842 Word,
5843 Space,
5844 Other,
5845}
5846
5847/// The source range of an inline node's own visible text — the part of it a
5848/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5849/// `None` for a node with no interior to empty (a `str`, a break).
5850///
5851/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5852/// one delimiter in from the span — the same place the renderer maps it to. A
5853/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5854/// against the source rather than trusted: a range guessed wrong here is text
5855/// deleted wrong.
5856fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5857 if let Some(span) = n.content_span.clone() {
5858 return Some(span);
5859 }
5860 match n.kind.as_str() {
5861 "verbatim" | "inline_math" => {
5862 let text = n.text.as_ref()?;
5863 let start = n.span.start + 1;
5864 let range = start..start + text.len();
5865 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
5866 }
5867 _ => None,
5868 }
5869}
5870
5871/// The `id` a node declares, or `None` for one that declares none — the
5872/// attribute djot writes for a `{#v1}` and mints for a heading.
5873///
5874/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
5875/// names nothing, so it reads as absent rather than as the empty string.
5876fn declared_id(n: &FlatNode) -> Option<&str> {
5877 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
5878}
5879
5880/// A heading's words reduced to the form a link fragment spells them in:
5881/// lowercase, runs of anything else collapsed to a single `-`, with none left
5882/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
5883///
5884/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
5885/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
5886/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
5887/// any other language is still a heading someone will link to. Underscores
5888/// survive for the same reason they do on the web: they are word characters
5889/// wherever identifiers are written.
5890fn slug(text: &str) -> String {
5891 let mut out = String::new();
5892 let mut pending = false;
5893 for c in text.chars() {
5894 if c.is_alphanumeric() || c == '_' {
5895 if pending && !out.is_empty() {
5896 out.push('-');
5897 }
5898 pending = false;
5899 out.extend(c.to_lowercase());
5900 } else {
5901 pending = true;
5902 }
5903 }
5904 out
5905}
5906
5907fn is_block_container(kind: &Kind) -> bool {
5908 matches!(
5909 kind,
5910 Kind::Doc
5911 | Kind::Section
5912 | Kind::BlockQuote
5913 | Kind::BulletList
5914 | Kind::OrderedList
5915 | Kind::TaskList
5916 | Kind::ListItem
5917 | Kind::TaskListItem
5918 // Every `container` — a directive in any of its three forms, or a
5919 // promoted HTML element. A *text* directive is really inline, so
5920 // claiming it here is a small overreach, and the deliberate one this
5921 // function's kind-only peer `is_inline_kind` documents: the pair is
5922 // consulted together, and answering "block container" for something
5923 // inline is what keeps an ancestor walk from stopping short of the
5924 // paragraph that actually holds it.
5925 | Kind::Container
5926 )
5927}
5928
5929/// The `[start, end)` byte range of the source line containing `off` (newline
5930/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
5931/// line between paragraphs).
5932fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
5933 let off = off.min(s.len());
5934 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
5935 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
5936 start..end
5937}
5938
5939/// How many leading bytes an outdent takes off `line`: a whole indent level
5940/// where the line has one, and whatever it has where it has less.
5941///
5942/// A leading tab counts as a level on its own. It's indentation some other
5943/// editor wrote, and one tab is one level everywhere it came from — measuring it
5944/// in spaces it doesn't contain would leave it untouchable.
5945fn outdent_width(line: &str, unit: usize) -> usize {
5946 if line.starts_with('\t') {
5947 return 1;
5948 }
5949 line.bytes().take(unit).take_while(|b| *b == b' ').count()
5950}
5951
5952/// A list marker found at the head of a line, together with everything before it
5953/// that a sibling line has to repeat.
5954///
5955/// The three offsets differ only inside a block quote, where `> - b` opens with
5956/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
5957/// `line_start == marker_start`, and `text` is the plain `" - "`.
5958#[derive(Clone, Debug)]
5959struct ListMarker {
5960 /// The line's first byte.
5961 line_start: usize,
5962 /// Where the marker proper begins, past any quote prefix. The offset to hand
5963 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
5964 marker_start: usize,
5965 /// `line_start` through the marker's trailing space — quote prefix, indent
5966 /// and bullet together, which is what the next item's line opens with.
5967 text: String,
5968}
5969
5970impl ListMarker {
5971 /// Where the item's content starts — one past the marker's trailing space.
5972 fn content_start(&self) -> usize {
5973 self.line_start + self.text.len()
5974 }
5975}
5976
5977fn classify(c: char) -> Class {
5978 if c == '_' || c.is_alphanumeric() {
5979 Class::Word
5980 } else if c.is_whitespace() {
5981 Class::Space
5982 } else {
5983 Class::Other
5984 }
5985}
5986
5987/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
5988/// skip any leading separators, then consume the following word run.
5989fn next_word(s: &str, i: usize) -> usize {
5990 let mut off = i;
5991 let mut in_word = false;
5992 for c in s[i..].chars() {
5993 if classify(c) == Class::Word {
5994 in_word = true;
5995 } else if in_word {
5996 break;
5997 }
5998 off += c.len_utf8();
5999 }
6000 off
6001}
6002
6003/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
6004/// skip separators walking left, then consume the preceding word run.
6005fn prev_word(s: &str, i: usize) -> usize {
6006 let mut off = i;
6007 let mut in_word = false;
6008 for c in s[..i].chars().rev() {
6009 if classify(c) == Class::Word {
6010 in_word = true;
6011 } else if in_word {
6012 break;
6013 }
6014 off -= c.len_utf8();
6015 }
6016 off
6017}
6018
6019/// The `[start, end)` run of same-class characters surrounding `off` — the
6020/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
6021/// the run ending there is used.
6022fn word_range_at(s: &str, off: usize) -> (usize, usize) {
6023 if s.is_empty() {
6024 return (0, 0);
6025 }
6026 let off = off.min(s.len());
6027 let reference = if off < s.len() {
6028 s[off..].chars().next()
6029 } else {
6030 s[..off].chars().next_back()
6031 };
6032 let Some(rc) = reference else {
6033 return (off, off);
6034 };
6035 let class = classify(rc);
6036
6037 let mut start = off;
6038 for c in s[..start].chars().rev() {
6039 if classify(c) == class {
6040 start -= c.len_utf8();
6041 } else {
6042 break;
6043 }
6044 }
6045 let mut end = off;
6046 for c in s[end..].chars() {
6047 if classify(c) == class {
6048 end += c.len_utf8();
6049 } else {
6050 break;
6051 }
6052 }
6053 (start, end)
6054}
6055
6056/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
6057/// the line's start — terminal cells, not characters, so the column names the
6058/// cell the caret is drawn in even on a line of `你好` or emoji.
6059fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
6060 let off = off.min(s.len());
6061 let mut row = 0;
6062 let mut line_start = 0;
6063 for (i, &b) in s.as_bytes().iter().enumerate() {
6064 if i >= off {
6065 break;
6066 }
6067 if b == b'\n' {
6068 row += 1;
6069 line_start = i + 1;
6070 }
6071 }
6072 (row, wysiwyg::text_width(&s[line_start..off]))
6073}
6074
6075/// The byte offset at display column `col` of `row` (clamped to that line's
6076/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
6077///
6078/// A column landing *inside* a character — the second cell of `你`, or any cell
6079/// but the first of an emoji — resolves to that character's start, which is the
6080/// column the caret would have been drawn at to begin with. So both cells of a
6081/// wide character mean the character, and every offset survives the round trip
6082/// out to a column and back. The walk steps by grapheme cluster for the same
6083/// reason the caret does: a cluster is the character, and the cells belong to it
6084/// rather than to the codepoints spelling it.
6085fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
6086 let start = line_start(s, row);
6087 let end = line_end_from(s, start);
6088 let mut off = start;
6089 let mut at = 0; // the display column `off` sits at
6090 while off < end {
6091 let next = next_boundary(s, off).min(end);
6092 let cells = wysiwyg::text_width(&s[off..next]);
6093 if at + cells > col {
6094 break; // `col` is one of this cluster's own cells
6095 }
6096 at += cells;
6097 off = next;
6098 }
6099 off
6100}
6101
6102fn line_start(s: &str, row: usize) -> usize {
6103 if row == 0 {
6104 return 0;
6105 }
6106 let mut r = 0;
6107 for (i, &b) in s.as_bytes().iter().enumerate() {
6108 if b == b'\n' {
6109 r += 1;
6110 if r == row {
6111 return i + 1;
6112 }
6113 }
6114 }
6115 s.len()
6116}
6117
6118fn line_end_from(s: &str, start: usize) -> usize {
6119 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
6120}
6121
6122/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
6123/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
6124/// twig applies writing the mark out, so the toolbar can light the same button
6125/// that made the node.
6126///
6127/// `None` for every other kind, including the inline nodes that aren't marks at
6128/// all (`str`, `link`, `image`, the math and break kinds): they're things a
6129/// caret stands in, not formatting a button toggles.
6130fn inline_kind(kind: &Kind) -> Option<InlineKind> {
6131 Some(match kind {
6132 Kind::Strong => InlineKind::Strong,
6133 Kind::Emph => InlineKind::Emph,
6134 Kind::Verbatim => InlineKind::Verbatim,
6135 Kind::Mark => InlineKind::Mark,
6136 Kind::Superscript => InlineKind::Superscript,
6137 Kind::Subscript => InlineKind::Subscript,
6138 Kind::Insert => InlineKind::Insert,
6139 Kind::Delete => InlineKind::Delete,
6140 _ => return None,
6141 })
6142}
6143
6144/// A watermark for a file's contents (see `Doc::disk_hash`).
6145///
6146/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
6147/// watermark is compared only against one taken by the same process moments
6148/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
6149/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
6150fn hash_bytes(bytes: &[u8]) -> u64 {
6151 use std::hash::{Hash, Hasher};
6152 let mut h = std::collections::hash_map::DefaultHasher::new();
6153 bytes.hash(&mut h);
6154 h.finish()
6155}
6156
6157#[cfg(feature = "fs")]
6158fn detect_format(path: &Path) -> Result<Format> {
6159 let ext = path
6160 .extension()
6161 .and_then(|e| e.to_str())
6162 .unwrap_or("")
6163 .to_ascii_lowercase();
6164 Ok(match ext.as_str() {
6165 "dj" | "djot" => Format::Djot,
6166 "md" | "markdown" => Format::Markdown,
6167 "xml" => Format::Xml,
6168 "html" | "htm" => Format::Html,
6169 other => return Err(anyhow!("unknown document extension: .{other}")),
6170 })
6171}
6172
6173#[cfg(test)]
6174mod tests {
6175 use super::*;
6176
6177 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
6178 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
6179 /// without one is a view no user is ever in.
6180 fn doc_in(view: View, name: &str, body: &str) -> Doc {
6181 // The fixture name doubles as the temp file's, so two tests picking the
6182 // same one raced under the parallel runner and read each other's body —
6183 // a green suite proving the wrong thing. The counter makes that
6184 // unreachable rather than asking every future caller to notice.
6185 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6186 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6187 let mut p = std::env::temp_dir();
6188 p.push(format!("leaf_test_{name}_{seq}.md"));
6189 std::fs::write(&p, body).unwrap();
6190 let mut d = Doc::open(p).unwrap();
6191 d.view = view;
6192 if view == View::Wysiwyg {
6193 d.build_visual(80);
6194 }
6195 d
6196 }
6197
6198 // Source-view document for the source-behaviour tests. `Doc::open` now
6199 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
6200 // `wysiwyg_doc` builds the rich-text variant on top of this.
6201 fn doc_with(name: &str, body: &str) -> Doc {
6202 doc_in(View::Source, name, body)
6203 }
6204
6205 /// Every visual row's drawn text — what the reader actually sees, which is
6206 /// the only thing the reveal preference is supposed to change.
6207 fn drawn_rows(d: &Doc) -> Vec<String> {
6208 d.vmap
6209 .rows
6210 .iter()
6211 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6212 .collect()
6213 }
6214
6215 /// Put the caret at the first byte of `needle` and rebuild, so the row under
6216 /// it becomes the revealed line.
6217 fn caret_at(d: &mut Doc, needle: &str) {
6218 d.caret = d.source.find(needle).expect("needle in source");
6219 d.build_visual(80);
6220 }
6221
6222 #[test]
6223 fn blockquote_after_a_list_is_not_bulleted() {
6224 // twig nests a following top-level block quote under the `bullet_list`
6225 // (a direct child, not a `list_item`). The map must render it de-nested —
6226 // `│ quote`, never `• │ quote` — with a blank separator, like any block
6227 // that follows a list. Regression for the "combined list + blockquote" bug.
6228 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
6229 d.build_visual(80);
6230 let rows: Vec<String> = d
6231 .vmap
6232 .rows
6233 .iter()
6234 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6235 .collect();
6236 assert!(
6237 rows.iter().any(|r| r == "│ quote"),
6238 "block quote should render on its own gutter, got rows: {rows:?}"
6239 );
6240 assert!(
6241 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
6242 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
6243 );
6244 }
6245
6246 // ── the map is built at most once per (revision, wrap) ───────────────────
6247 //
6248 // A frontend repaints for reasons that have nothing to do with the text — a
6249 // blinking caret, a scroll — and rebuilding the map is O(document). These
6250 // pin *that the cache fires*, which a passing suite can't tell you: a cache
6251 // that never hits is invisible to every other test in this file.
6252 //
6253 // The probe is to wreck the built map and ask for it again. A rebuild
6254 // repairs it; a cache hit hands the wreckage straight back. Nothing else
6255 // can distinguish the two from outside.
6256
6257 #[test]
6258 fn a_rebuild_with_nothing_changed_reuses_the_map() {
6259 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6260 d.build_visual(80);
6261 assert!(!d.vmap.rows.is_empty());
6262 d.vmap.rows.clear(); // wreck it
6263 d.build_visual(80);
6264 assert!(
6265 d.vmap.rows.is_empty(),
6266 "the map was rebuilt though nothing changed — the cache never fired"
6267 );
6268 }
6269
6270 #[test]
6271 fn an_edit_rebuilds_the_map() {
6272 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6273 d.build_visual(80);
6274 let before = d.revision();
6275 d.vmap.rows.clear();
6276 d.insert("x");
6277 d.build_visual(80);
6278 assert!(d.revision() > before, "an edit must move the revision");
6279 assert!(
6280 !d.vmap.rows.is_empty(),
6281 "an edited document must not paint from a stale map"
6282 );
6283 }
6284
6285 #[test]
6286 fn a_width_change_rebuilds_the_map() {
6287 // The map is a function of the wrap width too, so a resize is a miss
6288 // even though the text is untouched.
6289 let mut d = doc_in(
6290 View::Wysiwyg,
6291 "cache_width",
6292 "one two three four five six\n",
6293 );
6294 d.build_visual(80);
6295 d.vmap.rows.clear();
6296 d.build_visual(12);
6297 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6298 // And the unwrapped map is its own key, not the same as any width.
6299 d.vmap.rows.clear();
6300 d.build_visual_unwrapped();
6301 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6302 }
6303
6304 #[test]
6305 fn a_motion_does_not_rebuild_the_map() {
6306 // The whole point: moving the caret changes nothing the map is built
6307 // from. If a motion bumped the revision, every arrow key would cost a
6308 // full rebuild and the cache would be worthless.
6309 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6310 d.build_visual(80);
6311 let rev = d.revision();
6312 d.move_right(false);
6313 d.move_right(true);
6314 d.move_down(false);
6315 assert_eq!(d.revision(), rev, "a motion must not move the revision");
6316 d.vmap.rows.clear();
6317 d.build_visual(80);
6318 assert!(
6319 d.vmap.rows.is_empty(),
6320 "a motion should not rebuild the map"
6321 );
6322 }
6323
6324 #[test]
6325 fn saving_does_not_rebuild_the_map() {
6326 // Saving changes `dirty`, not the text.
6327 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6328 d.insert("x");
6329 d.build_visual(80);
6330 let rev = d.revision();
6331 d.save();
6332 assert_eq!(d.revision(), rev, "a save must not move the revision");
6333 assert!(!d.dirty, "the save should have cleaned the document");
6334 }
6335
6336 #[test]
6337 fn a_reload_rebuilds_the_map() {
6338 // Reload replaces the text without going through `refresh`, so it has to
6339 // move the revision itself — else the editor paints the old file.
6340 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6341 d.build_visual(80);
6342 let rev = d.revision();
6343 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6344 d.reload();
6345 assert!(d.revision() > rev, "a reload must move the revision");
6346 d.build_visual(80);
6347 let text: String = d
6348 .vmap
6349 .rows
6350 .iter()
6351 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6352 .collect();
6353 assert!(
6354 text.contains("wholly new"),
6355 "the reloaded text should be on screen, got {text:?}"
6356 );
6357 }
6358
6359 // ── golden-case harness ──────────────────────────────────────────────────
6360 // The pattern the whole parity suite can reuse: write a fixture with the
6361 // caret marked by `|`, run one action, and compare the rendered result —
6362 // also caret-marked — against the expected string. One readable line per
6363 // behavior, and it exercises the exact `Doc` ops both frontends call.
6364
6365 /// Split a `|`-marked fixture into `(source, caret_offset)`.
6366 fn parse_caret(marked: &str) -> (String, usize) {
6367 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6368 (marked.replacen('|', "", 1), caret)
6369 }
6370
6371 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6372 /// selection) so a result reads like the fixtures.
6373 fn render_caret(d: &Doc) -> String {
6374 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6375 // so the caret always renders inside its own selection.
6376 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6377 if let Some((s, e)) = d.selection() {
6378 marks.push((s, 0, '['));
6379 marks.push((e, 2, ']'));
6380 }
6381 // Insert right-to-left: descending offset, then descending rank.
6382 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6383 let mut out = d.source.clone();
6384 for (at, _, ch) in marks {
6385 out.insert(at, ch);
6386 }
6387 out
6388 }
6389
6390 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6391 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6392 golden_in(View::Source, name, marked, action)
6393 }
6394
6395 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6396 /// the same fixture has to read the same way in both.
6397 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6398 let (src, caret) = parse_caret(marked);
6399 let mut d = doc_in(view, name, &src);
6400 d.caret = caret;
6401 action(&mut d);
6402 render_caret(&d)
6403 }
6404
6405 #[test]
6406 fn word_motion_walks_word_by_word() {
6407 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6408 assert_eq!(
6409 g("hello wor|ld", |d| d.move_word_left(false)),
6410 "hello |world"
6411 );
6412 assert_eq!(
6413 g("hello| world", |d| d.move_word_left(false)),
6414 "|hello world"
6415 );
6416 assert_eq!(
6417 g("hel|lo world", |d| d.move_word_right(false)),
6418 "hello| world"
6419 );
6420 assert_eq!(
6421 g("hello| world", |d| d.move_word_right(false)),
6422 "hello world|"
6423 );
6424 // Punctuation is its own class, so motion stops at the boundary.
6425 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6426 }
6427
6428 #[test]
6429 fn word_motion_extends_the_selection_when_asked() {
6430 assert_eq!(
6431 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6432 "hello [world|]"
6433 );
6434 }
6435
6436 #[test]
6437 fn delete_word_removes_a_whole_word() {
6438 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6439 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6440 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6441 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6442 }
6443
6444 // ── Home / End ───────────────────────────────────────────────────────────
6445
6446 #[test]
6447 fn home_toggles_between_the_line_s_text_and_its_margin() {
6448 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6449 // indent to the markup it spells everywhere it means one, so the fixture
6450 // with whitespace left to walk is a code block, which is verbatim.
6451 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6452 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
6453 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
6454 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
6455 // A line with no indentation has one place to go, so the toggle is a
6456 // no-op rather than a trip to nowhere.
6457 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6458 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6459
6460 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
6461 let indent = d.source.find(" indented").unwrap();
6462 d.caret = indent + 6; // inside "indented"
6463 d.move_home(false);
6464 assert_eq!(
6465 d.caret,
6466 indent + 4,
6467 "wysiwyg: Home aims at the code line's text"
6468 );
6469 d.move_home(false);
6470 assert_eq!(
6471 d.caret, indent,
6472 "wysiwyg: the second press takes the indent"
6473 );
6474 d.move_home(false);
6475 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6476 }
6477
6478 #[test]
6479 fn end_takes_the_line_the_view_is_showing() {
6480 // The line differs by view for the same document, and that is the point:
6481 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6482 // as a space on one row and the source view as two lines.
6483 let mut d = doc_with("end_src", "one two\nthree\n");
6484 d.caret = 1;
6485 d.move_end(false);
6486 assert_eq!(d.caret, 7, "source: the end of the source line");
6487
6488 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6489 d.caret = 1;
6490 d.move_end(false);
6491 assert_eq!(
6492 d.caret, 13,
6493 "wysiwyg: the end of the row, soft break and all"
6494 );
6495 }
6496
6497 #[test]
6498 fn home_and_end_extend_the_selection_when_asked() {
6499 for (view, tag) in VIEWS {
6500 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6501 d.caret = 6;
6502 d.move_end(true);
6503 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6504 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6505 d.caret = 6;
6506 d.move_home(true);
6507 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6508 }
6509 }
6510
6511 // ── kill to the line's start / end ───────────────────────────────────────
6512
6513 #[test]
6514 fn kill_to_the_line_start_and_end_in_both_views() {
6515 for (view, tag) in VIEWS {
6516 // The gap that reads as a paragraph break in each view: the source
6517 // view's lines are the renderer's rows only where the source says so.
6518 let gap = if view == View::Source { "\n" } else { "\n\n" };
6519 let mut d = doc_in(
6520 view,
6521 &format!("kill_end_{tag}"),
6522 &format!("one two{gap}three\n"),
6523 );
6524 d.caret = 3;
6525 d.delete_to_line_end();
6526 assert_eq!(
6527 d.source,
6528 format!("one{gap}three\n"),
6529 "{tag}: ^K to the line's end"
6530 );
6531 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6532
6533 let mut d = doc_in(
6534 view,
6535 &format!("kill_start_{tag}"),
6536 &format!("one two{gap}three\n"),
6537 );
6538 d.caret = 7; // the end of the first line
6539 d.delete_to_line_start();
6540 assert_eq!(
6541 d.source,
6542 format!("{gap}three\n"),
6543 "{tag}: ⌘⌫ to the line's start"
6544 );
6545 assert_eq!(d.caret, 0, "{tag}");
6546 }
6547 }
6548
6549 #[test]
6550 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6551 // The decision: at the boundary both kills do nothing, rather than
6552 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6553 // at a soft wrap as often as at a newline, where there is nothing
6554 // written to delete — and a source newline is only half of the blank
6555 // line between two paragraphs, so taking it leaves a soft break rather
6556 // than the join it looks like. Backspace and Delete are the keys for it.
6557 for (view, tag) in VIEWS {
6558 let gap = if view == View::Source { "\n" } else { "\n\n" };
6559 let src = format!("one{gap}three\n");
6560 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6561 d.caret = 3; // the end of "one"
6562 d.delete_to_line_end();
6563 assert_eq!(
6564 d.source, src,
6565 "{tag}: ^K at the line's end joined it to the next"
6566 );
6567
6568 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6569 d.caret = 3 + gap.len(); // the start of "three"
6570 d.delete_to_line_start();
6571 assert_eq!(
6572 d.source, src,
6573 "{tag}: ⌘⌫ at the line's start joined it to the last"
6574 );
6575 }
6576 }
6577
6578 #[test]
6579 fn a_kill_takes_the_selection_when_there_is_one() {
6580 // What every other delete here does with one, so these two as well.
6581 for (view, tag) in VIEWS {
6582 for (name, kill) in [
6583 (
6584 "end",
6585 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6586 ),
6587 ("start", |d: &mut Doc| d.delete_to_line_start()),
6588 ] {
6589 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
6590 d.anchor = Some(4);
6591 d.caret = 7; // "two"
6592 kill(&mut d);
6593 assert_eq!(
6594 d.source, "one three\n",
6595 "{tag}: {name} ignored the selection"
6596 );
6597 assert_eq!(d.selection(), None, "{tag}: {name}");
6598 }
6599 }
6600 }
6601
6602 #[test]
6603 fn a_kill_takes_the_markup_it_empties_with_it() {
6604 // The same hazard a word-delete has: a WYSIWYG range covers what the
6605 // user can see, which for `**bold**` is the word and never the
6606 // delimiters, so a kill that stopped at the text would leave `a ****` —
6607 // markup wrapped around nothing.
6608 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
6609 d.caret = d.source.find("bold").unwrap();
6610 d.delete_to_line_end();
6611 assert_eq!(d.source, "a \n");
6612 }
6613
6614 #[test]
6615 fn a_kill_is_undone_in_one_step() {
6616 for (view, tag) in VIEWS {
6617 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6618 d.caret = 3;
6619 d.delete_to_line_end();
6620 assert_eq!(d.source, "one\n", "{tag}");
6621 d.undo();
6622 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6623 }
6624 }
6625
6626 #[test]
6627 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6628 // Regression: triple-click used move_home/move_end over visual rows, so
6629 // it only worked on a paragraph's first row (a wrap-boundary offset maps
6630 // to the earlier row). select_block_at reads the AST, so every offset in
6631 // the paragraph selects the whole thing.
6632 let body = "one two three four five six seven eight\n";
6633 let mut d = doc_with("sel_block", body);
6634 d.view = View::Wysiwyg;
6635 d.build_visual(12); // force the paragraph to wrap into several rows
6636 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6637 let para = (0, "one two three four five six seven eight".len());
6638 for off in [0usize, 8, 19, 28, 38] {
6639 d.caret = 0;
6640 d.anchor = None;
6641 d.select_block_at(off);
6642 assert_eq!(
6643 d.selection(),
6644 Some(para),
6645 "offset {off} should select the paragraph"
6646 );
6647 }
6648 }
6649
6650 #[test]
6651 fn select_block_uses_content_span_for_a_heading() {
6652 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6653 d.select_block_at(4); // inside "Title"
6654 // content_span excludes the "# " marker.
6655 assert_eq!(d.selected_text(), Some("Title"));
6656 d.select_block_at(10); // inside "body"
6657 assert_eq!(d.selected_text(), Some("body"));
6658 }
6659
6660 #[test]
6661 fn select_all_spans_the_document() {
6662 let mut d = doc_with("sel_all", "abc\n\ndef\n");
6663 d.select_all();
6664 assert_eq!(d.selection(), Some((0, d.source.len())));
6665 }
6666
6667 #[test]
6668 fn select_word_at_picks_the_surrounding_word() {
6669 let mut d = doc_with("sel_word", "hello world\n");
6670 d.select_word_at(8); // inside "world"
6671 assert_eq!(d.selection(), Some((6, 11)));
6672 // Double-clicking at end-of-word still grabs the word to its left.
6673 d.select_word_at(5); // the space between the words
6674 assert_eq!(d.selection(), Some((5, 6)));
6675 }
6676
6677 #[test]
6678 fn word_helpers_respect_utf8_boundaries() {
6679 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6680 assert_eq!(
6681 golden("utf8", "|café ok", |d| d.move_word_right(false)),
6682 "café| ok"
6683 );
6684 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6685 }
6686
6687 #[test]
6688 fn typing_inserts_at_the_caret_and_advances_it() {
6689 let mut d = doc_with("type", "hello\n");
6690 d.insert("Hi ");
6691 assert_eq!(d.source, "Hi hello\n");
6692 assert_eq!(d.caret, 3);
6693 assert!(d.dirty);
6694 }
6695
6696 #[test]
6697 fn backspace_deletes_the_char_before_the_caret() {
6698 let mut d = doc_with("bs", "hello\n");
6699 d.caret = 3; // after "hel"
6700 d.backspace();
6701 assert_eq!(d.source, "helo\n");
6702 assert_eq!(d.caret, 2);
6703 }
6704
6705 #[test]
6706 fn typing_replaces_the_selection() {
6707 let mut d = doc_with("replace", "a word b\n");
6708 d.anchor = Some(2);
6709 d.caret = 6; // "word" selected
6710 d.insert("X");
6711 assert_eq!(d.source, "a X b\n");
6712 assert_eq!(d.caret, 3);
6713 assert_eq!(d.anchor, None);
6714 }
6715
6716 #[test]
6717 fn toggle_bold_wraps_then_unwraps_the_selection() {
6718 let mut d = doc_with("bold", "a word b\n");
6719 d.anchor = Some(2);
6720 d.caret = 6;
6721 d.toggle(InlineKind::Strong);
6722 assert_eq!(d.source, "a **word** b\n");
6723 // The toggled region stays selected, so a second toggle reverses it.
6724 d.toggle(InlineKind::Strong);
6725 assert_eq!(d.source, "a word b\n");
6726 d.toggle(InlineKind::Strong);
6727 assert_eq!(d.source, "a **word** b\n");
6728 }
6729
6730 #[test]
6731 fn toggle_code_wraps_then_unwraps_the_selection() {
6732 let mut d = doc_with("code_rt", "a word b\n");
6733 d.anchor = Some(2);
6734 d.caret = 6;
6735 d.toggle(InlineKind::Verbatim);
6736 assert_eq!(d.source, "a `word` b\n");
6737 d.toggle(InlineKind::Verbatim);
6738 assert_eq!(d.source, "a word b\n");
6739 }
6740
6741 #[test]
6742 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6743 // ⌘b at a bare caret, then type: the text comes out bold with no
6744 // selection ever made — the word-processor "start bold here" gesture.
6745 let mut d = doc_with("sticky_wrap", "xy\n");
6746 d.caret = 1; // between x and y
6747 d.toggle(InlineKind::Strong);
6748 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6749 d.insert("A");
6750 assert_eq!(d.source, "x**A**y\n");
6751 }
6752
6753 #[test]
6754 fn sticky_bold_lights_the_toolbar_before_any_typing() {
6755 // The button must light the instant ⌘b is pressed, or the mode is
6756 // invisible until the first character lands.
6757 let mut d = doc_with("sticky_light", "xy\n");
6758 d.caret = 1;
6759 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6760 d.toggle(InlineKind::Strong);
6761 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6762 }
6763
6764 #[test]
6765 fn sticky_bold_toggled_off_types_normally_again() {
6766 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6767 // in the flow of typing, the exact sequence the user described.
6768 let mut d = doc_with("sticky_off", "\n");
6769 d.caret = 0;
6770 d.toggle(InlineKind::Strong);
6771 d.insert("a");
6772 d.insert("b"); // continues inside the run, no re-arming
6773 assert_eq!(d.source, "**ab**\n");
6774 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6775 d.insert("c");
6776 assert_eq!(d.source, "**ab**c\n");
6777 }
6778
6779 #[test]
6780 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6781 // Once a mark is realised the caret sits inside the run, so plain typing
6782 // extends it rather than starting a second, adjacent bold span.
6783 let mut d = doc_with("sticky_cont", "\n");
6784 d.caret = 0;
6785 d.toggle(InlineKind::Emph);
6786 d.insert("h");
6787 d.insert("i");
6788 assert_eq!(d.source, "*hi*\n");
6789 }
6790
6791 #[test]
6792 fn moving_the_caret_disarms_a_sticky_mark() {
6793 // Arming a mark and then moving away must not style text elsewhere.
6794 let mut d = doc_with("sticky_disarm", "xy\n");
6795 d.caret = 0;
6796 d.toggle(InlineKind::Strong);
6797 d.move_right(false); // caret 0 → 1, disarms
6798 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6799 d.insert("A");
6800 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6801 }
6802
6803 #[test]
6804 fn stacked_sticky_marks_apply_together() {
6805 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6806 let mut d = doc_with("sticky_stack", "\n");
6807 d.caret = 0;
6808 d.toggle(InlineKind::Strong);
6809 d.toggle(InlineKind::Emph);
6810 d.insert("x");
6811 // Land the caret on the styled character and confirm both marks are live.
6812 d.anchor = Some(d.source.find('x').unwrap());
6813 d.caret = d.anchor.unwrap() + 1;
6814 let marks = d.active_inline_marks();
6815 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6816 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6817 }
6818
6819 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6820
6821 #[test]
6822 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6823 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6824 // The space inside the run made `**bold **`, which is *not* bold — four
6825 // literal asterisks — so the rich view drew them, correctly and
6826 // uselessly, until the next character happened to close the run again.
6827 let mut d = wysiwyg_doc("edge_typing", "a \n");
6828 d.caret = 2;
6829 d.toggle(InlineKind::Strong);
6830 for c in "bold".chars() {
6831 d.insert(&c.to_string());
6832 }
6833 assert_eq!(d.source, "a **bold**\n");
6834 d.insert(" ");
6835 assert_eq!(
6836 d.source, "a **bold** \n",
6837 "the space belongs outside the run"
6838 );
6839 assert!(
6840 d.active_inline_marks().contains(InlineKind::Strong),
6841 "bold is still what's being typed, so the button stays lit"
6842 );
6843 // What the writer is looking at while all this happens: their words.
6844 d.build_visual(80);
6845 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6846 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6847 for c in "hey".chars() {
6848 d.insert(&c.to_string());
6849 }
6850 assert_eq!(
6851 d.source, "a **bold hey**\n",
6852 "one bold phrase, not two runs"
6853 );
6854 }
6855
6856 #[test]
6857 fn typing_past_a_space_can_still_leave_the_bold_behind() {
6858 // The other half: the marks stay armed across the space, so ⌘b turns
6859 // them off again there and the next word is plain — the run isn't
6860 // rejoined by a caret that was told not to.
6861 let mut d = wysiwyg_doc("edge_shed", "\n");
6862 d.caret = 0;
6863 d.toggle(InlineKind::Strong);
6864 for c in "bold ".chars() {
6865 d.insert(&c.to_string());
6866 }
6867 assert_eq!(d.source, "**bold** \n");
6868 d.toggle(InlineKind::Strong);
6869 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6870 d.insert("x");
6871 assert_eq!(d.source, "**bold** x\n");
6872 }
6873
6874 #[test]
6875 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
6876 // ⌘b and then a space before any word: the space is not marked (nothing
6877 // is), and the word after it is.
6878 let mut d = wysiwyg_doc("edge_space_first", "a\n");
6879 d.caret = 1;
6880 d.toggle(InlineKind::Strong);
6881 d.insert(" ");
6882 assert_eq!(d.source, "a \n");
6883 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6884 d.insert("b");
6885 assert_eq!(d.source, "a **b**\n");
6886 }
6887
6888 #[test]
6889 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
6890 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
6891 d.caret = 8; // the caret's home at the end of the run's text
6892 d.insert(" ");
6893 assert_eq!(
6894 d.source, "x **bold** \n",
6895 "the space lands past the delimiters"
6896 );
6897 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
6898
6899 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
6900 d.caret = 4; // in front of the "b"
6901 d.insert(" ");
6902 assert_eq!(d.source, "x **bold** y\n");
6903 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
6904 }
6905
6906 #[test]
6907 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
6908 // Backspace over the last letter of a bold phrase.
6909 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
6910 d.caret = 10; // past the "h"
6911 d.backspace();
6912 assert_eq!(d.source, "a **bold** \n");
6913 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
6914 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6915 d.insert("x");
6916 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
6917 }
6918
6919 #[test]
6920 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
6921 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
6922 // mark, which is only text. The marks live on in the caret instead.
6923 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
6924 d.caret = 5;
6925 d.backspace();
6926 assert_eq!(d.source, "a c\n");
6927 assert!(d.active_inline_marks().contains(InlineKind::Strong));
6928 d.insert("x");
6929 assert_eq!(d.source, "a **x** c\n");
6930 }
6931
6932 #[test]
6933 fn typing_over_a_whole_bold_word_keeps_it_bold() {
6934 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
6935 d.anchor = Some(4);
6936 d.caret = 8; // the word, not its delimiters
6937 d.insert("x");
6938 assert_eq!(d.source, "a **x** c\n");
6939 }
6940
6941 #[test]
6942 fn a_code_span_keeps_the_space_it_is_given() {
6943 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
6944 // is still verbatim, so nothing is re-spelt. The repair asks the parser
6945 // rather than a table of kinds, and this is the answer it gets.
6946 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
6947 d.caret = 7;
6948 d.insert(" ");
6949 assert_eq!(d.source, "a `code ` c\n");
6950 }
6951
6952 #[test]
6953 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
6954 // A run's closing delimiter has a caret home on each side of it, one
6955 // column apart on screen — and a plain ← off the space after a bold word
6956 // lands on the outer one. The character drawn behind the caret there is
6957 // still the last letter of the phrase, so that is what Backspace takes;
6958 // the byte behind it is a `*` nobody can see.
6959 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
6960 d.caret = 9;
6961 d.move_left(false);
6962 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
6963 d.backspace();
6964 assert_eq!(
6965 d.source, "**bol** x\n",
6966 "a letter of the phrase, not its `*`"
6967 );
6968 assert_eq!(d.caret, 5);
6969
6970 // And the mirror in front of the opening delimiter, where Delete's
6971 // character is the first letter of the run.
6972 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
6973 d.caret = 1;
6974 d.delete_forward();
6975 assert_eq!(d.source, "x**old**\n");
6976 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
6977 }
6978
6979 #[test]
6980 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
6981 // The byte beside the caret at either edge of a bold word is a `*` the
6982 // rich view draws nothing for. Taking it is not the character delete the
6983 // key was pressed for — it unspells the run and puts a literal asterisk
6984 // on screen (`a *bold** c`). The visible character is the one that goes.
6985 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
6986 d.caret = 4; // in front of the "b"
6987 d.backspace();
6988 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
6989
6990 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
6991 d.caret = 8; // past the "d"
6992 d.delete_forward();
6993 assert_eq!(d.source, "a **bold**c\n");
6994 assert_eq!(d.caret, 8, "and the caret stays inside the run");
6995 d.insert("x");
6996 assert_eq!(d.source, "a **boldx**c\n");
6997
6998 // A code span's backticks are hidden the same way, so they are covered
6999 // by the same rule and not by a list of kinds.
7000 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
7001 d.caret = 3;
7002 d.backspace();
7003 assert_eq!(d.source, "a`code` c\n");
7004 }
7005
7006 #[test]
7007 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
7008 // The asterisks are on the screen there and the caret can stand between
7009 // them, so a delete takes exactly the byte it is aimed at.
7010 let mut d = doc_with("edge_open_src", "a **bold** c\n");
7011 d.caret = 4;
7012 d.backspace();
7013 assert_eq!(d.source, "a *bold** c\n");
7014
7015 let mut d = doc_with("edge_close_src", "a **bold** c\n");
7016 d.caret = 8;
7017 d.delete_forward();
7018 assert_eq!(d.source, "a **bold* c\n");
7019 }
7020
7021 #[test]
7022 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
7023 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
7024 // The space had stepped outside the run (the mark-edge rule), taking the
7025 // caret with it, so the delete put it back down on the far side of the
7026 // closing `**` — one place on screen, and the wrong side of it. Typing
7027 // came out plain and the toolbar went dark, with nothing to see.
7028 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
7029 d.caret = 0;
7030 d.toggle(InlineKind::Strong);
7031 for c in "bold".chars() {
7032 d.insert(&c.to_string());
7033 }
7034 d.insert(" ");
7035 assert_eq!(d.source, "**bold** \n");
7036 d.backspace();
7037 assert_eq!(
7038 d.source, "**bold**\n",
7039 "the space goes, the delimiters stay"
7040 );
7041 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
7042 assert!(
7043 d.active_inline_marks().contains(InlineKind::Strong),
7044 "so the button is still lit"
7045 );
7046 d.insert("x");
7047 assert_eq!(
7048 d.source, "**boldx**\n",
7049 "and the next character is still bold"
7050 );
7051 }
7052
7053 #[test]
7054 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
7055 // What the stranded caret did next: the byte behind it was the closing
7056 // `*`, so a second press took that instead of a letter — `**bold*`, the
7057 // styling gone and an asterisk on the screen where the word had been.
7058 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
7059 d.caret = 0;
7060 d.toggle(InlineKind::Strong);
7061 for c in "bold ".chars() {
7062 d.insert(&c.to_string());
7063 }
7064 assert_eq!(d.source, "**bold** \n");
7065 d.backspace();
7066 d.backspace();
7067 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
7068 assert_eq!(d.caret, 5);
7069 }
7070
7071 #[test]
7072 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
7073 // `***both***` closes two runs with one stack of asterisks: the caret has
7074 // to walk in through all of them, or it lands between the emph and the
7075 // strong and types half-marked.
7076 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
7077 d.caret = 11;
7078 d.backspace();
7079 assert_eq!(d.source, "***both***\n");
7080 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
7081 d.insert("x");
7082 assert_eq!(d.source, "***bothx***\n");
7083 }
7084
7085 #[test]
7086 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
7087 // The settle only moves a caret a run actually closed over. Ordinary
7088 // deletes — inside a run, or in plain prose — are untouched.
7089 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
7090 d.caret = 8;
7091 d.backspace();
7092 assert_eq!(d.source, "a **bol** c\n");
7093 assert_eq!(d.caret, 7);
7094
7095 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
7096 d.caret = 5;
7097 d.backspace();
7098 assert_eq!(d.source, "plai\n");
7099 assert_eq!(d.caret, 4);
7100 }
7101
7102 #[test]
7103 fn the_source_view_leaves_a_delete_where_it_landed() {
7104 // The delimiters are on the screen there, so the offset past them is a
7105 // place the caret can be seen to be — nothing to settle.
7106 let mut d = doc_with("edge_bksp_src", "**bold** \n");
7107 d.caret = 9;
7108 d.backspace();
7109 assert_eq!(d.source, "**bold**\n");
7110 assert_eq!(d.caret, 8);
7111 }
7112
7113 #[test]
7114 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
7115 // `***both***` closes two runs with one stack of asterisks; a space that
7116 // clears only the inner one lands against the outer's and breaks that
7117 // instead.
7118 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
7119 d.caret = 9;
7120 d.insert(" ");
7121 assert_eq!(d.source, "a ***both*** \n");
7122 assert_eq!(d.caret, 13);
7123 d.insert("x");
7124 assert_eq!(d.source, "a ***both x***\n");
7125 }
7126
7127 #[test]
7128 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
7129 // The delimiter shuffle is not an edit the writer made, so it is not a
7130 // step they have to undo past.
7131 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
7132 d.caret = 8;
7133 d.insert(" ");
7134 assert_eq!(d.source, "a **bold** \n");
7135 d.undo();
7136 assert_eq!(d.source, "a **bold**\n");
7137 }
7138
7139 #[test]
7140 fn the_source_view_types_the_space_where_it_was_asked_to() {
7141 // The rule is a rich-view courtesy. In the source view the delimiters are
7142 // on the screen and the user is editing the bytes they can see.
7143 let mut d = doc_with("edge_src", "a **bold** c\n");
7144 d.caret = 8;
7145 d.insert(" ");
7146 assert_eq!(d.source, "a **bold ** c\n");
7147 }
7148
7149 #[test]
7150 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
7151 // Double-clicking a word takes the space after it; bolding that must not
7152 // spell `**word **`, which is not bold at all.
7153 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
7154 d.anchor = Some(2);
7155 d.caret = 7; // "word "
7156 d.toggle(InlineKind::Strong);
7157 assert_eq!(d.source, "a **word** b\n");
7158 d.toggle(InlineKind::Strong);
7159 assert_eq!(d.source, "a word b\n");
7160 d.toggle(InlineKind::Strong);
7161 assert_eq!(
7162 d.source, "a **word** b\n",
7163 "reapplying the mark must not wrap stale delimiter offsets"
7164 );
7165 // And a selection of nothing but whitespace has no word to mark.
7166 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
7167 d.anchor = Some(6);
7168 d.caret = 7;
7169 d.toggle(InlineKind::Strong);
7170 assert_eq!(d.source, "a word b\n");
7171 assert!(d.status.is_some());
7172 }
7173
7174 #[test]
7175 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
7176 let mut d = doc_with("head_set", "hello\n");
7177 d.caret = 2; // caret inside the paragraph, no selection
7178 d.set_block(BlockKind::Heading(1));
7179 assert_eq!(d.source, "# hello\n");
7180 }
7181
7182 #[test]
7183 fn set_block_heading_works_in_wysiwyg_view() {
7184 // The app defaults to WYSIWYG; the caret is a source offset either way.
7185 let mut d = wysiwyg_doc("head_wys", "hello\n");
7186 d.caret = 2;
7187 d.set_block(BlockKind::Heading(1));
7188 assert_eq!(d.source, "# hello\n");
7189 }
7190
7191 #[test]
7192 fn toggle_heading_applies_switches_and_reverts() {
7193 let mut d = doc_with("head_toggle", "hello\n");
7194 d.caret = 2;
7195 d.toggle_heading(1);
7196 assert_eq!(d.source, "# hello\n"); // paragraph → H1
7197 d.toggle_heading(2);
7198 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
7199 d.toggle_heading(2);
7200 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
7201 }
7202
7203 #[test]
7204 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
7205 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
7206 // the blank line but the caret rendered on the *next* line, because the
7207 // separator was a non-navigable decoration row. In Preserve flow that
7208 // blank line is a real caret home — the caret must resolve onto it, and
7209 // typing there makes the soft break that continues the paragraph.
7210 let src = "line one:\nsecond line\n";
7211 let mut d = wysiwyg_doc("pre_enter_lineend", src);
7212 d.set_line_flow(LineFlow::Preserve);
7213 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
7214 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
7215 d.newline();
7216 d.build_visual_unwrapped();
7217 assert_eq!(d.source, "line one:\n\nsecond line\n");
7218 assert_eq!(
7219 d.caret, 10,
7220 "caret sits on the new blank line, not the next line"
7221 );
7222 // The blank line is row 1, and the caret resolves onto it — not row 2.
7223 assert_eq!(
7224 d.vmap.pos_of_offset(10),
7225 (1, 0),
7226 "caret renders on the blank row"
7227 );
7228 assert!(
7229 !d.vmap.rows[1].decoration,
7230 "the blank line is navigable in Preserve"
7231 );
7232 // Typing there makes a soft break: one paragraph, three lines.
7233 d.insert("new clause,");
7234 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
7235 }
7236
7237 #[test]
7238 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
7239 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
7240 // that keeps it one paragraph — where Fold would open a second paragraph.
7241 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
7242 d.set_line_flow(LineFlow::Preserve);
7243 d.caret = 3;
7244 d.newline();
7245 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
7246
7247 // End-of-paragraph: Enter then typing continues the same paragraph on a
7248 // new line (a soft break), not a fresh paragraph.
7249 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
7250 d.set_line_flow(LineFlow::Preserve);
7251 d.caret = 3;
7252 d.newline();
7253 d.insert("def");
7254 assert_eq!(
7255 d.source, "abc\ndef\n",
7256 "end-of-line Enter + typing is a soft break"
7257 );
7258 }
7259
7260 #[test]
7261 fn preserve_double_enter_still_makes_a_paragraph() {
7262 // Two Enters in a row promote to a real paragraph break: the second lands
7263 // on the blank line the first opened and takes the empty-line branch.
7264 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
7265 d.set_line_flow(LineFlow::Preserve);
7266 d.caret = 3;
7267 d.newline();
7268 d.newline();
7269 d.insert("def");
7270 assert_eq!(
7271 d.source, "abc\n\ndef\n",
7272 "double Enter is a paragraph break"
7273 );
7274 }
7275
7276 #[test]
7277 fn preserve_backspace_joins_across_a_soft_break() {
7278 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7279 // soft break it deletes the single newline and joins the two lines.
7280 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7281 d.set_line_flow(LineFlow::Preserve);
7282 d.build_visual(80);
7283 d.caret = 4; // start of "def", just past the soft break
7284 d.backspace();
7285 assert_eq!(
7286 d.source, "abcdef\n",
7287 "Backspace joins across the soft break"
7288 );
7289 assert_eq!(d.caret, 3, "caret lands where the lines meet");
7290 }
7291
7292 #[test]
7293 fn fold_enter_still_starts_a_new_paragraph() {
7294 // The default flow is unchanged: a lone `\n` would render as an invisible
7295 // space, so Enter keeps opening the paragraph break that actually shows.
7296 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7297 d.caret = 3;
7298 d.newline();
7299 assert_eq!(
7300 d.source, "abc\n\ndef\n",
7301 "Fold mid-line Enter is a paragraph break"
7302 );
7303 }
7304
7305 #[test]
7306 fn wysiwyg_one_enter_starts_a_new_paragraph() {
7307 // Regression: one Enter left the caret between the two newlines, so typing
7308 // made a soft break (one paragraph) and you needed a second Enter.
7309 let mut d = wysiwyg_doc("wys_enter", "abc\n");
7310 d.caret = 3;
7311 d.newline();
7312 d.insert("def");
7313 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7314 }
7315
7316 #[test]
7317 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7318 // Regression: Enter at the caret's natural End-of-line resting place
7319 // after a bold run with nothing following it (on screen: right after
7320 // "bold", before the hidden closing "**") spliced the paragraph break
7321 // at that very byte offset — which sits *before* the closing "**" in
7322 // the source, since the delimiter is hidden and emits no glyph of its
7323 // own for `push_row`'s "end of row" fallback to count. That severed the
7324 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7325 // "**" alone on the new line instead of leaving "**bold**" intact with
7326 // a fresh empty paragraph after it.
7327 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7328 d.move_end(false); // the WYSIWYG End key, from caret 0
7329 assert_eq!(
7330 d.caret, 6,
7331 "caret rests right after \"bold\", before the hidden \"**\""
7332 );
7333 d.newline();
7334 assert!(
7335 d.source.starts_with("**bold**"),
7336 "the closing ** must stay attached to \"bold\": got {:?}",
7337 d.source
7338 );
7339 assert_eq!(
7340 d.source, "**bold**\n\n\n",
7341 "a fresh empty paragraph follows the still-intact bold run"
7342 );
7343 }
7344
7345 #[test]
7346 fn source_view_enter_is_a_single_newline() {
7347 let mut d = doc_with("src_enter", "abc\n");
7348 d.caret = 3;
7349 d.newline();
7350 assert_eq!(d.source, "abc\n\n");
7351 }
7352
7353 #[test]
7354 fn heading_applies_at_the_end_of_a_paragraph() {
7355 // The caret at a line end sits at the doc level; set_block must still find
7356 // the block on that line.
7357 let mut d = doc_with("head_end", "abc\n");
7358 d.caret = 3; // end of "abc"
7359 d.toggle_heading(1);
7360 assert_eq!(d.source, "# abc\n");
7361 }
7362
7363 #[test]
7364 fn heading_on_an_empty_new_paragraph_creates_one() {
7365 let mut d = wysiwyg_doc("head_empty", "abc\n");
7366 d.caret = 3;
7367 d.newline(); // caret now on a fresh, empty paragraph
7368 d.toggle_heading(1);
7369 d.insert("Title");
7370 assert!(d.source.contains("# Title"), "got {:?}", d.source);
7371 }
7372
7373 #[test]
7374 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7375 // The reported bug, end to end: click a blank line with another one under
7376 // it, press H1, type. The text landed in the heading and the caret's
7377 // offset was right (the source view drew it there), but the rich view
7378 // drew it two rows lower, on the trailing blank line — the empty `# `
7379 // heading had left every row below it short by the marker's two bytes,
7380 // and the blank line ended up claiming the heading's own end offset.
7381 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7382 d.build_visual_unwrapped();
7383 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7384 d.toggle_heading(1);
7385 for c in "title".chars() {
7386 d.insert(&c.to_string());
7387 d.build_visual_unwrapped(); // as a frontend does, one frame per key
7388 }
7389 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7390 assert_eq!(
7391 d.caret_pos(),
7392 (4, 5),
7393 "the caret draws at the end of the heading"
7394 );
7395 }
7396
7397 #[test]
7398 fn clicking_an_empty_heading_types_after_its_marker() {
7399 // The same anchor from the other side: the empty heading's row is its own
7400 // caret home, so a click on it must land past the hidden `# `. Landing in
7401 // front of the hashes made the first keystroke un-heading the line.
7402 let mut d = wysiwyg_doc("head_click", "# \n");
7403 d.build_visual_unwrapped();
7404 d.caret = d.vmap.offset_of_pos(0, 0);
7405 d.insert("x");
7406 assert_eq!(d.source, "# x\n");
7407 }
7408
7409 #[test]
7410 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7411 let mut d = wysiwyg_doc("head_enter", "# Title\n");
7412 d.caret = 7; // end of the heading
7413 d.newline();
7414 d.insert("body");
7415 assert_eq!(d.source, "# Title\n\nbody\n");
7416 }
7417
7418 #[test]
7419 fn wysiwyg_enter_continues_a_bullet_list() {
7420 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7421 d.caret = 6; // end of "item"
7422 d.newline();
7423 d.insert("two");
7424 assert_eq!(d.source, "- item\n- two\n");
7425 }
7426
7427 #[test]
7428 fn wysiwyg_enter_increments_an_ordered_list() {
7429 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7430 d.caret = 6; // end of "one"
7431 d.newline();
7432 d.insert("two");
7433 assert_eq!(d.source, "1. one\n2. two\n");
7434 }
7435
7436 #[test]
7437 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7438 // Regression for the "extra newline" left between a list and the paragraph
7439 // below it. Enter, Enter leaves the list on a fresh empty paragraph
7440 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7441 // Backspace should then take the caret cleanly back to the end of the list
7442 // item, `- item\n\nnext`, not delete a single newline and strand it on the
7443 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7444 // no caret can land on. The map is rebuilt between keystrokes exactly as a
7445 // frontend does, since Backspace reads the stop table to place the delete.
7446 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7447 d.caret = 6; // end of "item"
7448 d.newline();
7449 d.build_visual(80);
7450 d.newline(); // leave the list onto a fresh empty paragraph
7451 d.build_visual(80);
7452 assert_eq!(
7453 d.source, "- item\n\n\n\nnext\n",
7454 "double-Enter opens the empty paragraph"
7455 );
7456 d.backspace();
7457 assert_eq!(
7458 d.source, "- item\n\nnext\n",
7459 "one Backspace collapses the whole gap"
7460 );
7461 assert_eq!(
7462 d.caret, 6,
7463 "and lands the caret back at the end of the list item"
7464 );
7465 }
7466
7467 #[test]
7468 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7469 // The stop-wise delete must not over-reach when there is no block boundary
7470 // to cross: two blank lines in a row are one caret stop apart, so pressing
7471 // Enter on an empty line and then Backspace removes exactly the one newline
7472 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7473 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7474 d.caret = 5; // the empty paragraph the first Enter already opened
7475 d.build_visual(80);
7476 d.newline();
7477 d.build_visual(80);
7478 assert_eq!(
7479 d.source, "abc\n\n\n\n",
7480 "Enter on the blank line adds one newline"
7481 );
7482 d.backspace();
7483 assert_eq!(
7484 d.source, "abc\n\n\n",
7485 "Backspace takes back exactly that one newline"
7486 );
7487 }
7488
7489 #[test]
7490 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7491 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7492 d.caret = 6; // end of the empty "- " item
7493 d.newline();
7494 d.insert("p");
7495 assert_eq!(d.source, "- a\n\np\n");
7496 }
7497
7498 #[test]
7499 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7500 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7501 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7502 // not take the list-exit path (which would splice the `- ` away as if
7503 // leaving an empty item); the AST guard sends it to a normal break and
7504 // leaves the underline intact.
7505 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7506 assert!(
7507 d.nodes().iter().any(|n| n.kind == Kind::Heading),
7508 "precondition: twig parses this as a heading, not a list",
7509 );
7510 d.caret = 7; // on the `- ` underline line
7511 d.newline();
7512 assert!(
7513 d.source.contains("- "),
7514 "the setext underline survives, not spliced away as a list item: {:?}",
7515 d.source,
7516 );
7517 }
7518
7519 #[test]
7520 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7521 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7522 d.caret = 7; // end of "abc" inside the fence
7523 d.newline();
7524 d.insert("def");
7525 assert_eq!(d.source, "```\nabc\ndef\n```\n");
7526 }
7527
7528 #[test]
7529 fn wysiwyg_enter_continues_a_block_quote() {
7530 // Enter opens a new *paragraph* inside the quote, not a second line of
7531 // the same one. `> quote\n> more` is a soft break, which under
7532 // `LineFlow::Fold` renders as a space — the keystroke would look like it
7533 // did nothing. The quoted blank line is what makes the break visible, and
7534 // it's the same thing Enter does in running prose.
7535 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7536 d.caret = 7; // end of "quote"
7537 d.newline();
7538 d.insert("more");
7539 assert_eq!(d.source, "> quote\n>\n> more\n");
7540 // Still one quote, now holding two paragraphs — not a quote and a stray
7541 // line that fell out of it.
7542 let quotes = d
7543 .nodes()
7544 .iter()
7545 .filter(|n| n.kind == Kind::BlockQuote)
7546 .count();
7547 assert_eq!(quotes, 1);
7548 }
7549
7550 #[test]
7551 fn set_block_makes_a_heading_at_the_caret() {
7552 let mut d = doc_with("head", "Title\n\nbody\n");
7553 d.caret = 0;
7554 d.set_block(BlockKind::Heading(2));
7555 assert_eq!(d.source, "## Title\n\nbody\n");
7556 d.set_block(BlockKind::Paragraph);
7557 assert_eq!(d.source, "Title\n\nbody\n");
7558 }
7559
7560 // ── block containers (quote / list) ──────────────────────────────────────
7561
7562 #[test]
7563 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7564 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7565 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7566 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7567 // A caret at a line end sits at the doc level; the block is still found.
7568 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7569 }
7570
7571 #[test]
7572 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7573 // Every source line of the paragraph gets its own `> `, so a caret left
7574 // on its old byte offset falls one prefix per line above it too far
7575 // back — inside the markup it just asked for rather than in its word.
7576 assert_eq!(
7577 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7578 "> aaa\n> b|bb\n> ccc\n"
7579 );
7580 }
7581
7582 #[test]
7583 fn toggle_blockquote_works_in_wysiwyg_view() {
7584 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7585 assert_eq!(
7586 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
7587 "> hel|lo\n"
7588 );
7589 assert_eq!(
7590 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
7591 "hel|lo\n"
7592 );
7593 }
7594
7595 #[test]
7596 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
7597 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
7598 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7599 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7600 // The *other* kind converts in place instead of nesting, which is what
7601 // makes the two buttons one three-state control.
7602 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7603 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7604 // Its own kind, over the only item the list holds, takes it off.
7605 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
7606 }
7607
7608 #[test]
7609 fn toggle_list_works_in_wysiwyg_view() {
7610 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7611 assert_eq!(
7612 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
7613 "1. hel|lo\n"
7614 );
7615 assert_eq!(
7616 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
7617 "- hel|lo\n"
7618 );
7619 assert_eq!(
7620 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
7621 "hel|lo\n"
7622 );
7623 }
7624
7625 #[test]
7626 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
7627 // The selection has to grow with the markup: twig takes a container off
7628 // only a range covering every block it holds, so the second press can
7629 // reverse the first only if the result is what's selected.
7630 let mut d = doc_with("list_sel", "abc\n\ndef\n");
7631 d.select_all();
7632 d.toggle_list(true);
7633 assert_eq!(d.source, "1. abc\n\n2. def\n");
7634 assert_eq!(d.selection(), Some((0, d.source.len())));
7635 d.toggle_list(true);
7636 assert_eq!(d.source, "abc\n\ndef\n");
7637 }
7638
7639 #[test]
7640 fn toggle_blockquote_nests_a_partly_covered_quote() {
7641 // twig's rule: covering only some of a container's blocks nests, because
7642 // taking the quote off would drag its uncovered siblings out with it.
7643 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
7644 d.caret = 2; // in the first quoted paragraph only
7645 d.toggle_blockquote();
7646 assert_eq!(d.source, "> > a\n>\n> b\n");
7647 }
7648
7649 #[test]
7650 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
7651 // A blank line used to be no block for twig to wrap —
7652 // `toggle_block_container` answered `NotFound` — so Quote and the list
7653 // buttons did nothing on the very line the H1 button works on, and leaf
7654 // lent twig a scratch paragraph to wrap and took it back out again.
7655 // twig 3.2.0 opens an empty container there itself, so what is left here
7656 // is where the caret lands: inside the marker that was just written.
7657 let mut d = doc_with("quote_blank", "\nabc\n");
7658 d.caret = 0;
7659 d.toggle_blockquote();
7660 assert_eq!(d.source, "> \nabc\n");
7661 assert_eq!(
7662 d.caret, 2,
7663 "the caret belongs inside the quote it just opened"
7664 );
7665 assert!(d.status.is_none(), "{:?}", d.status);
7666 assert!(d.dirty);
7667
7668 // And the paragraph below is still its own block: an empty container one
7669 // soft break from `abc` would take that paragraph into the quote with it.
7670 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
7671 d.caret = 0;
7672 d.toggle_blockquote();
7673 d.build_visual(80);
7674 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
7675
7676 // The same from the other side: a blank line directly under a paragraph
7677 // earns the blank line an empty block needs, rather than being read as a
7678 // soft break inside that paragraph.
7679 let mut d = doc_with("list_blank_below", "abc\n");
7680 d.caret = 4;
7681 d.toggle_list(false);
7682 assert_eq!(d.source, "abc\n\n- ");
7683 assert_eq!(d.caret, 7);
7684 }
7685
7686 #[test]
7687 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
7688 // The gesture the rendering fix is for. `newline` inside a quote already
7689 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
7690 // spelling — but the two marker lines it adds belonged to no node until
7691 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
7692 // just made drew as plain prose under the quote.
7693 let mut d = wysiwyg_doc("quote_enter", "> a\n");
7694 d.caret = 3; // past `a`, at the end of the quoted line
7695 d.newline();
7696 assert_eq!(d.source, "> a\n>\n> \n");
7697 d.build_visual(80);
7698 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
7699 // And the caret is on the new line, not stranded on the old one.
7700 assert_eq!(d.caret, 8);
7701 }
7702
7703 #[test]
7704 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
7705 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
7706 // now it is twig's single edit. Either way one ⌘z has to put the blank
7707 // line back rather than undoing into a half-built document.
7708 for open in [
7709 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7710 &|d: &mut Doc| d.toggle_list(false),
7711 &|d: &mut Doc| d.toggle_list(true),
7712 ] {
7713 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7714 d.caret = 3;
7715 open(&mut d);
7716 assert_ne!(d.source, "a\n\n\n\nb\n");
7717 d.undo();
7718 assert_eq!(d.source, "a\n\n\n\nb\n");
7719 }
7720 }
7721
7722 #[test]
7723 fn a_container_toggle_is_one_undo_step() {
7724 let mut d = doc_with("quote_undo", "hello\n");
7725 d.caret = 3;
7726 d.insert("X"); // a typing run the structural edit must not fold into
7727 d.toggle_blockquote();
7728 assert_eq!(d.source, "> helXlo\n");
7729 d.undo();
7730 assert_eq!(d.source, "helXlo\n");
7731 }
7732
7733 // ── links ────────────────────────────────────────────────────────────────
7734
7735 #[test]
7736 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7737 let mut d = doc_with("link_sel", "word here\n");
7738 d.anchor = Some(0);
7739 d.caret = 4;
7740 d.insert_link("http://x.dev");
7741 assert_eq!(d.source, "[word](http://x.dev) here\n");
7742 // The text, not the destination — so a second press re-points the link
7743 // the first one made rather than nesting one inside it.
7744 assert_eq!(d.selected_text(), Some("word"));
7745 d.insert_link("http://y.dev");
7746 assert_eq!(d.source, "[word](http://y.dev) here\n");
7747 assert_eq!(d.selected_text(), Some("word"));
7748 }
7749
7750 #[test]
7751 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7752 let mut d = doc_with("img_caret", "before after\n");
7753 d.caret = 7; // between "before " and "after"
7754 d.insert_image("cat.png", "a cat");
7755 assert_eq!(d.source, "before after\n");
7756 // The caret sits just past the inserted image, nothing selected.
7757 assert_eq!(d.selection(), None);
7758 assert_eq!(d.caret, 7 + "".len());
7759 }
7760
7761 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7762 /// destination at the first space, so the `format!` this used to be wrote
7763 /// something that was not an image at all — and the reader saw the markup as
7764 /// text. twig owns the spelling now, and moves it into the angle form.
7765 #[test]
7766 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7767 let mut d = doc_with("img_space", "x\n");
7768 d.caret = 0;
7769 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7770 assert_eq!(
7771 d.source,
7772 "x\n"
7773 );
7774 // And it reads back as an image pointing at the unescaped path — the angle
7775 // brackets are spelling, not part of the destination.
7776 d.caret = 2;
7777 assert_eq!(
7778 d.image_destination_at_caret(),
7779 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7780 );
7781 }
7782
7783 /// A `)` in a caption or a filename must not close the image early.
7784 #[test]
7785 fn insert_image_escapes_a_paren_in_either_half() {
7786 let mut d = doc_with("img_paren", "x\n");
7787 d.caret = 0;
7788 d.insert_image("a)b.png", "");
7789 assert_eq!(d.source, "b.png)x\n");
7790 d.caret = 2;
7791 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7792 }
7793
7794 #[test]
7795 fn insert_image_uses_the_selection_as_alt_text() {
7796 let mut d = doc_with("img_sel", "caption here\n");
7797 d.anchor = Some(0);
7798 d.caret = 7; // "caption"
7799 d.insert_image("p.png", "ignored fallback");
7800 assert_eq!(d.source, " here\n");
7801 }
7802
7803 #[test]
7804 fn insert_image_with_no_alt_leaves_empty_brackets() {
7805 let mut d = doc_with("img_noalt", "\n");
7806 d.caret = 0;
7807 d.insert_image("logo.svg", "");
7808 assert_eq!(d.source, "\n");
7809 }
7810
7811 #[test]
7812 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7813 // The round trip is the point: it's no use writing markup the reader
7814 // can't pick up again. This is the pair that only holds from twig 2.5.1
7815 // on — before it, the one-line form went in fine and came back as a
7816 // paragraph of raw tags, publishing no media at all.
7817 let mut d = doc_with("vid_rt", "\n");
7818 d.caret = 0;
7819 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7820 assert_eq!(
7821 d.source,
7822 "<video src=\"clip.mp4\" controls>a clip</video>\n"
7823 );
7824
7825 d.build_visual(80);
7826 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7827 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7828 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7829 assert_eq!(d.vmap.media[0].alt, "a clip");
7830 }
7831
7832 #[test]
7833 fn insert_media_spells_audio_with_its_own_tag() {
7834 let mut d = doc_with("aud_rt", "\n");
7835 d.caret = 0;
7836 d.insert_media(MediaKind::Audio, "take.mp3", "");
7837 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7838 d.build_visual(80);
7839 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7840 }
7841
7842 #[test]
7843 fn insert_media_uses_the_selection_as_fallback_text() {
7844 // The same courtesy `insert_image` does with alt: select a caption,
7845 // insert, and the caption labels the thing rather than being replaced.
7846 let mut d = doc_with("vid_sel", "the talk here\n");
7847 d.anchor = Some(0);
7848 d.caret = 8; // "the talk"
7849 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7850 assert_eq!(
7851 d.source,
7852 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
7853 );
7854 }
7855
7856 #[test]
7857 fn insert_media_with_an_image_kind_is_just_insert_image() {
7858 let mut d = doc_with("img_via_media", "\n");
7859 d.caret = 0;
7860 d.insert_media(MediaKind::Image, "logo.svg", "x");
7861 assert_eq!(d.source, "\n");
7862 }
7863
7864 // ── thematic breaks ─────────────────────────────────────────────────────
7865
7866 /// The node the source parses as at `caret` — what confirms an inserted
7867 /// `---` actually reads back as a rule, not stray text or a setext heading.
7868 ///
7869 /// The *narrowest* node covering the offset. Every ancestor covers it too,
7870 /// and since twig 2.8 that includes the `doc` root, which now carries a real
7871 /// span (it reported none before, so taking the first match used to land on
7872 /// the block by luck and now always answers `"doc"`).
7873 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
7874 d.nodes()
7875 .into_iter()
7876 .filter(|n| n.span.start <= caret && caret < n.span.end)
7877 .min_by_key(|n| n.span.end - n.span.start)
7878 .map(|n| n.kind)
7879 }
7880
7881 #[test]
7882 fn a_task_box_toggles_at_the_caret_and_reads_back() {
7883 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
7884 d.caret = 8; // inside "todo"
7885 assert_eq!(d.task_checked_at_caret(), Some(false));
7886 d.toggle_task_checked();
7887 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
7888 assert_eq!(d.task_checked_at_caret(), Some(true));
7889 d.toggle_task_checked();
7890 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
7891 }
7892
7893 #[test]
7894 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
7895 // The whole reason `toggle_task_at` exists apart from the caret form:
7896 // ticking a box elsewhere must not move the cursor out of what's being
7897 // typed.
7898 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
7899 d.caret = 8; // inside "first"
7900 let second = d.source.find("second").unwrap();
7901 d.toggle_task_at(second);
7902 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
7903 assert_eq!(d.caret, 8, "the caret stayed in the first item");
7904 }
7905
7906 #[test]
7907 fn a_plain_item_gains_and_loses_a_box() {
7908 let mut d = doc_with("task_mint", "- plain\n");
7909 d.caret = 4;
7910 assert_eq!(d.task_checked_at_caret(), None);
7911 d.toggle_task_item();
7912 assert_eq!(d.source, "- [ ] plain\n");
7913 assert_eq!(
7914 d.task_checked_at_caret(),
7915 Some(false),
7916 "a new box arrives unticked"
7917 );
7918 d.toggle_task_item();
7919 assert_eq!(d.source, "- plain\n");
7920 }
7921
7922 #[test]
7923 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
7924 // `set checked` must not silently convert a bullet into a task — that is
7925 // `toggle_task_item`'s job, and twig refuses it here.
7926 let mut d = doc_with("task_none", "- plain\n");
7927 d.caret = 4;
7928 d.toggle_task_checked();
7929 assert_eq!(d.source, "- plain\n", "nothing written");
7930 assert!(
7931 d.status.is_some(),
7932 "the refusal should reach the status line"
7933 );
7934 }
7935
7936 #[test]
7937 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
7938 let mut d = doc_with("task_quote", "> - [ ] nested\n");
7939 d.caret = d.source.find("nested").unwrap();
7940 assert_eq!(d.task_checked_at_caret(), Some(false));
7941 d.toggle_task_checked();
7942 assert_eq!(d.source, "> - [x] nested\n");
7943 }
7944
7945 #[test]
7946 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
7947 // A rule is a block, so twig's `insert_thematic_break` alone lands it
7948 // after the whole paragraph. `split_block` parts the paragraph first and
7949 // the rule is aimed at the *first* half, which is what a rule button is
7950 // understood to do — and what leaf spelled by hand until twig grew both
7951 // halves of the gesture.
7952 let mut d = doc_with("hr_mid", "before after\n");
7953 d.caret = 7; // between "before " and "after"
7954 d.insert_thematic_break();
7955 assert_eq!(d.source, "before \n\n---\n\nafter\n");
7956 assert_eq!(d.selection(), None);
7957 assert_eq!(
7958 kind_at(&mut d, "before \n\n".len()),
7959 Some(Kind::ThematicBreak)
7960 );
7961 }
7962
7963 #[test]
7964 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
7965 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
7966 // and leaf wrote the first into both until twig started spelling it.
7967 let mut md = doc_with("hr_md", "para\n");
7968 md.caret = 2;
7969 md.insert_thematic_break();
7970 assert_eq!(md.source, "pa\n\n---\n\nra\n");
7971
7972 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
7973 dj.caret = 2;
7974 dj.insert_thematic_break();
7975 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
7976 }
7977
7978 #[test]
7979 fn clicking_below_a_final_thematic_break_can_type_after_it() {
7980 let mut d = wysiwyg_doc("hr_final_click", "---\n");
7981 d.build_visual(80);
7982 d.click(d.vmap.num_rows() + 2, 0, false);
7983 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
7984 d.insert("after");
7985 assert_eq!(d.source, "---\nafter");
7986 }
7987
7988 #[test]
7989 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
7990 // The same bytes are two documents. In Markdown ` - b` is a nested item
7991 // and the next one belongs beside it, at its indent. In Djot a list
7992 // marker can't interrupt a paragraph, so those bytes are literal text in
7993 // item `a` and there is only one item — writing ` - ` under it would add
7994 // no item at all, just more text, and the new sibling has to go to
7995 // column zero. Both spellings come out of the *enclosing item's* line.
7996 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
7997 md.caret = "- a\n - b".len();
7998 md.newline();
7999 assert_eq!(md.source, "- a\n - b\n - \n");
8000 assert_eq!(list_items(&mut md), 3);
8001
8002 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
8003 dj.view = View::Wysiwyg;
8004 dj.build_visual(80);
8005 dj.caret = "- a\n - b".len();
8006 dj.newline();
8007 assert_eq!(dj.source, "- a\n - b\n- \n");
8008 assert_eq!(list_items(&mut dj), 2);
8009
8010 // Where Djot's nesting is real — opened by a blank line — the indent is
8011 // reproduced there too, and the two formats agree again.
8012 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
8013 dj.view = View::Wysiwyg;
8014 dj.build_visual(80);
8015 dj.caret = "- a\n\n - b".len();
8016 dj.newline();
8017 assert_eq!(dj.source, "- a\n\n - b\n - \n");
8018 assert_eq!(list_items(&mut dj), 3);
8019 }
8020
8021 #[test]
8022 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
8023 // Tab replaces the line's whole prefix with the one twig spells, so the
8024 // quote markers, the parent's indent and an ordered marker's extra
8025 // column are all its answer rather than leaf's arithmetic.
8026 for (name, body, caret, want) in [
8027 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
8028 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
8029 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
8030 // A checkbox is markup the item's own text wraps past, but a nested
8031 // list may only open at the *list* marker's column — four in from
8032 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
8033 // parses as one item, not two.
8034 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
8035 (
8036 "quoted task",
8037 "> - [ ] a\n> - [ ] b\n",
8038 18,
8039 "> - [ ] a\n> - [ ] b\n",
8040 ),
8041 ] {
8042 let mut doc = wysiwyg_doc(name, body);
8043 doc.caret = caret;
8044 doc.indent();
8045 assert_eq!(doc.source, want, "{name}");
8046 // The nesting is real, not just indented text.
8047 assert_eq!(list_items(&mut doc), 2, "{name}");
8048 }
8049 }
8050
8051 #[test]
8052 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
8053 // The same bytes, the two formats disagreeing, and a gesture that used
8054 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
8055 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
8056 // so those bytes are literal text inside item `a` — there is nothing to
8057 // outdent, and treating them as a marker turned one item into two, a
8058 // structural edit from a keystroke that should delete one character.
8059 //
8060 // twig's `line_prefix` is what tells them apart: it reports the marker
8061 // on the Markdown line and nothing on the Djot one, which is a
8062 // continuation. No byte scan can reach that answer.
8063 let src = "- a\n - b\n";
8064 let at = "- a\n - ".len();
8065
8066 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
8067 md.view = View::Wysiwyg;
8068 md.build_visual(80);
8069 md.caret = at;
8070 md.backspace();
8071 assert_eq!(md.source, "- a\n- b\n");
8072 assert_eq!(list_items(&mut md), 2);
8073
8074 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
8075 dj.view = View::Wysiwyg;
8076 dj.build_visual(80);
8077 dj.caret = at;
8078 dj.backspace();
8079 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
8080 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
8081 }
8082
8083 #[test]
8084 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
8085 // Leaf used to spell the next item from the marker bytes it scanned, and
8086 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
8087 // and dropped out of the checklist. twig reproduces the whole
8088 // continuation, and a fresh item is always unticked however the one above
8089 // it stands.
8090 for (name, body, want) in [
8091 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
8092 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
8093 ] {
8094 let mut doc = wysiwyg_doc(name, body);
8095 doc.caret = body.trim_end_matches('\n').len();
8096 doc.newline();
8097 assert_eq!(doc.source, want, "{name}");
8098 // Both items are checklist items — the new one is a box, not the
8099 // plain bullet the old marker scan left behind — and it is unticked
8100 // whichever way the one above it faces.
8101 let boxes: Vec<Option<bool>> = doc
8102 .nodes()
8103 .iter()
8104 .filter(|n| n.kind == Kind::TaskListItem)
8105 .map(|n| n.checked)
8106 .collect();
8107 assert_eq!(boxes.len(), 2, "{name}");
8108 assert_eq!(boxes[1], Some(false), "{name}");
8109 }
8110 }
8111
8112 #[test]
8113 fn a_split_takes_the_space_the_caret_was_in_front_of() {
8114 // Splicing a break at the caret strands the space the words were parted
8115 // at on the head of the second block, where it reads as an indent nobody
8116 // typed. twig's split consumes it.
8117 for (name, body, caret, want) in [
8118 ("para", "one two\n", 3, "one\n\ntwo\n"),
8119 ("item", "- one two\n", 5, "- one\n- two\n"),
8120 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
8121 // A heading takes leaf's own path, which has to match.
8122 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
8123 ] {
8124 let mut doc = wysiwyg_doc(name, body);
8125 doc.caret = caret;
8126 doc.newline();
8127 assert_eq!(doc.source, want, "{name}");
8128 }
8129 }
8130
8131 #[test]
8132 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
8133 // The one place leaf keeps its own break: `split_block` repeats the `#`,
8134 // and Enter after a title is how the body under it is asked for.
8135 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
8136 doc.caret = "# Title".len();
8137 doc.newline();
8138 doc.insert("body");
8139 assert_eq!(doc.source, "# Title\n\nbody\n");
8140 assert_eq!(
8141 doc.nodes()
8142 .iter()
8143 .filter(|n| n.kind == Kind::Heading)
8144 .count(),
8145 1
8146 );
8147 }
8148
8149 #[test]
8150 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
8151 // A quoted item's marker doesn't open its line, so a scan that starts at
8152 // column zero finds a `>` where it wanted a bullet, calls the line "not a
8153 // list" and hands Enter to the plain-quote branch — which writes `> ` and
8154 // drops the list. The next item has to carry the whole prefix.
8155 for (name, body, want) in [
8156 ("flat", "> - a\n", "> - a\n> - \n"),
8157 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8158 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8159 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
8160 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
8161 ] {
8162 let mut doc = wysiwyg_doc(name, body);
8163 doc.caret = body.trim_end_matches('\n').len();
8164 doc.newline();
8165 assert_eq!(doc.source, want, "{name}");
8166 // The marker isn't just spelled right, it parses as an item.
8167 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
8168 }
8169 }
8170
8171 #[test]
8172 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
8173 // Double-Enter exits the list. Unquoted that means a blank line, but a
8174 // *bare* blank line would end the quote too and drop the caret out of it,
8175 // so the separator keeps its `>` and the caret's line keeps its `> `.
8176 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
8177 doc.caret = "> - a\n> - ".len();
8178 doc.newline();
8179 assert_eq!(doc.source, "> - a\n>\n> \n");
8180 assert_eq!(list_items(&mut doc), 1);
8181 // What "still in the quote" means for the next keystroke: the caret sits
8182 // behind the prefix, and what's typed there lands inside the quote as a
8183 // paragraph of its own — not as more of item `a`.
8184 doc.insert("x");
8185 assert_eq!(doc.source, "> - a\n>\n> x\n");
8186 assert!(
8187 doc.editor
8188 .ancestors_at(doc.caret - 1)
8189 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
8190 );
8191 }
8192
8193 #[test]
8194 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
8195 // The marker is hidden block markup, so Backspace over it is structural —
8196 // but only the marker is the list's. Splicing from the line start would
8197 // take the `>` with it and silently unquote the line.
8198 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
8199 doc.caret = "> - ".len();
8200 doc.backspace();
8201 assert_eq!(doc.source, "> a\n");
8202 assert_eq!(list_items(&mut doc), 0);
8203
8204 // A nested one outdents instead, moving the bullet within the quote
8205 // rather than moving the quote.
8206 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
8207 doc.caret = "> - a\n> - ".len();
8208 doc.backspace();
8209 assert_eq!(doc.source, "> - a\n> - b\n");
8210 assert_eq!(list_items(&mut doc), 2);
8211 }
8212
8213 #[test]
8214 fn only_a_bare_paragraph_is_parted_around_the_caret() {
8215 // The split is deliberately narrow. Parting a fenced block would leave
8216 // two fences with a rule between them, and parting a list item would
8217 // mint an item nobody asked for on the way to a rule that lands after
8218 // the list either way — so both keep the whole block intact and take the
8219 // rule after it. A caret in a quote is likewise left alone.
8220 for (name, body, caret, want) in [
8221 (
8222 "code",
8223 "```\nfn x() {}\n```\n",
8224 8,
8225 "```\nfn x() {}\n```\n\n---\n",
8226 ),
8227 ("list", "- one two\n", 6, "- one two\n\n---\n"),
8228 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
8229 ] {
8230 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
8231 d.caret = caret;
8232 d.insert_thematic_break();
8233 assert_eq!(d.source, want, "{name}: the block should stay whole");
8234 }
8235 }
8236
8237 #[test]
8238 fn insert_thematic_break_replaces_the_selection() {
8239 // Now that the rule lands *at* the caret again, replacing the selection
8240 // is coherent once more: the text goes, and the rule takes its place.
8241 // The space the deletion left leading the second half is consumed by the
8242 // split rather than opening the new paragraph with it.
8243 let mut d = doc_with("hr_sel", "one two three\n");
8244 d.anchor = Some(4);
8245 d.caret = 7; // "two"
8246 d.insert_thematic_break();
8247 assert_eq!(d.source, "one \n\n---\n\nthree\n");
8248 assert_eq!(d.selection(), None);
8249 }
8250
8251 #[test]
8252 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
8253 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
8254 // because writing `---` into one is code, not a rule — twig now walks out
8255 // to the block that owns the caret's line, so there is nothing to refuse.
8256 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
8257 code.caret = 5; // inside the fenced code
8258 code.insert_thematic_break();
8259 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
8260 assert_eq!(code.status, None, "no refusal to report any more");
8261
8262 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
8263 table.caret = 3; // in the header row
8264 table.insert_thematic_break();
8265 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
8266 }
8267
8268 #[test]
8269 fn insert_thematic_break_in_a_list_item_ends_the_list() {
8270 // The un-indented rule cannot continue the list, so it closes the list
8271 // and lands at the top level rather than nested inside it.
8272 let mut d = doc_with("hr_list", "- one\n- two\n");
8273 d.caret = "- one\n- tw".len(); // mid "two"
8274 d.insert_thematic_break();
8275 d.build_visual(80);
8276 let rule_at = d.source.find("---").unwrap();
8277 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8278 assert!(
8279 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8280 && n.span.start <= rule_at
8281 && rule_at < n.span.end),
8282 "the rule must not be nested inside the list"
8283 );
8284 }
8285
8286 #[test]
8287 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8288 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8289 // which is the document the gesture was actually asked for.
8290 let mut d = doc_with("hr_quote", "> hello\n");
8291 d.caret = 4; // inside the quoted text
8292 d.insert_thematic_break();
8293 assert_eq!(d.source, "> hello\n>\n> ---\n");
8294 d.build_visual(80);
8295 let rule_at = d.source.find("---").unwrap();
8296 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8297 assert!(
8298 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8299 && n.span.start <= rule_at
8300 && rule_at < n.span.end),
8301 "the rule belongs to the quote it was asked for"
8302 );
8303 }
8304
8305 // ── typing against a block picture ────────────────────────────────────────
8306
8307 /// A rendered-view document with the caret parked on one of the picture's two
8308 /// stops, and the map already built — the state a frontend is in between
8309 /// drawing a frame and the next keystroke.
8310 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8311 let mut d = doc_in(View::Wysiwyg, name, src);
8312 d.build_visual_unwrapped();
8313 let start = src.find("".len(),
8317 };
8318 d
8319 }
8320
8321 /// The block media the map publishes, after rebuilding it — "is this still a
8322 /// picture, or has it become a line of text with an image in it?"
8323 fn media_count(d: &mut Doc) -> usize {
8324 d.build_visual_unwrapped();
8325 d.vmap.media.len()
8326 }
8327
8328 #[test]
8329 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8330 // The accident this prevents: tap the blank page under a photo (which
8331 // lands on the picture's trailing stop), type, and `xy` is a
8332 // paragraph with an *inline* image — the photo stops being drawn.
8333 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
8334 d.insert("xy");
8335 assert_eq!(d.source, "hi\n\n\n\nxy\n");
8336 assert_eq!(media_count(&mut d), 1, "still a picture");
8337 }
8338
8339 #[test]
8340 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8341 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
8342 d.insert("xy");
8343 assert_eq!(d.source, "hi\n\nxy\n\n\n");
8344 assert_eq!(media_count(&mut d), 1);
8345 }
8346
8347 #[test]
8348 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8349 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
8350 d.insert("x");
8351 assert_eq!(d.source, "x\n\n\n");
8352 assert_eq!(media_count(&mut d), 1);
8353 }
8354
8355 #[test]
8356 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8357 // The opened paragraph is part of the keystroke, not an edit the writer
8358 // made — so it undoes with the character, not a step later.
8359 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
8360 d.insert("x");
8361 assert_eq!(d.source, "hi\n\n\n\nx\n");
8362 d.undo();
8363 assert_eq!(d.source, "hi\n\n\n");
8364 }
8365
8366 #[test]
8367 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
8368 // ⌘V dissolves the picture exactly as a keystroke does.
8369 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
8370 d.paste("pasted");
8371 assert_eq!(d.source, "hi\n\n\n\npasted\n");
8372 assert_eq!(media_count(&mut d), 1);
8373 }
8374
8375 #[test]
8376 fn typing_beside_an_inline_image_is_ordinary_editing() {
8377 // An inline image has no placeholder row and no stops of its own. Opening
8378 // a paragraph mid-sentence would be the bug, not the fix.
8379 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
8380 d.build_visual_unwrapped();
8381 d.caret = "see ".len();
8382 d.insert("!");
8383 assert_eq!(d.source, "see ! here\n");
8384 }
8385
8386 #[test]
8387 fn source_view_types_raw_markup_against_an_image_untouched() {
8388 // Source view is for writing the markup itself; a break inserted behind
8389 // the writer's back there would be the editor arguing with them.
8390 let mut d = doc_in(View::Source, "pic_src", "\n");
8391 d.caret = "".len();
8392 d.insert("x");
8393 assert_eq!(d.source, "x\n");
8394 }
8395
8396 #[test]
8397 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
8398 // A selection is replaced, not joined into, so there is nothing to
8399 // protect: the range takes the picture with it.
8400 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
8401 d.anchor = Some(d.caret);
8402 d.caret = d.source.find("".len();
8403 d.insert("x");
8404 assert_eq!(d.source, "hi\n\nx\n");
8405 }
8406
8407 #[test]
8408 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
8409 // What this actually cost: a real vault's photo, to one stray Backspace.
8410 // The caret past `` was deleting the closing paren — invisible
8411 // in the rendered view — and the photo became the text `\n", MediaStop::After);
8413 d.backspace();
8414 assert_eq!(d.source, "hi\n");
8415 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
8416 d.undo();
8417 assert_eq!(
8418 d.source, "hi\n\n\n",
8419 "and comes back in one piece"
8420 );
8421 }
8422
8423 #[test]
8424 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
8425 // Deleting the break here would join the picture to the paragraph above,
8426 // where it is an *inline* image and stops being drawn. Step over the
8427 // boundary; the next press deletes in the paragraph the caret reached.
8428 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
8429 d.backspace();
8430 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
8431 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8432 d.backspace();
8433 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
8434 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8435 }
8436
8437 #[test]
8438 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8439 // The mirror. A byte-step here eats the `!` and leaves a link.
8440 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
8441 d.delete_forward();
8442 assert_eq!(d.source, "hi\n\nbye\n");
8443 assert_eq!(media_count(&mut d), 0);
8444 }
8445
8446 #[test]
8447 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8448 let mut d = doc_at_picture(
8449 "pic_del_after",
8450 "hi\n\n\n\nbye\n",
8451 MediaStop::After,
8452 );
8453 d.delete_forward();
8454 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
8455 assert_eq!(
8456 d.caret,
8457 d.source.find("bye").unwrap(),
8458 "the caret stepped down to `bye`"
8459 );
8460 }
8461
8462 #[test]
8463 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8464 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
8465 d.backspace();
8466 assert_eq!(d.source, "\n");
8467 assert_eq!(media_count(&mut d), 0);
8468 }
8469
8470 #[test]
8471 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8472 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8473 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
8474 d.delete_word_back();
8475 assert_eq!(d.source, "hi there\n");
8476
8477 // And in front of one it runs *through* the paragraph break into the
8478 // prose above, which merges the picture inline — so it steps out first,
8479 // and the second press deletes the word it was aimed at.
8480 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
8481 d.delete_word_back();
8482 assert_eq!(d.source, "hi there\n\n\n");
8483 d.delete_word_back();
8484 assert_eq!(
8485 d.source, "hi \n\n\n",
8486 "the word above went, the picture stayed"
8487 );
8488 assert_eq!(media_count(&mut d), 1);
8489 }
8490
8491 #[test]
8492 fn source_view_deletes_raw_markup_against_an_image_untouched() {
8493 let mut d = doc_in(View::Source, "pic_src_del", "\n");
8494 d.caret = "".len();
8495 d.backspace();
8496 assert_eq!(d.source, ";
8497 }
8498
8499 #[test]
8500 fn image_destination_at_caret_reads_the_image_under_the_caret() {
8501 let mut d = doc_with("img_read", "\n");
8502 d.caret = 3; // inside the image markup
8503 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8504 // Past the image, the caret is in no image.
8505 d.caret = "".len();
8506 assert_eq!(d.image_destination_at_caret(), None);
8507 }
8508
8509 #[test]
8510 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8511 // The image is one placeholder row by default, and `set_media_rows` grows
8512 // it to the height the frontend measured: the label row plus blank
8513 // `decoration` fillers that hold the vertical space a raster is drawn into.
8514 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
8515 assert_eq!(d.vmap.media.len(), 1);
8516 let img_row = d.vmap.media[0].rows_span.start;
8517 assert_eq!(
8518 d.vmap.media[0].rows_span,
8519 img_row..img_row + 1,
8520 "default is one row"
8521 );
8522
8523 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8524 d.build_visual(80);
8525 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8526 let span = d.vmap.media[0].rows_span.clone();
8527 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8528 // The label row carries the mark and its glyphs; the three below are blank
8529 // decoration — drawn, but no caret and no text.
8530 assert!(
8531 d.vmap.rows[span.start].media.is_some(),
8532 "mark rides the first row"
8533 );
8534 for r in (span.start + 1)..span.end {
8535 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8536 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8537 assert!(
8538 d.vmap.rows[r].media.is_none(),
8539 "only the first row is marked"
8540 );
8541 }
8542 }
8543
8544 #[test]
8545 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8546 // The extra rows are pure spacers: the caret's only homes stay the stop in
8547 // front of the image and the one just past it, so walking the document top
8548 // to bottom visits the same offsets whether the image is 1 row or 5.
8549 let body = "ab\n\n\n\ncd\n";
8550 let stops_at = |rows: usize| -> Vec<usize> {
8551 let mut d = wysiwyg_doc("img_stops", body);
8552 if rows > 1 {
8553 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8554 d.build_visual(80);
8555 }
8556 d.caret = 0;
8557 let mut seen = vec![d.caret];
8558 loop {
8559 d.move_right(false);
8560 if *seen.last().unwrap() == d.caret {
8561 break;
8562 }
8563 seen.push(d.caret);
8564 }
8565 seen
8566 };
8567 assert_eq!(
8568 stops_at(1),
8569 stops_at(5),
8570 "reserving rows must not add stops"
8571 );
8572 }
8573
8574 #[test]
8575 fn insert_link_repoints_the_link_at_a_bare_caret() {
8576 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8577 d.caret = 3; // in the link's text, nothing selected
8578 d.insert_link("http://y.dev");
8579 assert_eq!(d.source, "[word](http://y.dev)\n");
8580 assert_eq!(d.selected_text(), Some("word"));
8581 }
8582
8583 #[test]
8584 fn insert_link_on_an_empty_range_autolinks_a_url() {
8585 // A link with no text of its own is an autolink, and twig spells it —
8586 // `<…>` is the canonical form and needs no text typed into it, so the
8587 // caret lands after it rather than selecting a finished link.
8588 let mut d = doc_with("link_empty", "\n");
8589 d.caret = 0;
8590 d.insert_link("http://x.dev");
8591 assert_eq!(d.source, "<http://x.dev>\n");
8592 assert_eq!(d.selection(), None);
8593 assert_eq!(d.caret, 14);
8594 }
8595
8596 #[test]
8597 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
8598 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
8599 // in Markdown, so a destination that can't autolink doubles as the text
8600 // instead — which is then selected, ready to be typed over.
8601 let mut d = doc_with("link_rel", "\n");
8602 d.caret = 0;
8603 d.insert_link("./notes.md");
8604 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
8605 assert_eq!(d.selection(), Some((1, 11)));
8606 d.insert("Notes");
8607 assert_eq!(d.source, "[Notes](./notes.md)\n");
8608 }
8609
8610 #[test]
8611 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
8612 // The autolink's text is its URL, so re-pointing replaces the whole
8613 // node — the caret must not splice a second link inside the first.
8614 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
8615 d.caret = 10;
8616 d.insert_link("https://y.dev");
8617 assert_eq!(d.source, "see <https://y.dev> ok\n");
8618 }
8619
8620 #[test]
8621 fn code_language_reads_and_edits_through_the_fence() {
8622 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
8623 d.caret = 10; // inside the code body
8624 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8625 assert!(d.caret_in_fenced_code());
8626
8627 d.set_code_language("python");
8628 assert!(
8629 d.source.starts_with("```python\n"),
8630 "source: {:?}",
8631 d.source
8632 );
8633 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
8634
8635 // Clearing it leaves a bare fence and no label.
8636 d.set_code_language("");
8637 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
8638 assert_eq!(d.code_language_at_caret(), None);
8639
8640 // A caret outside any code block edits nothing.
8641 let mut p = doc_with("code_lang_none", "just prose\n");
8642 assert!(!p.caret_in_fenced_code());
8643 p.set_code_language("rust");
8644 assert_eq!(p.source, "just prose\n");
8645 }
8646
8647 #[test]
8648 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
8649 // Markdown's info string ends at whitespace, so `two words` would write
8650 // a fence that reads back with a different language than the one asked
8651 // for. twig refuses it; leaf reports that and leaves the source alone.
8652 // The old splice trimmed the ends and wrote whatever was left.
8653 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
8654 d.caret = 10;
8655 d.set_code_language("two words");
8656 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
8657 assert!(d.status.is_some(), "the refusal should be reported");
8658 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8659 }
8660
8661 #[test]
8662 fn link_destination_at_caret_reads_both_spellings() {
8663 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
8664 d.caret = 5;
8665 assert_eq!(
8666 d.link_destination_at_caret().as_deref(),
8667 Some("https://x.dev")
8668 );
8669 d.caret = 0;
8670 assert_eq!(d.link_destination_at_caret(), None);
8671
8672 // An autolink has no `destination`; its text is the URL.
8673 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
8674 a.caret = 10;
8675 assert_eq!(
8676 a.link_destination_at_caret().as_deref(),
8677 Some("https://x.dev")
8678 );
8679 a.caret = 21;
8680 assert_eq!(a.link_destination_at_caret(), None);
8681 }
8682
8683 #[test]
8684 fn locate_finds_the_block_a_declared_id_names() {
8685 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
8686 // verse. The locator has to land on the *verse*, which is the whole
8687 // reason a link carries one.
8688 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
8689 {#v2}\nYea, I make a record in the language of my father.\n";
8690 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8691 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
8692 assert_eq!(
8693 d.source[v2.start..v2.end].trim_end(),
8694 "Yea, I make a record in the language of my father."
8695 );
8696 // The attribute line is not part of it: `start` is a place to put a
8697 // caret, and `{#v2}` is markup the caret has no business landing in.
8698 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
8699 assert_eq!(d.locate("v99"), None);
8700 }
8701
8702 #[test]
8703 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
8704 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
8705 // Markdown heading is literal text. So `#the-second-part` can only be
8706 // the heading's own words, which is the rule every Markdown renderer
8707 // already follows and therefore the one a link was authored against.
8708 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
8709 let mut d = doc_with("locate_md", src);
8710 let hit = d.locate("the-second-part").expect("the heading's slug");
8711 assert!(d.source[hit.start..].starts_with("## The Second Part"));
8712 // Bounded by the next heading that isn't under it, so a peek shows the
8713 // section rather than only its title.
8714 assert_eq!(
8715 &d.source[hit.start..hit.end],
8716 "## The Second Part\n\nbody\n\n"
8717 );
8718
8719 // A subsection does not end its parent: `# Title` runs to `## Third`'s
8720 // sibling only because there is no other `#`, so it covers the lot.
8721 let title = d.locate("title").expect("the top heading");
8722 assert_eq!(title.end, d.source.len());
8723 }
8724
8725 #[test]
8726 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
8727 // djot mints `Some-Heading-Here`; a link to it is written
8728 // `#some-heading-here` by nearly everything that writes links. Both
8729 // spellings are one question.
8730 let src = "## Some Heading Here\n\nbody\n";
8731 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8732 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
8733 let slugged = d.locate("some-heading-here").expect("the link's spelling");
8734 assert_eq!(exact, slugged);
8735 // The section, not the heading line — there is more to show than a title.
8736 assert_eq!(&d.source[exact.start..exact.end], src);
8737 }
8738
8739 #[test]
8740 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
8741 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
8742 assert_eq!(d.locate(""), None);
8743 assert_eq!(d.locate(" "), None);
8744 // All punctuation: it names nothing, and must not be read as "match the
8745 // first heading whose slug is also empty".
8746 assert_eq!(d.locate("!!!"), None);
8747 }
8748
8749 #[test]
8750 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
8751 // The document's mistake, and the answer every other anchor
8752 // implementation gives — the alternative is for a link to mean whichever
8753 // of the two a walk happened to reach first.
8754 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
8755 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8756 let hit = d.locate("dup").expect("the first `{#dup}`");
8757 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
8758 }
8759
8760 #[test]
8761 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
8762 // The button's whole job: a reference where the caret was, a definition
8763 // to give it meaning, and the caret waiting in the empty note so the
8764 // next keystroke is the note's first word.
8765 let mut d = doc_with("fn_insert", "A claim and more.\n");
8766 d.caret = 7; // just past "A claim"
8767 d.insert_footnote();
8768 assert!(
8769 d.source.starts_with("A claim[^1] and more."),
8770 "{:?}",
8771 d.source
8772 );
8773 assert!(
8774 d.source.contains("[^1]:"),
8775 "the definition too: {:?}",
8776 d.source
8777 );
8778 assert_eq!(d.status, None);
8779
8780 let reference = d.source.find("[^1]").unwrap();
8781 let note = d
8782 .footnote_at(reference + 2)
8783 .expect("the reference just written");
8784 assert_eq!(note.label, "1");
8785 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
8786 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
8787 // …and typing there is typing into the note, not near it.
8788 d.insert("the note");
8789 assert_eq!(
8790 d.footnote_at(reference + 2).and_then(|f| f.text),
8791 Some("the note".to_string())
8792 );
8793 }
8794
8795 #[test]
8796 fn insert_footnote_numbers_past_the_notes_already_written() {
8797 // A second press must not hand back a label somebody else is using: twig
8798 // reuses a defined label rather than appending a rival definition, so a
8799 // repeat of `1` would quietly point the new reference at the old note.
8800 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8801 d.caret = 7; // past `[^1]`, before " two."
8802 d.insert_footnote();
8803 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8804 assert_eq!(d.source.matches("[^2]:").count(), 1);
8805 }
8806
8807 #[test]
8808 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8809 // `[^2]` with no definition is still a 2 that means something to whoever
8810 // wrote it — stepping over it would mint a note for their reference. A
8811 // word label takes no number, so it blocks none.
8812 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8813 d.caret = d.source.find(" c").unwrap();
8814 d.insert_footnote();
8815 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8816 assert!(
8817 d.source.starts_with("a[^2] b[^why][^1] c"),
8818 "{:?}",
8819 d.source
8820 );
8821 }
8822
8823 #[test]
8824 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8825 // A reference annotates the words before it. Consuming the selection —
8826 // which is what an insert normally does — would delete the very claim
8827 // the author selected in order to footnote.
8828 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8829 d.anchor = Some(2);
8830 d.caret = 7; // "claim" selected
8831 d.insert_footnote();
8832 assert!(
8833 d.source.starts_with("A claim[^1] and more."),
8834 "{:?}",
8835 d.source
8836 );
8837 }
8838
8839 #[test]
8840 fn a_note_just_written_still_knows_where_its_reference_is() {
8841 // The authoring loop in one test: press the button, type the note, ask to
8842 // go back. The caret ends at the note's last byte — which is the *end* of
8843 // the definition's span, the one offset the query used to exclude — so
8844 // this is where the round trip either works or doesn't.
8845 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8846 d.caret = 7;
8847 d.insert_footnote();
8848 d.insert("the note");
8849 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8850 let back = d
8851 .footnote_definition_at_caret()
8852 .expect("still in the note we just typed");
8853 assert_eq!(back.label, "1");
8854 // …and following it lands on the reference's label, where a reader's
8855 // return leg lands.
8856 assert_eq!(back.offset, Some(9));
8857 assert_eq!(&d.source[9..10], "1");
8858 }
8859
8860 #[test]
8861 fn insert_footnote_takes_one_undo_for_both_halves() {
8862 // twig writes the pair as a single edit; the point of that is here.
8863 let before = "A claim and more.\n";
8864 let mut d = doc_with("fn_insert_undo", before);
8865 d.caret = 7;
8866 d.insert_footnote();
8867 assert_ne!(d.source, before);
8868 d.undo();
8869 assert_eq!(d.source, before, "one undo takes back both halves");
8870 }
8871
8872 #[test]
8873 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
8874 // HTML is authorable — it spells the inline marks — and has no footnote.
8875 // The refusal says so rather than writing brackets that would render as
8876 // brackets.
8877 let src = "<p>A claim.</p>\n";
8878 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
8879 assert!(!Capabilities::of(Format::Html).footnote);
8880 d.caret = 5;
8881 d.insert_footnote();
8882 assert_eq!(d.source, src, "nothing written");
8883 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
8884 }
8885
8886 #[test]
8887 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
8888 // The empty body is the one place this could go wrong: the definition
8889 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
8890 // byte early would draw up in the paragraph above the note it belongs to.
8891 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
8892 d.place_caret(7, false);
8893 d.insert_footnote();
8894 d.build_visual(80); // the frame a frontend draws after the edit
8895 assert_eq!(
8896 d.vmap.snap_to_stop(d.caret),
8897 d.caret,
8898 "the caret sits on a stop"
8899 );
8900 let (row, _) = d.caret_pos();
8901 assert!(
8902 drawn_rows(&d)[row].contains("[1]"),
8903 "the caret is on the note's row, not above it: {:?}",
8904 drawn_rows(&d)
8905 );
8906 }
8907
8908 #[test]
8909 fn footnote_at_caret_resolves_a_reference_to_its_note() {
8910 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
8911 // blank line, as one has to.
8912 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
8913 d.caret = 9;
8914 let f = d
8915 .footnote_at_caret()
8916 .expect("the caret stands in a reference");
8917 assert_eq!(f.label, "1");
8918 assert_eq!(f.text.as_deref(), Some("the note"));
8919 // The offset points at the note's first word, not at the definition's
8920 // `[` — the marker is decoration with no caret stop on it.
8921 assert_eq!(f.offset, Some(29));
8922 assert_eq!(&d.source[29..37], "the note");
8923 // …and `end` closes the range, so a frontend can ask which rendered rows
8924 // the note occupies rather than re-deriving them from the text.
8925 assert_eq!(f.end, Some(37));
8926 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
8927 }
8928
8929 /// Two definitions in a row: each is its own note, and neither reaches into
8930 /// the other.
8931 ///
8932 /// A djot definition's span used to run past the blank line into the first
8933 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
8934 /// offsets named the *next* note's rows too, showing a reader two footnotes
8935 /// when they had asked about one. twig 3.1 ends the span after the block's
8936 /// own last line; the test outlives the workaround leaf carried for it.
8937 #[test]
8938 fn footnote_at_stops_a_note_at_the_definition_after_it() {
8939 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
8940 for format in [Format::Markdown, Format::Djot] {
8941 let mut d = Doc::from_source(src.to_string(), format).unwrap();
8942 d.caret = 7;
8943 let f = d.footnote_at_caret().expect("a reference");
8944 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
8945 assert_eq!(
8946 &src[f.offset.unwrap()..f.end.unwrap()],
8947 "first note.",
8948 "in {format:?}"
8949 );
8950 }
8951 }
8952
8953 /// The other side of that boundary: a blank line *inside* a definition is
8954 /// interior to it, and the note keeps its second paragraph.
8955 ///
8956 /// This is what the old body scan cost. It stopped at the first line not
8957 /// indented under the note — a blank line is not — so a two-paragraph note
8958 /// came back as its first paragraph, and "go to note" framed half of it.
8959 /// Reading the span twig gives is both simpler and right.
8960 #[test]
8961 fn footnote_at_keeps_a_notes_second_paragraph() {
8962 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
8963 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8964 d.caret = 7;
8965 let f = d.footnote_at_caret().expect("a reference");
8966 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
8967 // And it stops there — `After.` is the next block, not more note.
8968 assert_eq!(
8969 &src[f.offset.unwrap()..f.end.unwrap()],
8970 f.text.as_deref().unwrap()
8971 );
8972 assert!(!f.text.as_deref().unwrap().contains("After"));
8973 }
8974
8975 #[test]
8976 fn footnote_at_bounds_a_note_whose_body_is_empty() {
8977 // `[^1]:` with nothing after it. The range is empty rather than
8978 // inverted, and still points inside the definition — which is what keeps
8979 // a frontend's row lookup from walking off into the block above.
8980 let src = "A claim[^1].\n\n[^1]:\n";
8981 let mut d = doc_with("fn_empty_body", src);
8982 d.caret = 9;
8983 let f = d.footnote_at_caret().expect("a reference");
8984 assert_eq!(f.text.as_deref(), Some(""));
8985 assert_eq!(f.offset, f.end, "an empty note is an empty range");
8986 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
8987 }
8988
8989 #[test]
8990 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
8991 let mut d = doc_with(
8992 "fn_at_caret_none",
8993 "A claim[^1] and more.\n\n[^1]: the note\n",
8994 );
8995 d.caret = 2; // in the prose
8996 assert_eq!(d.footnote_at_caret(), None);
8997 }
8998
8999 #[test]
9000 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
9001 // The two are deliberately separate: a reference names a note in this
9002 // document, a link names somewhere to leave for, and answering one with
9003 // the other is what made a reference click do nothing at all.
9004 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
9005 d.caret = 3; // the `1` of `[^1]`
9006 assert!(d.footnote_at_caret().is_some());
9007 assert_eq!(
9008 d.link_destination_at_caret(),
9009 None,
9010 "a reference is not a link"
9011 );
9012
9013 d.caret = 10; // inside the link's label
9014 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
9015 assert_eq!(
9016 d.link_destination_at_caret().as_deref(),
9017 Some("https://x.dev")
9018 );
9019 }
9020
9021 #[test]
9022 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
9023 // A `[^99]` the document never defines is a real state — a note deleted
9024 // out from under its reference — and the label is what lets a frontend
9025 // say so. `None` here would be indistinguishable from "not on a
9026 // reference", which is the wrong thing to tell a reader.
9027 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
9028 d.caret = 9;
9029 let f = d
9030 .footnote_at_caret()
9031 .expect("the reference is still a reference");
9032 assert_eq!(f.label, "99");
9033 assert_eq!(f.text, None);
9034 assert_eq!(f.offset, None);
9035 }
9036
9037 #[test]
9038 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
9039 // Labels are not always numbers, and a note's body runs past its first
9040 // line — the indented continuation belongs to the note, so it comes back
9041 // with it (source bytes, verbatim, as documented).
9042 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
9043 let mut d = doc_with("fn_word_label", src);
9044 d.caret = 6;
9045 let f = d
9046 .footnote_at_caret()
9047 .expect("the caret stands in a reference");
9048 assert_eq!(f.label, "note");
9049 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
9050 }
9051
9052 #[test]
9053 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
9054 // The point of the offset form: a pointer hovering a reference asks what
9055 // note it names, and must not drag the caret along to ask.
9056 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
9057 d.caret = 0;
9058 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
9059 assert_eq!(f.label, "1");
9060 assert_eq!(f.text.as_deref(), Some("the note"));
9061 assert_eq!(d.caret, 0, "asking must not move the caret");
9062 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
9063 }
9064
9065 #[test]
9066 fn footnote_definition_at_caret_points_back_at_the_reference() {
9067 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
9068 // the caret can rest on — is at 9.
9069 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
9070 d.caret = 30; // inside the note's body
9071 let f = d
9072 .footnote_definition_at_caret()
9073 .expect("the caret stands in a definition");
9074 assert_eq!(f.label, "1");
9075 assert_eq!(f.offset, Some(9));
9076 assert_eq!(&d.source[7..11], "[^1]");
9077 }
9078
9079 #[test]
9080 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
9081 // The two legs have to meet: wherever `footnote_at` sends the caret, the
9082 // definition query must answer for — otherwise arriving at a note leaves
9083 // the reader somewhere the way back isn't offered.
9084 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
9085 let mut d = doc_with("fn_def_marker", src);
9086 let landed = d.footnote_at(9).unwrap().offset.unwrap();
9087 assert_eq!(
9088 d.footnote_definition_at(landed).and_then(|f| f.offset),
9089 Some(9),
9090 "the note a reference sends you to offers the way back"
9091 );
9092 }
9093
9094 #[test]
9095 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
9096 // The two queries answer for disjoint places, which is what lets one
9097 // gesture mean "down to the note" in one and "back up" in the other
9098 // without either having to remember which way the reader is going.
9099 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
9100 d.caret = 2; // prose
9101 assert_eq!(d.footnote_definition_at_caret(), None);
9102 d.caret = 9; // the reference
9103 assert_eq!(d.footnote_definition_at_caret(), None);
9104 assert!(
9105 d.footnote_at_caret().is_some(),
9106 "which is the reference's own query"
9107 );
9108 }
9109
9110 #[test]
9111 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
9112 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
9113 // note", which is false and leaves a frontend unable to explain why the
9114 // way back is missing.
9115 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
9116 let mut d = doc_with("fn_def_orphan", src);
9117 d.caret = src.find("orphan").unwrap();
9118 let f = d
9119 .footnote_definition_at_caret()
9120 .expect("an orphan is still a definition");
9121 assert_eq!(f.label, "2");
9122 assert_eq!(f.offset, None);
9123 }
9124
9125 #[test]
9126 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
9127 // One label, cited twice. The first is where the reader most likely came
9128 // from, and the only answer that doesn't depend on how they got here.
9129 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
9130 let mut d = doc_with("fn_def_repeat", src);
9131 d.caret = src.find("the note").unwrap();
9132 let f = d.footnote_definition_at_caret().expect("a definition");
9133 assert_eq!(
9134 f.offset,
9135 Some(5),
9136 "the first `[^a]`'s label, not the second's"
9137 );
9138 assert_eq!(&src[3..7], "[^a]");
9139 }
9140
9141 #[test]
9142 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
9143 // Down and back up, each leg found from the document rather than from a
9144 // memory of the other — so it still works for a reader who scrolled to
9145 // the notes instead of jumping there.
9146 //
9147 // `place_caret` rather than assigning `caret`, because that is what a
9148 // frontend calls: it snaps to a real caret stop, and a jump that lands
9149 // on a byte the caret can't rest on would arrive somewhere the return
9150 // leg no longer answers for. `build_map` first, since snapping is a
9151 // no-op until the map exists — which is exactly how this went unnoticed
9152 // when the offsets pointed at the `[^` markers.
9153 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
9154 d.build_map(None);
9155 d.place_caret(9, false);
9156 let down = d
9157 .footnote_at_caret()
9158 .expect("a reference")
9159 .offset
9160 .expect("a note");
9161 d.place_caret(down, false);
9162 let up = d
9163 .footnote_definition_at_caret()
9164 .expect("a definition")
9165 .offset
9166 .expect("a reference");
9167 d.place_caret(up, false);
9168 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
9169 assert_eq!(
9170 d.footnote_at_caret().expect("back on the reference").label,
9171 "1"
9172 );
9173 }
9174
9175 #[test]
9176 fn insert_link_hands_the_destination_to_twig_raw() {
9177 // Escaping is twig's, and format-specific: Markdown ends a destination
9178 // at the first space and needs the `<…>` form, where djot would read
9179 // those angle brackets as part of the URL.
9180 let mut d = doc_with("link_space", "word\n");
9181 d.anchor = Some(0);
9182 d.caret = 4;
9183 d.insert_link("a b");
9184 assert_eq!(d.source, "[word](<a b>)\n");
9185 }
9186
9187 #[test]
9188 fn insert_link_reports_a_destination_no_format_can_carry() {
9189 let mut d = doc_with("link_bad", "word\n");
9190 d.anchor = Some(0);
9191 d.caret = 4;
9192 d.insert_link("a\nb");
9193 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
9194 assert!(
9195 d.status.is_some(),
9196 "InvalidArgument should reach the status line"
9197 );
9198 assert!(!d.dirty);
9199 }
9200
9201 #[test]
9202 fn insert_link_works_in_wysiwyg_view() {
9203 let mut d = wysiwyg_doc("link_wys", "word here\n");
9204 d.anchor = Some(0);
9205 d.caret = 4;
9206 d.insert_link("http://x.dev");
9207 assert_eq!(d.source, "[word](http://x.dev) here\n");
9208 assert_eq!(d.selected_text(), Some("word"));
9209 // The map the caret has to keep riding is rebuilt each frame; motion
9210 // over the fresh one must still land on a real stop (the debug_assert).
9211 d.build_visual(80);
9212 d.move_right(false);
9213 d.move_left(false);
9214 }
9215
9216 #[test]
9217 fn click_maps_a_row_col_to_a_byte_offset() {
9218 let mut d = doc_with("click", "ab\ncd\n");
9219 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
9220 assert_eq!(d.caret, 4);
9221 }
9222
9223 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
9224 // stop just as the `(row, col)` click path does, so the caret can never come
9225 // to rest in the blank gap between two paragraphs — where it would draw in one
9226 // place and type in another.
9227 #[test]
9228 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
9229 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
9230 // caret stop (stops are 0,1,3,4).
9231 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
9232 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
9233 d.place_caret(2, false);
9234 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9235 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
9236 }
9237
9238 #[test]
9239 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
9240 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
9241 d.place_caret(0, false); // anchor at the start of "A"
9242 d.place_caret(2, true); // drag into the gap
9243 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9244 let (s, e) = d.selection().expect("a selection");
9245 assert!(
9246 d.vmap.is_stop(s) && d.vmap.is_stop(e),
9247 "selection {s}..{e} off a stop"
9248 );
9249 }
9250
9251 #[test]
9252 fn place_caret_on_a_real_stop_is_left_untouched() {
9253 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
9254 d.place_caret(3, false); // the start of "B" — a genuine stop
9255 assert_eq!(d.caret, 3);
9256 }
9257
9258 // An *empty paragraph* (two blank lines, an intentional blank line the user
9259 // opened) is a real caret stop, unlike the gap — a click into it must stay.
9260 #[test]
9261 fn place_caret_rests_in_an_empty_paragraph() {
9262 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
9263 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
9264 assert!(d.vmap.is_stop(empty));
9265 d.place_caret(empty, false);
9266 assert_eq!(d.caret, empty);
9267 }
9268
9269 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
9270 doc_in(View::Wysiwyg, name, body)
9271 }
9272
9273 /// How many list items the source actually parses into — the check that a
9274 /// marker Leaf wrote is a marker the format agrees is one.
9275 fn list_items(doc: &mut Doc) -> usize {
9276 doc.editor
9277 .nodes()
9278 .unwrap()
9279 .iter()
9280 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9281 .count()
9282 }
9283
9284 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9285 /// incremental (`build_spliced` / `build_cached`) path must always match.
9286 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9287 reference_map_revealing(source, None)
9288 }
9289
9290 /// [`reference_map`] with a reveal line — the ground truth for the
9291 /// `MarkupMode::Full` builds, where the map is a function of the caret's
9292 /// line as well as the text.
9293 fn reference_map_revealing(
9294 source: &str,
9295 reveal: Option<Range<usize>>,
9296 ) -> crate::wysiwyg::VisualMap {
9297 // The same parse `Doc` uses. With twig's plain defaults instead, the two
9298 // sides disagree on what the *document* is before the renderer is even
9299 // reached — a bare `:word` is a text directive to one and prose to the
9300 // other — and the mismatch reads as a splice bug that isn't one.
9301 let mut ed =
9302 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9303 let nodes = ed.nodes().unwrap();
9304 crate::wysiwyg::build(
9305 &nodes,
9306 source,
9307 None,
9308 false,
9309 &std::collections::HashMap::new(),
9310 reveal,
9311 )
9312 }
9313
9314 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
9315 if a.rows.len() != b.rows.len() {
9316 return true;
9317 }
9318 for (ra, rb) in a.rows.iter().zip(&b.rows) {
9319 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
9320 return true;
9321 }
9322 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
9323 if ga.ch != gb.ch || ga.src != gb.src {
9324 return true;
9325 }
9326 }
9327 }
9328 false
9329 }
9330
9331 #[test]
9332 fn incremental_build_matches_a_fresh_build_across_edits() {
9333 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
9334 // fast path, gated on twig's `dirty_range`) or falls back to
9335 // `build_cached`. After each edit the map must be byte-identical to a
9336 // from-scratch build — this is the correctness net under the splice.
9337 let docs = [
9338 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
9339 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
9340 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
9341 // A footnote definition is a root beside `doc`, merged back into the
9342 // top-level list by `wysiwyg::top_blocks`. The random edits below
9343 // make and unmake definitions as they go (a deleted `:` turns one
9344 // back into a paragraph, and vice versa), which is exactly the
9345 // structural churn the splice path has to notice and bail out of.
9346 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
9347 ];
9348 // A deterministic mix: mostly single characters (which stay inside one
9349 // block → splice), plus edits that reshape structure (a paragraph break,
9350 // a heading marker, a code fence → fallback), so both paths are exercised.
9351 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
9352 for src in docs {
9353 let mut d = wysiwyg_doc("diff", src);
9354 d.build_visual_unwrapped();
9355 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
9356
9357 for step in 0..60usize {
9358 let len = d.source.len();
9359 let raw = (step * 13 + 5) % (len + 1);
9360 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9361 let pre = d.source.clone();
9362 let action;
9363 if step % 3 == 0 && pos < len {
9364 let end = (pos + 1..=len)
9365 .find(|&i| d.source.is_char_boundary(i))
9366 .unwrap();
9367 action = format!("delete [{pos},{end})");
9368 d.edit(pos, end, "");
9369 } else {
9370 let ins = inserts[step % inserts.len()];
9371 action = format!("insert {ins:?} @ {pos}");
9372 d.edit(pos, pos, ins);
9373 }
9374 d.build_visual_unwrapped();
9375 if maps_differ(&d.vmap, &reference_map(&d.source)) {
9376 panic!(
9377 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
9378 d.source
9379 );
9380 }
9381 }
9382 }
9383 }
9384
9385 #[test]
9386 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
9387 // The same correctness net as `incremental_build_matches_a_fresh_build_
9388 // across_edits`, under `MarkupMode::Full` — where the map depends on
9389 // the caret's *line* as well as the text, so the two caches have a new
9390 // way to be wrong. Both are exercised: the block cache can hand back
9391 // rows built for a line that is no longer the revealed one, and the
9392 // splice path can reuse a suffix that still has yesterday's line raw.
9393 //
9394 // Caret motion is interleaved with the edits deliberately, because a
9395 // caret that only ever moved with the edit would never cross a line
9396 // without also dirtying it — the case where a stale reveal survives.
9397 let docs = [
9398 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
9399 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
9400 ];
9401 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
9402 for src in docs {
9403 let mut d = wysiwyg_doc("reveal_diff", src);
9404 d.set_markup_mode(MarkupMode::Full);
9405
9406 for step in 0..60usize {
9407 let len = d.source.len();
9408 let raw = (step * 13 + 5) % (len + 1);
9409 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9410 let pre = d.source.clone();
9411 let action;
9412 if step % 3 == 0 && pos < len {
9413 let end = (pos + 1..=len)
9414 .find(|&i| d.source.is_char_boundary(i))
9415 .unwrap();
9416 action = format!("delete [{pos},{end})");
9417 d.edit(pos, end, "");
9418 } else {
9419 let ins = inserts[step % inserts.len()];
9420 action = format!("insert {ins:?} @ {pos}");
9421 d.edit(pos, pos, ins);
9422 }
9423 // Walk the caret somewhere else in the document, independently
9424 // of where the edit landed.
9425 let want = (step * 29 + 11) % (d.source.len() + 1);
9426 d.caret = (want..=d.source.len())
9427 .find(|&i| d.source.is_char_boundary(i))
9428 .unwrap();
9429 d.build_visual_unwrapped();
9430
9431 let want = reference_map_revealing(&d.source, d.reveal_line());
9432 if maps_differ(&d.vmap, &want) {
9433 panic!(
9434 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
9435 d.caret, d.source
9436 );
9437 }
9438 }
9439 }
9440 }
9441
9442 #[test]
9443 fn caret_motion_across_lines_rebuilds_only_under_full() {
9444 // The cache-key change has to earn its keep in both directions: `Full`
9445 // must rebuild when the caret changes line (or the reveal would never
9446 // move), and the hidden modes must *not* (or every arrow key would pay
9447 // for a feature they don't use). The existing `cache_motion` test pins
9448 // the second for the default mode; this pins the pair against a mode
9449 // change alone.
9450 let body = "*one* here\n\n*two* there\n";
9451
9452 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9453 full.set_markup_mode(MarkupMode::Full);
9454 caret_at(&mut full, "one");
9455 let before = full.revision();
9456 caret_at(&mut full, "two");
9457 assert_eq!(full.revision(), before, "motion is not an edit");
9458 assert!(
9459 drawn_rows(&full).iter().any(|r| r == "*two* there"),
9460 "the map followed the caret: {:?}",
9461 drawn_rows(&full)
9462 );
9463
9464 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9465 caret_at(&mut hidden, "one");
9466 let key = hidden.vmap_key.clone();
9467 caret_at(&mut hidden, "two");
9468 assert_eq!(
9469 hidden.vmap_key, key,
9470 "a hidden mode rebuilds nothing on motion"
9471 );
9472 }
9473
9474 #[test]
9475 fn wysiwyg_down_crosses_a_paragraph_boundary() {
9476 // Regression: the blank separator row used to share the previous
9477 // paragraph's end offset, so Down got pinned at the boundary (while Up
9478 // still crossed). Both directions must step through it symmetrically.
9479 //
9480 // It's now stepped *over* rather than onto: the blank line between two
9481 // paragraphs is the boundary being drawn, not a line of the document, so
9482 // one press of Down crosses it. The goal column survives the crossing —
9483 // col 3 at the end of "abc" is col 3 at the end of "def".
9484 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9485 d.caret = 3; // end of "abc" (row 0)
9486 d.move_down(false);
9487 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9488 assert_eq!(d.caret, 8); // end of "def", col 3 kept
9489 d.move_up(false);
9490 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9491 assert_eq!(d.caret, 3);
9492 }
9493
9494 #[test]
9495 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
9496 // The second Up and the second Down here run off the ends of the
9497 // document, which is no longer a place a press is swallowed: they carry
9498 // the caret to the start and the end of the text. The claim in the
9499 // middle — that a Down retraces the Up that crossed the paragraph gap —
9500 // is the one this test is for, and it is asserted where it is made.
9501 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
9502 d.caret = 5; // start of "def"
9503 let start = d.caret_pos();
9504 d.move_up(false);
9505 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
9506 d.move_up(false);
9507 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
9508 d.move_down(false);
9509 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
9510 d.move_down(false);
9511 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
9512 }
9513
9514 #[test]
9515 fn wysiwyg_new_paragraph_shows_before_typing() {
9516 // Regression: two Enters at the end of a paragraph produced trailing
9517 // newlines with no AST node, so the caret appeared stuck on the old line
9518 // until a character was typed. It must ride down onto the new line now.
9519 let mut d = doc_with("wys_newpara", "abc\n");
9520 d.view = View::Wysiwyg;
9521 d.caret = 3;
9522 d.insert("\n");
9523 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
9524 assert_eq!(d.source, "abc\n\n\n");
9525 d.build_visual(80);
9526 let (row, _) = d.caret_pos();
9527 assert!(
9528 row >= 2,
9529 "caret should have moved down to the new line, got row {row}"
9530 );
9531 assert!(
9532 d.vmap.num_rows() >= 3,
9533 "the blank lines should render as rows"
9534 );
9535 }
9536
9537 #[test]
9538 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
9539 // The reported bug: Enter at the end of a paragraph that has another
9540 // paragraph below put the caret at the *start of the next paragraph* —
9541 // the empty paragraph it opened had no row, so the caret snapped onto
9542 // "World". It must now sit on its own empty line, with a blank spacer
9543 // above it (the paragraph gap).
9544 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
9545 d.caret = 5; // end of "Hello"
9546 d.newline();
9547 d.build_visual(80);
9548 let (row, col) = d.caret_pos();
9549 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
9550 assert_eq!(
9551 d.vmap.row_width(row),
9552 0,
9553 "caret's row must be empty, not 'World'"
9554 );
9555 assert!(
9556 row >= 2,
9557 "a blank spacer row should sit above the caret, got row {row}"
9558 );
9559 // The row above the caret is a real (empty) gap, and "Hello" stays put.
9560 assert_eq!(
9561 d.vmap.row_width(row - 1),
9562 0,
9563 "the row above the caret is a gap"
9564 );
9565 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9566 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
9567 }
9568
9569 #[test]
9570 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
9571 // At the document end a single Enter must also show the paragraph gap —
9572 // a blank spacer row above the caret — so the layout already matches how
9573 // it will look once the new paragraph has text.
9574 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
9575 d.caret = 5; // end of "Hello", no trailing newline
9576 d.newline(); // source becomes "Hello\n\n"
9577 d.build_visual(80);
9578 let (row, col) = d.caret_pos();
9579 assert_eq!(col, 0);
9580 assert!(
9581 row >= 2,
9582 "caret should sit below a blank spacer, got row {row}"
9583 );
9584 assert_eq!(
9585 d.vmap.row_width(row - 1),
9586 0,
9587 "the row above the caret is a gap"
9588 );
9589 }
9590
9591 #[test]
9592 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
9593 // The spacer is view-only: typing the new paragraph must not reflow the
9594 // caret onto a different row — the transient view already matched the
9595 // settled one.
9596 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
9597 d.caret = 5;
9598 d.newline();
9599 d.build_visual(80);
9600 let before = d.caret_pos();
9601 d.insert("New");
9602 d.build_visual(80);
9603 let after = d.caret_pos();
9604 assert_eq!(
9605 after.0, before.0,
9606 "typing must not move the caret to another row ({before:?} -> {after:?})"
9607 );
9608 }
9609
9610 #[test]
9611 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
9612 let fm = "---\ntitle: hi\n---\n";
9613 let body = format!("{fm}# leaf\n\nbody\n");
9614 let mut d = wysiwyg_doc("wys_fm", &body);
9615 // Opening lifts the caret out of the now-hidden frontmatter.
9616 assert_eq!(
9617 d.caret,
9618 fm.len(),
9619 "caret should start at the first real block"
9620 );
9621 // Left at the content start can't step back into frontmatter.
9622 d.move_left(false);
9623 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
9624 // Doc-start lands on the content floor, not offset 0.
9625 d.move_doc_start(false);
9626 assert_eq!(d.caret, fm.len());
9627 // Select-all + copy never include the frontmatter bytes.
9628 d.select_all();
9629 let sel = d.selected_text().unwrap().to_string();
9630 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
9631 assert!(
9632 sel.starts_with("# leaf"),
9633 "selection should begin at content: {sel:?}"
9634 );
9635 }
9636
9637 #[test]
9638 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
9639 // A fresh note is frontmatter and nothing else. With no rendered block
9640 // to floor the caret it opened at offset 0 — before the opening `---` —
9641 // so the first keystroke wrote itself in front of the metadata and the
9642 // file came out as `This---\ntitle: …`.
9643 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
9644 let mut d = wysiwyg_doc("wys_fm_only", fm);
9645 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
9646 // Nothing is rendered, so the caret draws at the origin of an empty view
9647 // — the same place an empty document puts it.
9648 assert_eq!(d.caret_pos(), (0, 0));
9649 d.insert("This");
9650 assert_eq!(d.source, format!("{fm}This"));
9651 }
9652
9653 /// `select_range` is the verb for a range a host already knows the bytes of,
9654 /// so it must not snap — and must still hold every invariant `place_caret`
9655 /// holds, the frontmatter floor above all.
9656 #[test]
9657 fn select_range_takes_the_range_as_given_but_still_floors_it() {
9658 let fm = "---\ntitle: foo\n---\n\n";
9659 let body = format!("{fm}body foo here\n");
9660 let mut d = wysiwyg_doc("wys_select_range", &body);
9661
9662 // The `foo` in the body: taken exactly, not snapped to a caret stop.
9663 let at = body.rfind("foo").unwrap();
9664 d.select_range(at, at + 3);
9665 assert_eq!(d.selection(), Some((at, at + 3)));
9666 assert_eq!(d.selected_text(), Some("foo"));
9667
9668 // The `foo` in the hidden frontmatter: below the floor, so both ends
9669 // come up to it rather than parking the caret in the metadata, where a
9670 // later keystroke would rewrite `title:`.
9671 let hidden = body.find("foo").unwrap();
9672 assert!(hidden < d.vmap.content_start);
9673 d.select_range(hidden, hidden + 3);
9674 assert!(
9675 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
9676 "a range under the floor must not leave the caret in the frontmatter"
9677 );
9678
9679 // Past the end, and mid-character, are both brought back to something
9680 // sliceable rather than panicking the next reader of the range.
9681 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
9682 let mut d = multi;
9683 d.select_range(2, 9_999);
9684 assert_eq!(d.caret, d.source.len());
9685 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
9686 assert!(d.source.is_char_boundary(d.caret));
9687 }
9688
9689 /// The bug `select_range` exists for: a match butting up against a hidden
9690 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
9691 /// the one before the `**`.
9692 #[test]
9693 fn select_range_does_not_snap_off_a_hidden_delimiter() {
9694 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
9695 let at = d.source.find("needle").unwrap();
9696 d.select_range(at, at + 6);
9697 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
9698 }
9699
9700 #[test]
9701 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
9702 // Backspace deletes `prev_boundary..caret` directly; at the first real
9703 // block that boundary is inside the hidden frontmatter, so it must be a
9704 // no-op rather than eating the closing `---`.
9705 let fm = "---\ntitle: hi\n---\n";
9706 let body = format!("{fm}leaf\n");
9707 let mut d = wysiwyg_doc("wys_fm_bs", &body);
9708 assert_eq!(d.caret, fm.len());
9709 d.backspace();
9710 assert_eq!(d.source, body, "backspace must not touch frontmatter");
9711 d.delete_word_back();
9712 assert_eq!(
9713 d.source, body,
9714 "word-delete must not touch frontmatter either"
9715 );
9716 }
9717
9718 #[test]
9719 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
9720 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
9721 // fenced div, really, since core parses these on for every document
9722 // now (`parse_extensions`). The container is a `directive` node, an
9723 // `is_block_container` kind like `block_quote`, so the caret works
9724 // inside its child paragraph exactly as it would inside a quote: typing
9725 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
9726 // untouched.
9727 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
9728 let mut d = wysiwyg_doc("wys_vis", body);
9729 d.caret = body.find("hello").unwrap() + "hello".len();
9730 d.insert("!");
9731 assert_eq!(
9732 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
9733 "typing inside the block edits its content in place"
9734 );
9735 assert!(
9736 d.source.contains(":::vis{.public .family}"),
9737 "opening fence survives"
9738 );
9739 assert!(d.source.contains(":::\nafter"), "closing fence survives");
9740 }
9741
9742 #[test]
9743 fn source_view_still_reaches_frontmatter() {
9744 // The metadata is only *hidden*, never lost: the source view edits and
9745 // selects it in full, and it's always preserved on save.
9746 let fm = "---\ntitle: hi\n---\n";
9747 let body = format!("{fm}# leaf\n");
9748 let mut d = doc_with("src_fm", &body);
9749 d.select_all();
9750 let sel = d.selected_text().unwrap();
9751 assert!(
9752 sel.contains("title"),
9753 "source view should select everything"
9754 );
9755 d.move_doc_start(false);
9756 assert_eq!(d.caret, 0, "source view can reach offset 0");
9757 }
9758
9759 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
9760
9761 #[test]
9762 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
9763 // The border and padding between two cells all share one source offset,
9764 // so a column-stepping caret would sit on `│` and then stall there
9765 // forever. Right must step: end of "Name" -> start of "Qty".
9766 let mut d = wysiwyg_doc("tbl_right", TABLE);
9767 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
9768 d.move_right(false);
9769 assert_eq!(
9770 d.caret,
9771 TABLE.find("Qty").unwrap(),
9772 "should land in the next cell"
9773 );
9774 let (r, c) = d.caret_pos();
9775 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
9776 }
9777
9778 #[test]
9779 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
9780 let mut d = wysiwyg_doc("tbl_left", TABLE);
9781 d.caret = TABLE.find("Qty").unwrap();
9782 d.move_left(false);
9783 assert_eq!(
9784 d.caret,
9785 TABLE.find("Name").unwrap() + 4,
9786 "end of the previous cell"
9787 );
9788 }
9789
9790 #[test]
9791 fn wysiwyg_down_steps_over_a_table_rule() {
9792 // Between the header and the first body row sits a `├───┼───┤` rule.
9793 // It's drawn but holds no caret, so one Down must reach "Pear".
9794 let mut d = wysiwyg_doc("tbl_down", TABLE);
9795 d.caret = TABLE.find("Name").unwrap();
9796 d.move_down(false);
9797 assert_eq!(
9798 d.caret,
9799 TABLE.find("Pear").unwrap(),
9800 "one Down reaches the body row"
9801 );
9802 d.move_down(false);
9803 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
9804 }
9805
9806 #[test]
9807 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
9808 let mut d = wysiwyg_doc("tbl_tab", TABLE);
9809 d.caret = TABLE.find("Name").unwrap();
9810 // A hop lands with the destination cell's whole content selected, the
9811 // caret at its end — so typing replaces the cell like a form field.
9812 assert!(d.cell_hop(true));
9813 assert_eq!(
9814 d.selected_text(),
9815 Some("Qty"),
9816 "the target cell comes up selected"
9817 );
9818 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
9819 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
9820 assert_eq!(d.selected_text(), Some("Pear"));
9821 assert!(d.cell_hop(false));
9822 assert_eq!(d.selected_text(), Some("Qty"));
9823 }
9824
9825 #[test]
9826 fn tab_outside_a_table_is_not_a_cell_hop() {
9827 // `cell_hop` reports false so the frontend can indent as usual.
9828 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
9829 d.caret = 4;
9830 assert!(!d.cell_hop(true));
9831 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
9832 }
9833
9834 #[test]
9835 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
9836 let mut d = wysiwyg_doc("tbl_edge", TABLE);
9837 d.caret = TABLE.rfind("12").unwrap(); // the final cell
9838 assert!(!d.cell_hop(true), "no cell after the last one");
9839 d.caret = TABLE.find("Name").unwrap();
9840 assert!(!d.cell_hop(false), "no cell before the first one");
9841 }
9842
9843 #[test]
9844 fn wysiwyg_vertical_cell_motion_holds_the_column() {
9845 // Down/Up step to the cell above/below in the *same column*, not back to
9846 // the top-left the way a naive row/col motion over the picture would.
9847 let mut d = wysiwyg_doc("tbl_vert", TABLE);
9848 d.caret = TABLE.find("Qty").unwrap();
9849 // Each vertical hop selects the destination cell, holding the column.
9850 assert!(d.cell_move_vertical(true));
9851 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
9852 assert!(d.cell_move_vertical(true));
9853 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
9854 assert!(!d.cell_move_vertical(true), "no row below the last");
9855 assert!(d.cell_move_vertical(false));
9856 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
9857 assert!(d.cell_move_vertical(false));
9858 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
9859 assert!(!d.cell_move_vertical(false), "no row above the header");
9860 }
9861
9862 #[test]
9863 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
9864 let mut d = wysiwyg_doc("tbl_grow", TABLE);
9865 d.caret = TABLE.rfind("12").unwrap();
9866 let rows_before = d.source.matches('\n').count();
9867 assert!(d.cell_tab(true), "acts as a table key");
9868 assert_eq!(
9869 d.source.matches('\n').count(),
9870 rows_before + 1,
9871 "a fresh row was appended"
9872 );
9873 assert!(d.caret_in_table(), "the caret entered the new row");
9874 // The caret sits in the new row's first cell — past the old last cell.
9875 assert!(d.caret > TABLE.rfind("12").unwrap());
9876 }
9877
9878 #[test]
9879 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
9880 let mut d = wysiwyg_doc("tbl_ret", TABLE);
9881 d.caret = TABLE.find("Name").unwrap();
9882 assert!(d.cell_return(), "acts as a table key");
9883 assert_eq!(
9884 d.selected_text(),
9885 Some("Pear"),
9886 "Return drops one cell, selecting it"
9887 );
9888 // From the last row, Return appends a row and enters it.
9889 d.caret = TABLE.rfind("Fig").unwrap();
9890 let rows_before = d.source.matches('\n').count();
9891 assert!(d.cell_return());
9892 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
9893 assert!(d.caret_in_table());
9894 }
9895
9896 #[test]
9897 fn return_and_tab_outside_a_table_decline() {
9898 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
9899 d.caret = 4;
9900 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
9901 assert!(!d.cell_tab(true), "no table: the frontend indents");
9902 assert!(
9903 !d.cell_line_break(),
9904 "no table: the frontend breaks the line"
9905 );
9906 }
9907
9908 #[test]
9909 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
9910 let mut d = wysiwyg_doc("tbl_break", TABLE);
9911 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
9912 assert!(d.cell_line_break(), "acts as a table key");
9913 assert!(
9914 d.source.contains("Pear<br>"),
9915 "spelled as an inline <br>: {}",
9916 d.source
9917 );
9918 assert!(d.caret_in_table(), "still in the cell, past the break");
9919 // The break renders as a real line: the "Pear" cell now draws two lines,
9920 // so the table's picture is one row taller than a single-line table.
9921 d.build_visual(80);
9922 let table = &d.vmap.tables[0];
9923 let cell = &table.grid[1].cells[0]; // first body row, first column
9924 assert!(
9925 cell.glyphs.iter().any(|g| g.ch == '\n'),
9926 "the cell carries the break as a newline glyph for the frontend to split"
9927 );
9928 }
9929
9930 #[test]
9931 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
9932 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
9933 // back as structure — the whole point of routing through insert_line_break
9934 // instead of splicing raw `<br>` bytes.
9935 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
9936 d.caret = TABLE.find("Pear").unwrap() + 4;
9937 assert!(d.cell_line_break());
9938 let kinds: Vec<Kind> = d
9939 .editor
9940 .nodes()
9941 .unwrap()
9942 .iter()
9943 .map(|n| n.kind.clone())
9944 .collect();
9945 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
9946 assert!(
9947 !kinds.contains(&Kind::RawInline),
9948 "still raw HTML: {kinds:?}"
9949 );
9950 }
9951
9952 #[test]
9953 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
9954 // The `<br>` draws as one newline glyph, so Backspace over it must take
9955 // all four bytes — a one-byte delete would strand a visible `<br` in the
9956 // cell (the reported bug).
9957 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
9958 d.caret = TABLE.find("Pear").unwrap() + 4;
9959 assert!(d.cell_line_break());
9960 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
9961 d.backspace(); // caret sits just past the break
9962 assert!(
9963 !d.source.contains("<br"),
9964 "no half-deleted <br left: {}",
9965 d.source
9966 );
9967 assert!(
9968 d.source.contains("| Pear |"),
9969 "the cell is back to one line: {}",
9970 d.source
9971 );
9972 }
9973
9974 #[test]
9975 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
9976 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
9977 d.caret = TABLE.find("Pear").unwrap() + 4;
9978 assert!(d.cell_line_break());
9979 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
9980 d.delete_forward();
9981 assert!(
9982 !d.source.contains("<br"),
9983 "no half-deleted <br: {}",
9984 d.source
9985 );
9986 assert!(
9987 d.source.contains("| Pear |"),
9988 "cell back to one line: {}",
9989 d.source
9990 );
9991 }
9992
9993 #[test]
9994 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
9995 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
9996 // still consumed (a real newline would split the one-line row), but the
9997 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
9998 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
9999 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10000 d.caret = src.find("Pear").unwrap() + 4;
10001 assert!(d.caret_in_table(), "caret should be inside the djot table");
10002 assert!(
10003 d.cell_line_break(),
10004 "the key is consumed, not passed to the frontend"
10005 );
10006 assert_eq!(d.source, src, "the djot cell is left untouched");
10007 assert!(
10008 !d.source.contains("<br>"),
10009 "no non-idiomatic <br> spliced into djot"
10010 );
10011 assert!(
10012 d.status.is_some(),
10013 "the refusal is surfaced on the status line"
10014 );
10015 }
10016
10017 #[test]
10018 fn typing_in_a_cell_edits_that_cell() {
10019 // Editing comes free once offsets map correctly: the caret is a source
10020 // offset, so a normal splice lands inside the pipe table.
10021 let mut d = wysiwyg_doc("tbl_type", TABLE);
10022 d.caret = TABLE.find("Pear").unwrap() + 4;
10023 d.insert("s");
10024 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
10025 }
10026
10027 #[test]
10028 fn motion_and_delete_treat_an_emoji_as_one_character() {
10029 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
10030 // bytes, several codepoints. Right-arrow must clear it in one step, and
10031 // backspace must remove the whole cluster, not a stray joiner.
10032 let family = "👨👩👧";
10033 let mut d = doc_with("emoji", &format!("a{family}b\n"));
10034 d.caret = 1; // just after 'a', before the emoji
10035 d.move_right(false);
10036 assert_eq!(
10037 d.caret,
10038 1 + family.len(),
10039 "one step clears the whole cluster"
10040 );
10041 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
10042
10043 d.backspace(); // delete the emoji as a unit
10044 assert_eq!(d.source, "ab\n");
10045 assert_eq!(d.caret, 1);
10046 }
10047
10048 #[test]
10049 fn motion_handles_a_combining_accent_as_one_character() {
10050 // "e" + U+0301 (combining acute) renders as one é.
10051 let mut d = doc_with("combining", "e\u{0301}x\n");
10052 d.caret = 0;
10053 d.move_right(false);
10054 assert_eq!(
10055 d.caret,
10056 "e\u{0301}".len(),
10057 "steps past base + combining mark"
10058 );
10059 }
10060
10061 #[test]
10062 fn undo_then_redo_round_trips_an_edit() {
10063 let mut d = doc_with("undo", "hello\n");
10064 d.caret = 5;
10065 d.insert("!");
10066 assert_eq!(d.source, "hello!\n");
10067 d.undo();
10068 assert_eq!(d.source, "hello\n");
10069 assert_eq!(d.caret, 5, "undo restores the caret");
10070 d.redo();
10071 assert_eq!(d.source, "hello!\n");
10072 }
10073
10074 #[test]
10075 fn a_run_of_typing_undoes_as_one_step() {
10076 let mut d = doc_with("coalesce", "\n");
10077 d.caret = 0;
10078 d.insert("a");
10079 d.insert("b");
10080 d.insert("c");
10081 assert_eq!(d.source, "abc\n");
10082 d.undo(); // the whole typed run, not just "c"
10083 assert_eq!(d.source, "\n");
10084 d.undo(); // nothing left — the run was one step
10085 assert_eq!(d.source, "\n");
10086 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10087 }
10088
10089 // ── IME composition ──────────────────────────────────────────────────────
10090
10091 #[test]
10092 fn a_composition_run_undoes_as_one_step() {
10093 let mut d = doc_with("compose", "\n");
10094 d.caret = 0;
10095 // What an IME does: each step replaces the last one's provisional bytes.
10096 d.edit_composing(0, 0, "k");
10097 d.edit_composing(0, 1, "か");
10098 d.edit_composing(0, 3, "かん");
10099 d.edit_composing(0, 6, "感"); // the commit
10100 d.end_composition();
10101 assert_eq!(d.source, "感\n");
10102 d.undo(); // the whole composition, not its last keystroke
10103 assert_eq!(d.source, "\n");
10104 assert_eq!(d.status.as_deref(), None, "the run was a single step");
10105 }
10106
10107 #[test]
10108 fn two_compositions_are_two_undo_steps() {
10109 let mut d = doc_with("compose_two", "\n");
10110 d.caret = 0;
10111 d.edit_composing(0, 0, "か");
10112 d.edit_composing(0, 3, "蚊");
10113 d.end_composition();
10114 d.edit_composing(3, 3, "き");
10115 d.edit_composing(3, 6, "木");
10116 d.end_composition();
10117 assert_eq!(d.source, "蚊木\n");
10118 d.undo();
10119 assert_eq!(d.source, "蚊\n", "only the second composition");
10120 d.undo();
10121 assert_eq!(d.source, "\n");
10122 }
10123
10124 #[test]
10125 fn a_composition_does_not_fold_into_the_typing_around_it() {
10126 let mut d = doc_with("compose_typing", "\n");
10127 d.caret = 0;
10128 d.insert("a");
10129 d.insert("b");
10130 d.edit_composing(2, 2, "か");
10131 d.edit_composing(2, 5, "蚊");
10132 d.end_composition();
10133 d.insert("c");
10134 assert_eq!(d.source, "ab蚊c\n");
10135 d.undo();
10136 assert_eq!(d.source, "ab蚊\n");
10137 d.undo();
10138 assert_eq!(d.source, "ab\n");
10139 d.undo();
10140 assert_eq!(d.source, "\n");
10141 }
10142
10143 #[test]
10144 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
10145 let mut d = doc_with("compose_spurious", "\n");
10146 d.caret = 0;
10147 d.insert("a");
10148 d.end_composition(); // an IME unmarking unprompted
10149 d.insert("b");
10150 assert_eq!(d.source, "ab\n");
10151 d.undo();
10152 assert_eq!(d.source, "\n", "still one typed run");
10153 }
10154
10155 // ── the clipboard's rich flavor ──────────────────────────────────────────
10156
10157 #[test]
10158 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
10159 let mut d = doc_with("sel_inline", "a **bold** c\n");
10160 d.anchor = Some(2);
10161 d.caret = 10; // `**bold**`, inside the paragraph
10162 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
10163 }
10164
10165 #[test]
10166 fn a_whole_block_selection_keeps_its_paragraph() {
10167 let mut d = doc_with("sel_block", "a **bold** c\n");
10168 d.anchor = Some(0);
10169 d.caret = 12; // the entire paragraph
10170 assert_eq!(
10171 d.selection_html().as_deref(),
10172 Some("<p>a <strong>bold</strong> c</p>")
10173 );
10174 }
10175
10176 #[test]
10177 fn a_multi_block_selection_keeps_its_structure() {
10178 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
10179 d.select_all();
10180 let html = d.selection_html().expect("renders");
10181 assert!(html.contains("<p>para</p>"), "{html:?}");
10182 assert!(html.contains("<li>one</li>"), "{html:?}");
10183 }
10184
10185 #[test]
10186 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
10187 // The fragment `Head` is a paragraph standalone; the *document* says it
10188 // sits inside one block, so the wrapper is an artifact either way.
10189 let mut d = doc_with("sel_heading", "# Head line\n");
10190 d.anchor = Some(2);
10191 d.caret = 6;
10192 assert_eq!(d.selection_html().as_deref(), Some("Head"));
10193 }
10194
10195 #[test]
10196 fn no_selection_publishes_no_html() {
10197 let mut d = doc_with("sel_none", "a b\n");
10198 d.caret = 1;
10199 assert_eq!(d.selection_html(), None);
10200 }
10201
10202 #[test]
10203 fn pasting_html_converts_it_and_is_one_undo_step() {
10204 let mut d = doc_with("paste_html", "x\n");
10205 d.caret = 1;
10206 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
10207 assert_eq!(d.source, "xa **b** c\n");
10208 d.undo();
10209 assert_eq!(d.source, "x\n", "the whole paste, in one step");
10210 }
10211
10212 #[test]
10213 fn pasting_html_replaces_the_selection() {
10214 let mut d = doc_with("paste_html_sel", "keep drop\n");
10215 d.anchor = Some(5);
10216 d.caret = 9;
10217 assert!(d.paste_html("<em>new</em>"));
10218 assert_eq!(d.source, "keep *new*\n");
10219 }
10220
10221 #[test]
10222 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
10223 let mut d = doc_with("paste_html_bad", "x\n");
10224 d.caret = 1;
10225 // twig builds no table from HTML; raw `<table>` in prose is worse than
10226 // the plain flavor the caller still holds.
10227 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
10228 assert_eq!(d.source, "x\n", "declined edits nothing");
10229 }
10230
10231 #[test]
10232 fn copy_then_paste_round_trips_through_the_html_flavor() {
10233 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
10234 d.select_all();
10235 let html = d.selection_html().expect("renders");
10236 let mut into = doc_with("clip_round_dst", "\n");
10237 into.caret = 0;
10238 assert!(into.paste_html(&html));
10239 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
10240 }
10241
10242 #[test]
10243 fn moving_the_caret_starts_a_new_undo_group() {
10244 let mut d = doc_with("break", "\n");
10245 d.caret = 0;
10246 d.insert("a");
10247 d.insert("b"); // "ab\n", caret at 2
10248 d.move_left(false); // breaks the run
10249 d.insert("X"); // "aXb\n"
10250 assert_eq!(d.source, "aXb\n");
10251 d.undo();
10252 assert_eq!(
10253 d.source, "ab\n",
10254 "first undo removes only the post-move insert"
10255 );
10256 d.undo();
10257 assert_eq!(d.source, "\n", "second undo removes the earlier run");
10258 }
10259
10260 #[test]
10261 fn undo_reverses_a_format_toggle() {
10262 let mut d = doc_with("fmt_undo", "a word b\n");
10263 d.anchor = Some(2);
10264 d.caret = 6;
10265 d.toggle(InlineKind::Strong);
10266 assert_eq!(d.source, "a **word** b\n");
10267 d.undo();
10268 assert_eq!(d.source, "a word b\n");
10269 }
10270
10271 #[test]
10272 fn undo_back_to_the_saved_state_clears_dirty() {
10273 let mut d = doc_with("dirty_undo", "hello\n");
10274 assert!(!d.dirty);
10275 d.caret = 5;
10276 d.insert("!");
10277 assert!(d.dirty);
10278 d.undo();
10279 assert!(
10280 !d.dirty,
10281 "undoing to the saved source is not a modification"
10282 );
10283 }
10284
10285 #[test]
10286 fn a_new_edit_invalidates_redo() {
10287 let mut d = doc_with("redo_inv", "\n");
10288 d.caret = 0;
10289 d.insert("a");
10290 d.undo();
10291 d.insert("b"); // diverges — the redo of "a" is now gone
10292 d.redo();
10293 assert_eq!(d.source, "b\n");
10294 }
10295
10296 #[test]
10297 fn undo_on_empty_history_is_a_no_op() {
10298 let mut d = doc_with("undo_empty", "hi\n");
10299 d.undo();
10300 assert_eq!(d.source, "hi\n");
10301 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10302 }
10303
10304 #[test]
10305 fn a_one_character_paste_is_its_own_undo_step() {
10306 for view in [View::Source, View::Wysiwyg] {
10307 let mut d = doc_in(view, "paste_step", "ab\n");
10308 d.caret = 0;
10309 d.insert("x");
10310 d.insert("y"); // a run of typing
10311 d.paste("z"); // one character, but pasted — not part of that run
10312 assert_eq!(d.source, "xyzab\n");
10313 d.undo();
10314 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
10315 assert_eq!(d.caret, 2, "and hands back the caret it found");
10316 d.undo();
10317 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
10318 }
10319 }
10320
10321 #[test]
10322 fn the_same_character_typed_still_joins_the_run() {
10323 // The other half of the pair: `z` is a keystroke here and a paste above,
10324 // and the two undo differently. Nothing about the *string* says which —
10325 // which is why provenance has to come from the door the caller uses.
10326 for view in [View::Source, View::Wysiwyg] {
10327 let mut d = doc_in(view, "typed_run", "ab\n");
10328 d.caret = 0;
10329 d.insert("x");
10330 d.insert("y");
10331 d.insert("z");
10332 d.undo();
10333 assert_eq!(d.source, "ab\n", "one run, one step");
10334 }
10335 }
10336
10337 #[test]
10338 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
10339 for view in [View::Source, View::Wysiwyg] {
10340 let mut d = doc_in(view, "undo_caret", "hello world\n");
10341 d.caret = 11; // standing at the end of "world", away from the edit
10342 d.edit(0, 5, "goodbye");
10343 assert_eq!(d.source, "goodbye world\n");
10344 d.undo();
10345 assert_eq!(d.source, "hello world\n");
10346 // The undone edit ends at offset 5; the user was at 11.
10347 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
10348 }
10349 }
10350
10351 #[test]
10352 fn undo_restores_the_selection_the_edit_replaced() {
10353 for view in [View::Source, View::Wysiwyg] {
10354 let mut d = doc_in(view, "undo_sel", "a word b\n");
10355 d.anchor = Some(2);
10356 d.caret = 6; // "word" selected
10357 d.insert("X");
10358 assert_eq!(d.source, "a X b\n");
10359 d.undo();
10360 assert_eq!(d.source, "a word b\n");
10361 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
10362 }
10363 }
10364
10365 #[test]
10366 fn redo_restores_the_caret_the_edit_left_behind() {
10367 for view in [View::Source, View::Wysiwyg] {
10368 let mut d = doc_in(view, "redo_caret", "hello world\n");
10369 d.caret = 11;
10370 d.edit(0, 5, "goodbye");
10371 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
10372 d.undo();
10373 d.redo();
10374 assert_eq!(d.source, "goodbye world\n");
10375 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
10376 }
10377 }
10378
10379 #[test]
10380 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
10381 for view in [View::Source, View::Wysiwyg] {
10382 let mut d = doc_in(view, "run_caret", "hi\n");
10383 d.caret = 2;
10384 d.insert("a");
10385 d.insert("b");
10386 d.insert("c");
10387 assert_eq!(d.source, "hiabc\n");
10388 d.undo();
10389 assert_eq!(d.source, "hi\n");
10390 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
10391 d.redo();
10392 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
10393 }
10394 }
10395
10396 #[test]
10397 fn undo_restores_the_caret_across_a_format_toggle() {
10398 // A toggle reaches twig without going through `splice`, so it has to
10399 // record its own step — miss it and every stack depth below it is off by
10400 // one, and undo starts handing back another edit's caret.
10401 for view in [View::Source, View::Wysiwyg] {
10402 let mut d = doc_in(view, "fmt_caret", "a word b\n");
10403 d.caret = 8;
10404 d.anchor = Some(2);
10405 d.caret = 6;
10406 d.toggle(InlineKind::Strong);
10407 assert_eq!(d.source, "a **word** b\n");
10408 d.undo();
10409 assert_eq!(d.source, "a word b\n");
10410 assert_eq!(
10411 d.selection(),
10412 Some((2, 6)),
10413 "the toggled selection comes back"
10414 );
10415 }
10416 }
10417
10418 #[test]
10419 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
10420 // The drift that would never announce itself: twig drops its redo stack
10421 // on any fresh edit, so a leaf redo entry that outlives it would restore
10422 // a caret from the timeline that edit abandoned.
10423 for view in [View::Source, View::Wysiwyg] {
10424 let mut d = doc_in(view, "redo_trunc", "hello world\n");
10425 d.caret = 11;
10426 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
10427 d.undo();
10428 assert_eq!(d.caret, 11);
10429 d.caret = 0;
10430 d.insert("X"); // diverges: A's redo is gone from twig
10431 assert_eq!(d.source, "Xhello world\n");
10432
10433 d.redo();
10434 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
10435 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
10436 d.undo();
10437 assert_eq!(d.source, "hello world\n");
10438 assert_eq!(
10439 d.caret, 0,
10440 "the surviving step's caret, not the dropped one"
10441 );
10442 }
10443 }
10444
10445 #[test]
10446 fn indent_and_outdent_move_the_caret_line_with_its_text() {
10447 for view in [View::Source, View::Wysiwyg] {
10448 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
10449 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
10450 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
10451 // Indentation the caret is standing *in* collapses to the line start
10452 // rather than dragging the caret into the text.
10453 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
10454 // A line with none to give back is left exactly as it was.
10455 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
10456 // Less than a full level gives back what it has.
10457 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
10458 // A tab is one level however many spaces it isn't.
10459 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
10460 }
10461 }
10462
10463 #[test]
10464 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
10465 // Why the level is two spaces and not the four both frontends type
10466 // today. Four is markdown's indented-code-block marker, so a Tab on a
10467 // paragraph would silently restyle it as code — a width that changes
10468 // what the document *means* isn't an indent. Pinned because the number
10469 // is the kind of thing a later list-aware pass would reach for.
10470 let mut d = doc_with("indent_kind", "hello\n");
10471 d.caret = 2;
10472 d.indent();
10473 assert_eq!(d.source, " hello\n");
10474 assert!(
10475 d.nodes().iter().any(|n| n.kind == Kind::Para),
10476 "still prose after a Tab"
10477 );
10478 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
10479
10480 // The four-space level this replaces, for contrast: same text, and twig
10481 // reparses the paragraph into a code block.
10482 let mut wide = doc_with("indent_kind_4", " hello\n");
10483 wide.build_visual(80);
10484 assert!(
10485 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
10486 "four spaces is a code block, not an indented paragraph"
10487 );
10488 }
10489
10490 #[test]
10491 fn indent_nests_a_list_item_under_its_parent() {
10492 // Tab indents a list item by its own marker width, landing its marker at
10493 // the parent's content column so twig reparses it as a nested list.
10494 for view in [View::Source, View::Wysiwyg] {
10495 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
10496 d.caret = 6; // on the second item
10497 d.indent();
10498 assert_eq!(d.source, "- a\n - b\n");
10499 let lists = d
10500 .nodes()
10501 .iter()
10502 .filter(|n| n.kind == Kind::BulletList)
10503 .count();
10504 assert_eq!(lists, 2, "the indented item is a nested list");
10505 }
10506 }
10507
10508 #[test]
10509 fn indent_nests_an_ordered_item_at_its_marker_width() {
10510 // An ordered marker `1. ` is three columns wide, so a two-space step
10511 // (which nests a bullet) leaves it flat. Regression: Tab must use the
10512 // marker width, three, so the item actually nests — and the source
10513 // renumbers so the sub-list restarts at 1 and the outer list resumes.
10514 for view in [View::Source, View::Wysiwyg] {
10515 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
10516 d.caret = d.source.find('b').unwrap();
10517 d.indent();
10518 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
10519 let lists = d
10520 .nodes()
10521 .iter()
10522 .filter(|n| n.kind == Kind::OrderedList)
10523 .count();
10524 assert_eq!(lists, 2, "the indented item is a nested ordered list");
10525 }
10526 }
10527
10528 #[test]
10529 fn indent_leaves_a_lists_first_item_put() {
10530 // The first item of a list has no sibling above it to nest under, so Tab
10531 // is a no-op there — the marker stays at column zero rather than being
10532 // shoved into indentation twig can't read as a sub-list.
10533 for view in [View::Source, View::Wysiwyg] {
10534 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
10535 d.caret = 1; // on the FIRST item
10536 d.indent();
10537 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
10538 // The sibling below still nests, proving the guard is per-item.
10539 d.caret = d.source.find('b').unwrap();
10540 d.indent();
10541 assert_eq!(d.source, "- a\n - b\n");
10542 }
10543 }
10544
10545 #[test]
10546 fn hidden_mode_keeps_typed_markup_literal() {
10547 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
10548 // twig escapes what would open markup, so the source is `\*hi\*` and the
10549 // AST is a plain string. Formatting is the commands' job in this mode.
10550 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
10551 d.insert("*hi*");
10552 assert_eq!(d.source, "\\*hi\\*");
10553 assert!(
10554 d.nodes()
10555 .iter()
10556 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
10557 );
10558 }
10559
10560 #[test]
10561 fn hidden_mode_escapes_a_line_start_block_marker() {
10562 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
10563 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
10564 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
10565 d.insert("# hi");
10566 assert_eq!(d.source, "\\# hi");
10567 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
10568 }
10569
10570 #[test]
10571 fn authoring_modes_keep_typed_markup_live() {
10572 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
10573 // (no escape), the same as source view — escaping is `None`'s alone, and
10574 // it's the axis, not the reveal, that decides.
10575 for (view, mode) in [
10576 (View::Wysiwyg, MarkupMode::Shortcuts),
10577 (View::Wysiwyg, MarkupMode::Full),
10578 (View::Source, MarkupMode::None),
10579 ] {
10580 let mut d = doc_in(view, "live_markup", "");
10581 d.set_markup_mode(mode);
10582 d.insert("*hi*");
10583 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
10584 }
10585 }
10586
10587 #[test]
10588 fn hidden_mode_overwrite_undoes_in_one_step() {
10589 // Typing over a selection escapes the replacement *and* stays a single
10590 // undo — the selection-delete and the literal insert fold together, so
10591 // one undo brings the whole selection back, like a plain overwrite.
10592 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
10593 d.anchor = Some(2);
10594 d.caret = 6; // "word"
10595 d.insert("*");
10596 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
10597 d.undo();
10598 assert_eq!(d.source, "a word b\n");
10599 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
10600 }
10601
10602 #[test]
10603 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
10604 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
10605 // the whole visual character, never stranding the hidden `\`.
10606 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
10607 d.insert("*");
10608 assert_eq!(d.source, "\\*");
10609 d.backspace();
10610 assert_eq!(d.source, "", "the escape backslash went with the *");
10611 // A *literal* backslash (source view, no escape) is an ordinary char.
10612 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
10613 s.caret = 3; // after `b`
10614 s.backspace();
10615 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
10616 }
10617
10618 #[test]
10619 fn hidden_mode_leaves_structural_markup_alone() {
10620 // Enter continues a bullet list by writing a real `- ` marker (an
10621 // `insert_raw`, not the typing path), so Hidden mode's escaping never
10622 // touches it — the list keeps working.
10623 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
10624 d.caret = 6;
10625 d.newline();
10626 d.insert("two");
10627 assert_eq!(d.source, "- item\n- two\n");
10628 }
10629
10630 #[test]
10631 fn markup_mode_defaults_to_none_and_round_trips() {
10632 // Diaryx's default is the clean `None` surface; a markup-fluent
10633 // frontend can climb the ladder, and the choice sticks.
10634 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
10635 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
10636 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
10637 d.set_markup_mode(mode);
10638 assert_eq!(d.markup_mode(), mode);
10639 }
10640 }
10641
10642 #[test]
10643 fn full_mode_reveals_only_the_caret_line() {
10644 // The mode's whole claim: the caret's line shows its raw delimiters and
10645 // every other line stays resolved. Two paragraphs with identical markup
10646 // so the only difference between the rows is where the caret is.
10647 let mut d = doc_in(
10648 View::Wysiwyg,
10649 "reveal_caret_line",
10650 "*one* here\n\n*two* there\n",
10651 );
10652 d.set_markup_mode(MarkupMode::Full);
10653
10654 caret_at(&mut d, "one");
10655 let rows = drawn_rows(&d);
10656 assert!(
10657 rows.iter().any(|r| r == "*one* here"),
10658 "caret's line raw: {rows:?}"
10659 );
10660 assert!(
10661 rows.iter().any(|r| r == "two there"),
10662 "other line resolved: {rows:?}"
10663 );
10664
10665 // Move to the other paragraph: the reveal follows, and the line just
10666 // left goes back to being resolved.
10667 caret_at(&mut d, "two");
10668 let rows = drawn_rows(&d);
10669 assert!(
10670 rows.iter().any(|r| r == "*two* there"),
10671 "caret's line raw: {rows:?}"
10672 );
10673 assert!(
10674 rows.iter().any(|r| r == "one here"),
10675 "left line resolved: {rows:?}"
10676 );
10677 }
10678
10679 #[test]
10680 fn hidden_modes_never_reveal_wherever_the_caret_is() {
10681 // The two rungs below `Full` share a rendering: delimiters stay hidden
10682 // even under the caret. `Shortcuts` differing from `None` only in what
10683 // typing does is exactly the point of splitting the axes.
10684 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
10685 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
10686 d.set_markup_mode(mode);
10687 caret_at(&mut d, "one");
10688 let rows = drawn_rows(&d);
10689 assert!(
10690 rows.iter().any(|r| r == "one here"),
10691 "{mode:?} hides: {rows:?}"
10692 );
10693 assert!(
10694 !rows.iter().any(|r| r.contains('*')),
10695 "{mode:?} shows no `*`: {rows:?}"
10696 );
10697 }
10698 }
10699
10700 #[test]
10701 fn revealed_delimiters_are_the_authors_own_spelling() {
10702 // Delimiters are re-read from the source rather than synthesized per
10703 // kind, so a line comes back spelled the way it was written: `_em_` does
10704 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
10705 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
10706 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
10707 d.set_markup_mode(MarkupMode::Full);
10708 caret_at(&mut d, "em");
10709 let rows = drawn_rows(&d);
10710 assert!(
10711 rows.iter().any(|r| r == body.trim_end()),
10712 "the revealed line is its own source: {rows:?}"
10713 );
10714 }
10715
10716 #[test]
10717 fn revealed_heading_shows_its_hashes() {
10718 // The `# ` marker is a block-level prefix, not an inline delimiter, so
10719 // it takes its own path — but it reveals on the same rule.
10720 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
10721 d.set_markup_mode(MarkupMode::Full);
10722
10723 caret_at(&mut d, "Title");
10724 assert!(
10725 drawn_rows(&d).iter().any(|r| r == "# Title"),
10726 "{:?}",
10727 drawn_rows(&d)
10728 );
10729
10730 caret_at(&mut d, "body");
10731 let rows = drawn_rows(&d);
10732 assert!(
10733 rows.iter().any(|r| r == "Title"),
10734 "hashes hidden again: {rows:?}"
10735 );
10736 }
10737
10738 #[test]
10739 fn revealed_delimiters_are_caret_stops() {
10740 // A delimiter that is drawn but can't be reached is worse than one
10741 // that's hidden: the mode exists so the markup can be *edited*. Every
10742 // revealed byte must be somewhere the caret can stand.
10743 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
10744 d.set_markup_mode(MarkupMode::Full);
10745 caret_at(&mut d, "em");
10746 let opener = d.source.find('*').unwrap();
10747 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
10748 assert!(
10749 d.vmap.is_stop(opener + 3),
10750 "the closing `*` is a caret stop"
10751 );
10752 }
10753
10754 #[test]
10755 fn setext_heading_reveals_nothing_across_its_newline() {
10756 // A setext heading's underline is on another line, so it is not the
10757 // caret line's to reveal — and emitting it would inject a `\n` glyph
10758 // that splits the row where the author wrote no break.
10759 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
10760 d.set_markup_mode(MarkupMode::Full);
10761 caret_at(&mut d, "Title");
10762 let rows = drawn_rows(&d);
10763 assert!(
10764 rows.iter().any(|r| r == "Title"),
10765 "title renders alone: {rows:?}"
10766 );
10767 assert!(
10768 !rows.iter().any(|r| r.contains('=')),
10769 "no underline leaks in: {rows:?}"
10770 );
10771 }
10772
10773 #[test]
10774 fn markup_mode_axes_split_the_ladder() {
10775 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
10776 // that authors markup but still hides it, and it's the only rung where
10777 // the two axes disagree.
10778 assert!(!MarkupMode::None.authors());
10779 assert!(!MarkupMode::None.reveals_caret_line());
10780 assert!(MarkupMode::Shortcuts.authors());
10781 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
10782 assert!(MarkupMode::Full.authors());
10783 assert!(MarkupMode::Full.reveals_caret_line());
10784 }
10785
10786 #[test]
10787 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
10788 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
10789 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
10790 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
10791 // nested bullet and `hello` stays prose: the file round-trips instead of
10792 // hiding a heading the user never asked for.
10793 for view in [View::Source, View::Wysiwyg] {
10794 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
10795 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
10796 d.indent();
10797 assert_eq!(d.source, "- hello\n * \n");
10798 assert!(
10799 d.nodes().iter().all(|n| n.kind != Kind::Heading),
10800 "no heading"
10801 );
10802 // And it's genuinely a nested list, not a flat one.
10803 assert_eq!(
10804 d.nodes()
10805 .iter()
10806 .filter(|n| n.kind == Kind::BulletList)
10807 .count(),
10808 2
10809 );
10810 }
10811 }
10812
10813 #[test]
10814 fn indenting_a_dash_item_with_content_keeps_its_dash() {
10815 // With content, `- x` can't be a setext underline, so there's nothing to
10816 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
10817 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
10818 d.caret = d.source.find('x').unwrap();
10819 d.indent();
10820 assert_eq!(d.source, "- hello\n - x\n");
10821 }
10822
10823 #[test]
10824 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
10825 // The dash→`*` repair coalesces into the Tab, so a single undo restores
10826 // the whole pre-Tab state rather than stranding a half-collapsed doc.
10827 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
10828 d.caret = d.source.find("- \n").unwrap() + 2;
10829 d.indent();
10830 assert_eq!(d.source, "- hello\n * \n");
10831 d.undo();
10832 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
10833 }
10834
10835 #[test]
10836 fn indent_leaves_a_nested_lists_first_item_put_too() {
10837 // The guard is about siblings, not depth: the first item of an *inner*
10838 // list (already nested under `a`) still has nothing before it at its own
10839 // level, so Tab can't take it deeper.
10840 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
10841 d.caret = d.source.find('b').unwrap();
10842 d.indent();
10843 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
10844 // But `c` (a sibling of `b`) nests under `b`.
10845 d.caret = d.source.find('c').unwrap();
10846 d.indent();
10847 assert_eq!(d.source, "- a\n - b\n - c\n");
10848 }
10849
10850 #[test]
10851 fn backspace_at_a_nested_item_start_outdents_it() {
10852 // Backspace with the caret right after a nested item's marker gives back
10853 // one level of nesting, the mirror of Tab — and renumbers the flattened
10854 // ordered list back to a clean run.
10855 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
10856 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
10857 d.backspace();
10858 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10859 }
10860
10861 #[test]
10862 fn backspace_at_a_top_level_item_start_strips_the_marker() {
10863 // At the outermost level there's no nesting left to give back, so the same
10864 // keystroke drops the bullet and leaves a plain paragraph.
10865 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
10866 d.caret = d.source.find('b').unwrap(); // right after `- `
10867 d.backspace();
10868 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
10869 }
10870
10871 #[test]
10872 fn backspace_mid_item_still_deletes_a_character() {
10873 // The list behaviour is armed only at the item's content start; anywhere
10874 // else Backspace is the ordinary character delete.
10875 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
10876 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
10877 d.backspace();
10878 assert_eq!(d.source, "- b\n");
10879 }
10880
10881 #[test]
10882 fn backspace_at_a_heading_start_strips_the_marker() {
10883 // The `# ` is markup the rich view hides, so Backspace over it takes the
10884 // whole marker and leaves a paragraph. Deleting a byte of it instead left
10885 // `#Title` — no longer a heading, with the hash now literal text the user
10886 // never typed and has to delete again.
10887 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
10888 d.caret = d.source.find('T').unwrap(); // right after `## `
10889 d.backspace();
10890 assert_eq!(d.source, "Title\n");
10891 assert_eq!(
10892 d.caret, 0,
10893 "the caret stays with the text it was in front of"
10894 );
10895 }
10896
10897 #[test]
10898 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
10899 // Only the heading's own marker goes — the quote (or list) it sits in is
10900 // untouched, exactly as un-heading it should be.
10901 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
10902 d.caret = d.source.find('T').unwrap();
10903 d.backspace();
10904 assert_eq!(d.source, "> Title\n");
10905 }
10906
10907 #[test]
10908 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
10909 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
10910 // behind would surface the same stray hash the marker delete just avoided.
10911 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
10912 d.caret = d.source.find('T').unwrap();
10913 d.backspace();
10914 assert_eq!(d.source, "Title\n");
10915 // And it's one edit: a single undo puts the whole heading back.
10916 d.undo();
10917 assert_eq!(d.source, "# Title #\n");
10918 }
10919
10920 #[test]
10921 fn backspace_mid_heading_still_deletes_a_character() {
10922 // The heading behaviour is armed only at the content's start; anywhere
10923 // else Backspace is the ordinary character delete.
10924 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
10925 d.caret = d.source.find('b').unwrap();
10926 d.backspace();
10927 assert_eq!(d.source, "# b\n");
10928 }
10929
10930 #[test]
10931 fn source_view_backspace_still_edits_the_heading_marker_literally() {
10932 // In source view the `# ` is text on the screen the user is deleting a
10933 // byte of, so it keeps its literal meaning — the same split the list
10934 // ladder and Enter draw between the two views.
10935 let mut d = doc_with("bsp_head_src", "# Title\n");
10936 d.caret = d.source.find('T').unwrap();
10937 d.backspace();
10938 assert_eq!(d.source, "#Title\n");
10939 }
10940
10941 #[test]
10942 fn outdent_unnests_an_ordered_item_in_one_press() {
10943 // Shift+Tab gives back exactly the marker width the indent added, so a
10944 // nested ordered item unnests in a single press, and the flattened list
10945 // renumbers back to a clean 1, 2, 3.
10946 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
10947 d.caret = d.source.find('b').unwrap();
10948 d.outdent();
10949 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10950 let lists = d
10951 .nodes()
10952 .iter()
10953 .filter(|n| n.kind == Kind::OrderedList)
10954 .count();
10955 assert_eq!(lists, 1, "back to one flat list");
10956 }
10957
10958 #[test]
10959 fn table_insert_row_adds_a_row_below_the_caret() {
10960 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10961 d.caret = d.source.find('1').unwrap(); // in the body row
10962 d.table_insert_row(true);
10963 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
10964 }
10965
10966 #[test]
10967 fn table_insert_and_delete_column_at_the_caret() {
10968 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10969 d.caret = d.source.find('a').unwrap(); // column 0
10970 d.table_insert_column(true); // add a column to the right of `a`
10971 assert_eq!(
10972 d.source,
10973 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
10974 );
10975 d.caret = d.source.find('b').unwrap(); // now the third column
10976 d.table_delete_column();
10977 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
10978 }
10979
10980 // ── ragged formats ───────────────────────────────────────────────────────
10981 // No format spells every gesture. HTML writes the inline marks as a tag pair
10982 // and no heading, list, quote or link; Markdown spells three of the eight
10983 // marks; djot spells all eight and no in-cell break. leaf asks twig per
10984 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
10985 // op discover the fact on its own — one of them didn't.
10986
10987 /// An HTML document in the rich view, ready for a gesture.
10988 fn html_doc(body: &str) -> Doc {
10989 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
10990 d.view = View::Wysiwyg;
10991 d.build_visual(80);
10992 d
10993 }
10994
10995 #[test]
10996 fn a_table_gesture_leaves_an_html_table_alone() {
10997 // The regression this guard exists for. twig's table editor consults no
10998 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
10999 // HTML `<table>` as a *pipe table* and reported success: the whole
11000 // element replaced by `| a | b |`, silently, on one press of a toolbar
11001 // button. Every grid op went the same way.
11002 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
11003 // A table of named operations, which is what it looks like.
11004 #[allow(clippy::type_complexity)]
11005 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11006 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
11007 ("delete row", &|d: &mut Doc| d.table_delete_row()),
11008 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
11009 ("delete column", &|d: &mut Doc| d.table_delete_column()),
11010 ("align", &|d: &mut Doc| {
11011 d.table_set_alignment(Alignment::Right)
11012 }),
11013 ("move row", &|d: &mut Doc| d.table_move_row(true)),
11014 ("move column", &|d: &mut Doc| d.table_move_column(true)),
11015 ];
11016 for (name, op) in ops {
11017 let mut d = html_doc(src);
11018 d.caret = d.source.find('a').unwrap();
11019 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
11020 op(&mut d);
11021 assert_eq!(d.source, src, "{name} rewrote an HTML table");
11022 assert!(
11023 !d.dirty,
11024 "{name} marked the document dirty without editing it"
11025 );
11026 assert!(d.status.is_some(), "{name} refused without saying why");
11027 }
11028 }
11029
11030 #[test]
11031 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
11032 // A heading is a wrapping tag pair carrying its level in both ends, a
11033 // quote wraps a range rather than prefixing each line, a link's
11034 // destination lives in an attribute — different *shapes*, not a
11035 // different alphabet, so twig spells none of them and neither does leaf.
11036 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11037 // A table of named operations, which is what it looks like.
11038 #[allow(clippy::type_complexity)]
11039 let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
11040 ("heading", &|d: &mut Doc| d.toggle_heading(2)),
11041 ("paragraph", &|d: &mut Doc| {
11042 d.set_block(BlockKind::Paragraph)
11043 }),
11044 ("quote", &|d: &mut Doc| d.toggle_blockquote()),
11045 ("list", &|d: &mut Doc| d.toggle_list(false)),
11046 ("task item", &|d: &mut Doc| d.toggle_task_item()),
11047 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
11048 ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
11049 ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
11050 ("video", &|d: &mut Doc| {
11051 d.insert_media(MediaKind::Video, "clip.mp4", "")
11052 }),
11053 ];
11054 for (name, op) in ops {
11055 let mut d = html_doc(src);
11056 let at = d.source.find("Hello").unwrap();
11057 d.caret = at;
11058 d.anchor = Some(at + 5); // a selection, for the ops that want one
11059 op(&mut d);
11060 assert_eq!(d.source, src, "{name} edited an HTML document");
11061 assert!(
11062 !d.dirty,
11063 "{name} marked the document dirty without editing it"
11064 );
11065 let status = d.status.as_deref().unwrap_or("");
11066 assert!(
11067 status.contains("html"),
11068 "{name}: the refusal should name the format, got {status:?}"
11069 );
11070 }
11071 }
11072
11073 #[test]
11074 fn html_spells_the_inline_marks_and_the_rule() {
11075 // The other half, and why one per-document flag stopped being enough:
11076 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
11077 // already emits and the parser reads straight back as the same mark —
11078 // and the rule button writes an `<hr>`. Refusing these on the old
11079 // "HTML is parse-only" reading would now be leaf's own limitation.
11080 let mut d = html_doc("<p>Hello world</p>\n");
11081 let at = d.source.find("world").unwrap();
11082 d.caret = at;
11083 d.anchor = Some(at + 5);
11084 d.toggle(InlineKind::Strong);
11085 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
11086 assert!(d.dirty);
11087 assert_eq!(d.status, None, "a supported gesture reports nothing");
11088
11089 // And off again — the toggle reverses, which is the property that makes
11090 // authoring in HTML worth offering rather than a one-way trip.
11091 d.toggle(InlineKind::Strong);
11092 assert_eq!(d.source, "<p>Hello world</p>\n");
11093
11094 let mut d = html_doc("<p>Hello world</p>\n");
11095 d.caret = d.source.find("world").unwrap();
11096 d.insert_thematic_break();
11097 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
11098 }
11099
11100 #[test]
11101 fn a_mark_the_format_cannot_spell_arms_nothing() {
11102 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
11103 // sticky mark for the next text typed. Guarding only the twig call
11104 // leaves that path live, promising a highlight Markdown will never spell
11105 // and then swallowing the error inside `insert`. Markdown carries the
11106 // case now that HTML spells `<mark>`: `==mark==` is djot's alone.
11107 let mut d = doc_with("mark", "Hello world\n");
11108 d.view = View::Wysiwyg;
11109 d.build_visual(80);
11110 d.caret = d.source.find("world").unwrap();
11111 d.toggle(InlineKind::Mark);
11112 assert!(d.pending_marks.is_empty(), "no mark should be armed");
11113 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
11114 d.insert("X");
11115 assert_eq!(d.source, "Hello Xworld\n");
11116 }
11117
11118 #[test]
11119 fn html_documents_still_take_typed_text() {
11120 // The guard covers *markup* gestures and must not touch plain editing:
11121 // twig's splicer is language-neutral, and typing into an HTML document
11122 // is the thing that does work today.
11123 let mut d = html_doc("<p>Hello world</p>\n");
11124 d.caret = d.source.find("world").unwrap();
11125 d.insert("big ");
11126 assert_eq!(d.source, "<p>Hello big world</p>\n");
11127 assert!(d.dirty);
11128 d.backspace();
11129 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
11130 d.undo();
11131 d.undo();
11132 assert_eq!(d.source, "<p>Hello world</p>\n");
11133 }
11134
11135 #[test]
11136 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
11137 // `authorable` only separates "there is a door in" from "there is not",
11138 // and HTML is on the near side of that line — which is exactly why a
11139 // toolbar must not be built from it.
11140 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
11141 assert!(html.authorable());
11142 assert!(
11143 !Doc::from_source("<r>x</r>".into(), Format::Xml)
11144 .unwrap()
11145 .authorable()
11146 );
11147
11148 let caps = html.capabilities();
11149 assert!(caps.bold && caps.italic && caps.code && caps.mark);
11150 assert!(caps.thematic_break && caps.cell_line_break);
11151 assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
11152 assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
11153 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
11154 // twig's table editor would happily re-emit as `| a | b |`.
11155 assert!(!caps.table);
11156
11157 // The two lightweight formats spell everything leaf offers — and still
11158 // differ from each other, which is the other half of why one boolean
11159 // can't serve.
11160 for fmt in [Format::Markdown, Format::Djot] {
11161 let caps = Capabilities::of(fmt);
11162 assert!(
11163 caps.heading && caps.blockquote && caps.ordered_list,
11164 "{fmt:?}"
11165 );
11166 assert!(
11167 caps.task && caps.link && caps.image && caps.table,
11168 "{fmt:?}"
11169 );
11170 }
11171 assert!(Capabilities::of(Format::Djot).mark);
11172 assert!(!Capabilities::of(Format::Markdown).mark);
11173 assert!(Capabilities::of(Format::Markdown).cell_line_break);
11174 assert!(!Capabilities::of(Format::Djot).cell_line_break);
11175
11176 // A parse-only format answers no to every one of them, so the coarse
11177 // predicate and the record agree there.
11178 let caps = Capabilities::of(Format::Xml);
11179 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
11180 }
11181
11182 #[test]
11183 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
11184 // The guard exists to name the *document's* format rather than twig's
11185 // internals, so the message has to survive being one leaf writes itself.
11186 // Checked against the gesture twig also refuses, since that is the pair
11187 // most at risk of drifting apart.
11188 let mut d = html_doc("<p>Hello</p>\n");
11189 d.caret = d.source.find("Hello").unwrap();
11190 d.set_code_language("zig");
11191 assert_eq!(
11192 d.status.as_deref(),
11193 Some("code language: not supported in html")
11194 );
11195 assert!(!d.dirty);
11196 }
11197
11198 #[test]
11199 fn table_set_alignment_respells_the_delimiter() {
11200 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11201 d.caret = d.source.find('b').unwrap();
11202 d.table_set_alignment(Alignment::Right);
11203 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
11204 }
11205
11206 #[test]
11207 fn each_empty_table_cell_has_its_own_editable_home() {
11208 // Regression: an empty cell has no twig content_span, so both cells of a
11209 // `| | |` row collapsed onto the row's start (before the first `│`).
11210 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
11211 // tell the cells apart. Each empty cell must now have a distinct home
11212 // inside it.
11213 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
11214 let (c0, c1) = {
11215 let cells = &d.vmap.tables[0].grid[1].cells;
11216 (cells[0].start, cells[1].start)
11217 };
11218 assert!(
11219 c0 < c1,
11220 "the two empty cells have distinct homes: {c0} < {c1}"
11221 );
11222 d.caret = c0;
11223 d.insert("x");
11224 assert_eq!(
11225 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
11226 "typed inside the cell"
11227 );
11228 }
11229
11230 #[test]
11231 fn arrows_step_into_each_empty_table_cell() {
11232 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
11233 let (c0, c1) = {
11234 let cells = &d.vmap.tables[0].grid[1].cells;
11235 (cells[0].start, cells[1].start)
11236 };
11237 d.caret = d.source.find('b').unwrap(); // in the header's second cell
11238 let mut seen = std::collections::HashSet::new();
11239 for _ in 0..6 {
11240 d.move_right(false);
11241 seen.insert(d.caret);
11242 }
11243 assert!(
11244 seen.contains(&c0),
11245 "right arrow reaches the first empty cell"
11246 );
11247 assert!(
11248 seen.contains(&c1),
11249 "right arrow reaches the second empty cell"
11250 );
11251 }
11252
11253 #[test]
11254 fn table_op_off_a_table_is_a_no_op_with_a_status() {
11255 let mut d = doc_with("tbl_none", "just text\n");
11256 d.caret = 3;
11257 d.table_insert_row(true);
11258 assert_eq!(d.source, "just text\n", "nothing changed");
11259 assert!(d.status.is_some(), "a status explains why");
11260 assert!(!d.caret_in_table());
11261 }
11262
11263 #[test]
11264 fn enter_in_an_ordered_list_renumbers_the_following_items() {
11265 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
11266 // the renumber pass keeps them sequential, matching what the view draws.
11267 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
11268 d.caret = d.source.find('a').unwrap() + 1; // end of item a
11269 d.newline();
11270 d.insert("x");
11271 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
11272 }
11273
11274 #[test]
11275 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
11276 for view in [View::Source, View::Wysiwyg] {
11277 let mut d = doc_in(view, "outdent_noop", "hello\n");
11278 d.caret = 2;
11279 d.outdent();
11280 assert_eq!(d.source, "hello\n");
11281 assert!(!d.dirty, "a no-op is not a modification");
11282 d.undo();
11283 assert_eq!(
11284 d.status.as_deref(),
11285 Some("nothing to undo"),
11286 "spends no undo step"
11287 );
11288 assert_eq!(d.source, "hello\n");
11289 }
11290 }
11291
11292 #[test]
11293 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
11294 for view in [View::Source, View::Wysiwyg] {
11295 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
11296 d.anchor = Some(0);
11297 d.caret = 7; // through "two"
11298 d.indent();
11299 assert_eq!(
11300 d.source, " one\n\n two\n",
11301 "the blank line keeps no trailing pad"
11302 );
11303 // Selected, so a second Tab lands on the same lines rather than on
11304 // whatever the shifted offsets now cover.
11305 assert_eq!(d.selection(), Some((0, 12)));
11306 d.indent();
11307 assert_eq!(d.source, " one\n\n two\n");
11308 }
11309 }
11310
11311 #[test]
11312 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
11313 for view in [View::Source, View::Wysiwyg] {
11314 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
11315 d.anchor = Some(0);
11316 d.caret = 15;
11317 d.outdent();
11318 assert_eq!(d.source, "two\none\nnone\n");
11319 }
11320 }
11321
11322 #[test]
11323 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
11324 for view in [View::Source, View::Wysiwyg] {
11325 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
11326 d.anchor = Some(0);
11327 d.caret = 7;
11328 d.indent();
11329 assert_eq!(d.source, " one\n\n two\n");
11330 d.undo();
11331 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
11332 assert_eq!(
11333 d.selection(),
11334 Some((0, 7)),
11335 "with the selection it was aimed at"
11336 );
11337 d.redo();
11338 assert_eq!(d.source, " one\n\n two\n");
11339 assert_eq!(
11340 d.selection(),
11341 Some((0, 12)),
11342 "redo replays the caret the indent placed, not the one splice left"
11343 );
11344 }
11345 }
11346
11347 #[test]
11348 fn vertical_motion_keeps_the_column() {
11349 let mut d = doc_with("move", "abcd\nef\n");
11350 d.caret = 3; // "abc|d" on row 0, col 3
11351 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
11352 assert_eq!(d.caret, 7); // just after "ef"
11353 }
11354
11355 // ── goal column ──────────────────────────────────────────────────────────
11356
11357 #[test]
11358 fn vertical_motion_goal_column_survives_a_short_line() {
11359 // Regression: re-deriving the column from the clamped position on
11360 // every step permanently forgets it once a short line clamps it.
11361 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
11362 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
11363 assert_eq!(
11364 g("abcd|ef\nxy\nghijkl\n", |d| {
11365 d.move_down(false); // clamps to end of "xy"
11366 d.move_down(false); // restores col 4 on the long line
11367 }),
11368 "abcdef\nxy\nghij|kl\n"
11369 );
11370 }
11371
11372 #[test]
11373 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
11374 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
11375 assert_eq!(d.goal_col, None);
11376 d.caret = 4; // row 0, col 4
11377 d.move_down(false); // clamps into "xy"; goal stays the original col
11378 assert_eq!(d.goal_col, Some(4));
11379 assert_eq!(d.caret_pos(), (1, 2));
11380
11381 // A horizontal motion drops the goal column...
11382 d.move_left(false);
11383 assert_eq!(d.goal_col, None);
11384
11385 // ...so the next vertical motion picks up the *new* column (1), not
11386 // the stale one (4).
11387 d.move_down(false);
11388 assert_eq!(d.goal_col, Some(1));
11389 assert_eq!(d.caret_pos(), (2, 1));
11390 }
11391
11392 #[test]
11393 fn editing_clears_the_goal_column() {
11394 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
11395 d.caret = 4;
11396 d.move_down(false);
11397 assert_eq!(d.goal_col, Some(4));
11398 d.insert("Z");
11399 assert_eq!(d.goal_col, None);
11400 }
11401
11402 #[test]
11403 fn vertical_motion_on_an_empty_document_is_a_no_op() {
11404 let mut d = doc_with("empty_vert", "");
11405 d.move_down(false);
11406 assert_eq!(d.caret, 0);
11407 d.move_up(false);
11408 assert_eq!(d.caret, 0);
11409 }
11410
11411 // ── the document's edges ─────────────────────────────────────────────────
11412
11413 #[test]
11414 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
11415 // The reproduction, and the disagreement: Down on the last line ran to
11416 // the end of the document in the source view — by accident, an
11417 // out-of-range row clamping to the end of the string — and did nothing
11418 // whatever in the view leaf opens in. One rule now, in both.
11419 for (view, tag) in VIEWS {
11420 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
11421 d.caret = 1;
11422 d.move_down(false);
11423 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
11424 d.move_up(false);
11425 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
11426 }
11427 }
11428
11429 #[test]
11430 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
11431 // Down off the bottom is a motion like any other, so it latches a goal
11432 // column — and Up comes back to the column the caret left, not to the
11433 // one the document's end happened to be in.
11434 for (view, tag) in VIEWS {
11435 let gap = if view == View::Source { "\n" } else { "\n\n" };
11436 let src = format!("abcdef{gap}ghijkl");
11437 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
11438 d.caret = 2; // row 0, col 2
11439 d.move_down(false);
11440 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
11441 d.move_down(false);
11442 assert_eq!(
11443 d.caret,
11444 src.len(),
11445 "{tag}: Down off the bottom reaches the end"
11446 );
11447 d.move_up(false);
11448 assert_eq!(
11449 d.caret_pos().1,
11450 2,
11451 "{tag}: Up returns to the column Down left"
11452 );
11453 }
11454 }
11455
11456 #[test]
11457 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
11458 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
11459 // Up that did nothing still armed a goal column, and the next Down aimed
11460 // at a column the caret had never been in.
11461 for (view, tag) in VIEWS {
11462 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
11463 d.caret = 0;
11464 d.move_up(false);
11465 assert_eq!(d.caret, 0, "{tag}: already at the start");
11466 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
11467
11468 d.caret = d.source.len();
11469 d.move_down(false);
11470 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
11471 assert_eq!(
11472 d.goal_col, None,
11473 "{tag}: a no-op Down latched a goal column"
11474 );
11475 }
11476 }
11477
11478 // ── soft wrap ────────────────────────────────────────────────────────────
11479 // Every other test here builds the map at 80 columns, where no fixture is
11480 // long enough to fold. A wrap is where one offset belongs to two rows at
11481 // once, and it broke everything that asks the caret what row it is on.
11482
11483 /// The wrapped fixture these cases share, folded at 12 columns into
11484 /// `one two ` / `three four ` / `five six ` / `seven eight`.
11485 fn wrapped_doc(name: &str) -> Doc {
11486 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
11487 d.build_visual(12);
11488 d
11489 }
11490
11491 #[test]
11492 fn home_and_end_work_from_a_wrapped_row() {
11493 // The reproduction: offset 19 is the `f` of "five", the first character
11494 // of the third row — and also the offset the second row ends at. It
11495 // resolved to the *second* row, so End aimed at a place the caret was
11496 // already in and did nothing, while Home walked backwards onto a row the
11497 // caret had left.
11498 let mut d = wrapped_doc("wrap_home_end");
11499 d.caret = 19;
11500 assert_eq!(
11501 d.caret_pos(),
11502 (2, 0),
11503 "the wrap boundary opens the third row"
11504 );
11505 d.move_end(false);
11506 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
11507 d.move_home(false);
11508 assert_eq!(d.caret, 19, "Home left the row the caret was on");
11509 }
11510
11511 #[test]
11512 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
11513 // The row's end is the last offset that is only ever its own: the offset
11514 // past it opens the row below, and aiming there would send a second
11515 // press on to *that* row's end, and a third to the next — End walking
11516 // down the paragraph rather than sitting where it landed.
11517 let mut d = wrapped_doc("wrap_end_twice");
11518 d.caret = 12; // inside "three", on the second row
11519 d.move_end(false);
11520 assert_eq!(
11521 d.caret, 18,
11522 "the end of `three four`, before the space the wrap ate"
11523 );
11524 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
11525 d.move_end(false);
11526 assert_eq!(d.caret, 18, "a second End moved the caret");
11527 d.move_home(false);
11528 assert_eq!(d.caret, 8, "Home takes the row's own start");
11529 }
11530
11531 #[test]
11532 fn vertical_motion_crosses_a_soft_wrap() {
11533 // Down aimed at the row below's column 0, an offset that resolved *up*
11534 // to the row above's end — so it landed on the offset it already had and
11535 // the caret could never leave a paragraph's first row.
11536 let mut d = wrapped_doc("wrap_down");
11537 d.caret = 0;
11538 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
11539 d.move_down(false);
11540 assert_eq!(d.caret, want, "Down stalled");
11541 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
11542 }
11543 d.move_down(false);
11544 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
11545
11546 // ...and back up, one row per press. The goal column is the end of the
11547 // last row, past every other row's width, so each press clamps to the
11548 // row's own last offset rather than to the one that opens the next.
11549 let mut d = wrapped_doc("wrap_up");
11550 d.caret = 39;
11551 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
11552 d.move_up(false);
11553 assert_eq!(d.caret, want, "Up stalled");
11554 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
11555 }
11556 }
11557
11558 #[test]
11559 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
11560 // The kills take the same line Home and End do, so in WYSIWYG they take
11561 // the visual row — and a soft wrap has no newline in it to delete, so
11562 // nothing is joined by reaching the end of one.
11563 let mut d = wrapped_doc("wrap_kill");
11564 d.caret = 19; // the `f` of "five", opening the third row
11565 d.delete_to_line_end();
11566 // The space the wrap ate goes with the row it was drawn on: sparing it
11567 // would leave "four seven", two spaces where the row had been.
11568 assert_eq!(d.source, "one two three four seven eight");
11569
11570 // Backwards from the row's last caret position — which is *before* that
11571 // space, so this one survives, being on the far side of the caret.
11572 let mut d = wrapped_doc("wrap_kill_back");
11573 d.caret = 27;
11574 d.delete_to_line_start();
11575 assert_eq!(d.source, "one two three four seven eight");
11576 }
11577
11578 // ── document start / end ────────────────────────────────────────────────
11579
11580 #[test]
11581 fn move_doc_start_and_end_jump_to_the_edges() {
11582 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
11583 assert_eq!(
11584 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
11585 "|hello\nworld\n"
11586 );
11587 assert_eq!(
11588 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
11589 "hello\nworld\n|"
11590 );
11591 // Already at the edge: a no-op.
11592 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
11593 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
11594 }
11595
11596 #[test]
11597 fn move_doc_start_and_end_extend_the_selection() {
11598 assert_eq!(
11599 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
11600 .move_doc_end(true)),
11601 "hello wor[ld\n|]"
11602 );
11603 assert_eq!(
11604 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
11605 .move_doc_start(true)),
11606 "[|hello wor]ld\n"
11607 );
11608 }
11609
11610 #[test]
11611 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
11612 let mut d = doc_with("empty_edges", "");
11613 d.move_doc_end(false);
11614 assert_eq!(d.caret, 0);
11615 d.move_doc_start(false);
11616 assert_eq!(d.caret, 0);
11617 }
11618
11619 // ── arrow collapses an active selection ─────────────────────────────────
11620
11621 #[test]
11622 fn arrow_collapses_selection_to_its_near_edge() {
11623 let mut d = doc_with("collapse", "hello world\n");
11624
11625 // Forward selection (anchor before caret): Right -> end, Left -> start.
11626 d.anchor = Some(2);
11627 d.caret = 7;
11628 d.move_right(false);
11629 assert_eq!((d.caret, d.anchor), (7, None));
11630
11631 d.anchor = Some(2);
11632 d.caret = 7;
11633 d.move_left(false);
11634 assert_eq!((d.caret, d.anchor), (2, None));
11635
11636 // Backward selection (anchor after caret): edges are the same
11637 // regardless of which end the caret started on.
11638 d.anchor = Some(7);
11639 d.caret = 2;
11640 d.move_right(false);
11641 assert_eq!((d.caret, d.anchor), (7, None));
11642
11643 d.anchor = Some(7);
11644 d.caret = 2;
11645 d.move_left(false);
11646 assert_eq!((d.caret, d.anchor), (2, None));
11647 }
11648
11649 #[test]
11650 fn arrow_with_extend_keeps_growing_the_selection() {
11651 let mut d = doc_with("collapse_extend", "hello world\n");
11652 d.anchor = Some(2);
11653 d.caret = 7;
11654 d.move_right(true); // extend: no collapse, caret steps one further
11655 assert_eq!((d.caret, d.anchor), (8, Some(2)));
11656 }
11657
11658 #[test]
11659 fn arrow_without_a_selection_moves_one_character_as_before() {
11660 let mut d = doc_with("no_collapse", "hello\n");
11661 d.caret = 2;
11662 d.move_right(false);
11663 assert_eq!(d.caret, 3);
11664 d.move_left(false);
11665 assert_eq!(d.caret, 2);
11666 }
11667
11668 /// Press Right until it stops, collecting the offsets walked through. Every
11669 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
11670 /// two stops sharing one source offset can't be moved between, so the caret
11671 /// stalls on the first of them and the walk never reaches the rest.
11672 fn walk_right(d: &mut Doc) -> Vec<usize> {
11673 let mut seen = vec![d.caret];
11674 for _ in 0..2000 {
11675 let before = d.caret;
11676 d.move_right(false);
11677 if d.caret == before {
11678 break;
11679 }
11680 seen.push(d.caret);
11681 }
11682 seen
11683 }
11684
11685 #[test]
11686 fn the_caret_crosses_a_soft_break() {
11687 // A newline inside a paragraph is a `soft_break`, which twig gives no
11688 // span of its own — the space it renders as used to borrow the offset of
11689 // the character before it, and a caret can't move without changing
11690 // offset. Right must walk clean off the end of the first line.
11691 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
11692 d.caret = 0;
11693 let seen = walk_right(&mut d);
11694 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11695 }
11696
11697 #[test]
11698 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
11699 // The paragraph holds one soft break. Folded (the default) it lays out as
11700 // a single reflowed row; Preserve re-lays it as a row per source line.
11701 // The setter must invalidate the cached map for the change to show, and
11702 // again on the way back — so a round trip returns to the folded layout.
11703 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
11704 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
11705 d.build_visual(80);
11706 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
11707
11708 d.set_line_flow(LineFlow::Preserve);
11709 d.build_visual(80);
11710 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
11711
11712 d.set_line_flow(LineFlow::Fold);
11713 d.build_visual(80);
11714 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
11715 }
11716
11717 #[test]
11718 fn the_caret_still_crosses_a_preserved_soft_break() {
11719 // Preserve renders the soft break as a row boundary rather than a space,
11720 // but the caret must still reach every offset — the break's own offset is
11721 // the first row's end stop, so Right walks clean off the end of line one
11722 // onto line two, exactly as it does when the break is folded.
11723 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
11724 d.set_line_flow(LineFlow::Preserve);
11725 d.build_visual(80);
11726 d.caret = 0;
11727 let seen = walk_right(&mut d);
11728 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11729 }
11730
11731 #[test]
11732 fn the_caret_walks_a_code_block() {
11733 // Every glyph of a code block used to map to the block's start, so the
11734 // whole block was a single offset and the caret couldn't move inside it.
11735 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
11736 let mut d = wysiwyg_doc("code_walk", src);
11737 d.caret = 0;
11738 let seen = walk_right(&mut d);
11739 // The fences are markup: hidden, and no caret stop. The code between
11740 // them is reached a character at a time.
11741 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
11742 for off in code.clone() {
11743 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
11744 }
11745 assert!(seen.contains(&code.end), "no stop after the last line");
11746 }
11747
11748 #[test]
11749 fn the_caret_walks_an_indented_code_block() {
11750 // An indented block's text has the four-space indent stripped, so it
11751 // isn't a verbatim slice and its lines have to be re-found. The caret
11752 // lands on the code, never in the indent.
11753 let src = " indented\n code\n";
11754 let mut d = wysiwyg_doc("indent_code_walk", src);
11755 d.caret = 0;
11756 let seen = walk_right(&mut d);
11757 assert!(seen.contains(&src.find("indented").unwrap()));
11758 assert!(seen.contains(&src.find("code").unwrap()));
11759 assert!(
11760 !seen.contains(&0) || seen[0] == 0,
11761 "the caret starts where it was put"
11762 );
11763 // Nothing in the stripped indent is a stop.
11764 for off in [1, 2, 3] {
11765 assert!(!seen.contains(&off), "landed in the indent at {off}");
11766 }
11767 }
11768
11769 #[test]
11770 fn the_caret_leaves_a_tight_heading() {
11771 // "# H" with text directly under it: the heading row's end and the
11772 // separator row's end are the same offset. Right used to find the
11773 // separator's copy, set the caret to where it already was, and stop.
11774 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
11775 d.caret = 2; // the "H"
11776 let seen = walk_right(&mut d);
11777 assert!(
11778 seen.len() > 2,
11779 "Right stalled at the heading's end: {seen:?}"
11780 );
11781 assert!(
11782 seen.contains(&8),
11783 "never reached the end of \"text\": {seen:?}"
11784 );
11785 }
11786
11787 #[test]
11788 fn the_caret_skips_the_gap_between_two_paragraphs() {
11789 // The blank line between two paragraphs is the boundary itself. The
11790 // caret used to be able to sit on it, and typing there landed in the
11791 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
11792 // soft break, so the text visibly snapped back up.
11793 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
11794 d.caret = 1; // the end of "A"
11795 d.move_right(false);
11796 assert_eq!(d.caret, 3, "Right stopped in the gap");
11797 d.insert("x");
11798 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
11799 }
11800
11801 #[test]
11802 fn down_from_a_paragraph_lands_on_the_next_one() {
11803 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
11804 d.caret = 0;
11805 d.move_down(false);
11806 assert_eq!(d.caret, 3, "Down stopped in the gap");
11807 }
11808
11809 #[test]
11810 fn clicking_the_gap_lands_on_real_text() {
11811 // A click can still *reach* the gap — it's drawn, so it's clickable.
11812 // It has to resolve to somewhere the caret can be.
11813 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
11814 d.click(1, 0, false); // the gap row
11815 assert!(
11816 d.caret == 1 || d.caret == 3,
11817 "click left the caret in the gap at {}",
11818 d.caret
11819 );
11820 d.insert("x");
11821 // Either edge of the boundary is a fair place to land; inside it isn't.
11822 assert!(
11823 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
11824 "click in the gap typed into the boundary: {:?}",
11825 d.source
11826 );
11827 }
11828
11829 #[test]
11830 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
11831 // Enter inserts a paragraph break, which leaves a blank line spare on
11832 // either side of a new one. That middle line is a real empty paragraph:
11833 // the caret lands there, and typing makes a paragraph rather than
11834 // extending a neighbour.
11835 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
11836 d.caret = 1;
11837 d.newline();
11838 assert_eq!(d.source, "A\n\n\n\nB\n");
11839 d.build_visual(80);
11840 let (row, _) = d.caret_pos();
11841 assert!(
11842 d.vmap.row_is_navigable(row),
11843 "the caret landed on a gap row"
11844 );
11845 d.insert("x");
11846 assert_eq!(
11847 d.source, "A\n\nx\n\nB\n",
11848 "the new paragraph merged into a neighbour"
11849 );
11850 }
11851
11852 #[test]
11853 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
11854 let mut d = wysiwyg_doc("gap_eof", "A\n");
11855 d.caret = 1;
11856 d.newline();
11857 d.build_visual(80);
11858 let (row, _) = d.caret_pos();
11859 assert!(
11860 d.vmap.row_is_navigable(row),
11861 "the caret landed on a gap row"
11862 );
11863 d.insert("x");
11864 assert!(
11865 d.source.starts_with("A\n\n") && d.source.contains('x'),
11866 "typing at the end merged into A: {:?}",
11867 d.source
11868 );
11869 }
11870
11871 #[test]
11872 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
11873 // A paragraph broken over two source lines is one paragraph. Selecting
11874 // it must not stop at the newline inside it — that newline is markup the
11875 // rich-text view exists to hide.
11876 let src = "one two\nthree four\n\nnext\n";
11877 let mut d = wysiwyg_doc("triple_para", src);
11878 d.select_block_at(2);
11879 assert_eq!(
11880 d.selected_text(),
11881 Some("one two\nthree four"),
11882 "stopped at the soft break"
11883 );
11884 }
11885
11886 #[test]
11887 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
11888 // The reader scrolls down past the caret's row. Nothing moved the
11889 // caret, so the view must stay where it was put — the old code revealed
11890 // the caret every frame, which dragged the view straight back and made
11891 // the document unscrollable past the caret.
11892 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
11893 d.caret = 0;
11894 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
11895 d.scroll = 4; // the wheel
11896 d.follow_caret(0, 3, 9);
11897 assert_eq!(
11898 d.scroll, 4,
11899 "the wheel was overruled by a caret that never moved"
11900 );
11901 }
11902
11903 #[test]
11904 fn moving_the_caret_brings_the_view_back_to_it() {
11905 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
11906 d.caret = 0;
11907 d.follow_caret(0, 3, 9);
11908 d.scroll = 6; // scrolled away
11909 d.move_right(false); // ...and now the caret moves
11910 let (row, _) = d.caret_pos();
11911 d.follow_caret(row, 3, 9);
11912 assert!(
11913 d.scroll <= row && row < d.scroll + 3,
11914 "caret row {row} off screen at scroll {}",
11915 d.scroll
11916 );
11917 }
11918
11919 #[test]
11920 fn scrolling_stops_at_the_last_row() {
11921 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
11922 d.caret = 0;
11923 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
11924 d.scroll = 999; // the wheel, spun hard
11925 d.follow_caret(0, 3, 3);
11926 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
11927 }
11928
11929 #[test]
11930 fn every_cell_of_a_wide_table_is_reachable() {
11931 // A table whose cells are far wider than the surface: the columns are
11932 // cut to fit and the text wraps inside them, so no cell hangs off the
11933 // right edge where the caret can never go.
11934 let src = "| Ingredient | Notes |\n|---|---|\n\
11935 | flour milled coarse | sift it twice before folding it in |\n";
11936 let mut d = wysiwyg_doc("wide_table_walk", src);
11937 d.build_visual(30);
11938 d.caret = 0;
11939 let seen = walk_right(&mut d);
11940 for word in ["Ingredient", "Notes", "coarse", "folding"] {
11941 let at = src.find(word).unwrap();
11942 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
11943 }
11944 }
11945
11946 // ── view parity ──────────────────────────────────────────────────────────
11947 // `doc_with` pins the source view, so everything above tests a view users
11948 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
11949 // deletion golden cases through *both*, plus the WYSIWYG cases the two
11950 // can't share: where the source carries markup the rendered text is a
11951 // different string, and the views agreeing would itself be the bug.
11952
11953 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
11954
11955 /// Run `action` in both views on one `|`-marked fixture and assert they
11956 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
11957 /// source verbatim, so the two views are looking at the same text and any
11958 /// disagreement is one of them having lost the plot.
11959 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
11960 let (src, caret) = parse_caret(marked);
11961 let run = |view: View, tag: &str| {
11962 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
11963 d.caret = caret;
11964 action(&mut d);
11965 render_caret(&d)
11966 };
11967 let source = run(VIEWS[0].0, VIEWS[0].1);
11968 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
11969 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
11970 source
11971 }
11972
11973 #[test]
11974 fn word_motion_agrees_across_the_views_on_plain_prose() {
11975 let g = both_views;
11976 assert_eq!(
11977 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
11978 "hello |world"
11979 );
11980 assert_eq!(
11981 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
11982 "|hello world"
11983 );
11984 assert_eq!(
11985 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
11986 "hello| world"
11987 );
11988 assert_eq!(
11989 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
11990 "hello world|"
11991 );
11992 assert_eq!(
11993 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
11994 "foo|.bar"
11995 );
11996 assert_eq!(
11997 g("par_ext", "hello |world", |d| d.move_word_right(true)),
11998 "hello [world|]"
11999 );
12000 }
12001
12002 #[test]
12003 fn word_deletion_agrees_across_the_views_on_plain_prose() {
12004 let g = both_views;
12005 assert_eq!(
12006 g("par_db", "hello world|", |d| d.delete_word_back()),
12007 "hello |"
12008 );
12009 assert_eq!(
12010 g("par_df", "hello |world", |d| d.delete_word_forward()),
12011 "hello |"
12012 );
12013 assert_eq!(
12014 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
12015 "|bar baz"
12016 );
12017 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
12018 }
12019
12020 #[test]
12021 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
12022 let g = both_views;
12023 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
12024 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
12025 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
12026 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
12027 }
12028
12029 #[test]
12030 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
12031 // The reproduction: the stop table was built one stop per `char`, so
12032 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
12033 // source view, which steps by grapheme, can't reach and backspace can't
12034 // survive. The two views must land on the same offset.
12035 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
12036 for (view, tag) in VIEWS {
12037 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
12038 d.caret = 1;
12039 d.move_right(false);
12040 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
12041
12042 // ...and the edit that used to sever a joiner off the front of it.
12043 d.backspace();
12044 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
12045 assert_eq!(d.caret, 1);
12046 }
12047 }
12048
12049 #[test]
12050 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
12051 for (view, tag) in VIEWS {
12052 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
12053 d.caret = 0;
12054 d.move_right(false);
12055 assert_eq!(
12056 d.caret,
12057 "e\u{0301}".len(),
12058 "{tag} stopped on the combining mark"
12059 );
12060 }
12061 }
12062
12063 #[test]
12064 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
12065 // The general form: whatever route the caret takes through a document
12066 // full of clusters, it never lands between the codepoints of one — so no
12067 // motion-then-backspace sequence can leave a dangling joiner behind.
12068 use unicode_segmentation::UnicodeSegmentation;
12069
12070 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
12071 let mut d = wysiwyg_doc("cluster_walk", src);
12072 d.caret = 0;
12073 let boundaries: Vec<usize> = src
12074 .grapheme_indices(true)
12075 .map(|(i, _)| i)
12076 .chain(std::iter::once(src.len()))
12077 .collect();
12078 for off in walk_right(&mut d) {
12079 assert!(
12080 boundaries.contains(&off),
12081 "Right stopped at {off}, inside a grapheme cluster"
12082 );
12083 }
12084 }
12085
12086 #[test]
12087 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
12088 // The reproduction: ⌥→ from inside the opening `**` computed its
12089 // boundary over the raw source and landed on byte 8 — inside the
12090 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
12091 // "bold". The caret drew past the bold word and sat inside it.
12092 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
12093 d.caret = 2;
12094 d.move_word_right(false);
12095 assert!(
12096 d.vmap.is_stop(d.caret),
12097 "landed at {}, not a caret stop",
12098 d.caret
12099 );
12100 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
12101 // The rendered row is "a bold c": column 6 is the space just past "bold",
12102 // and now the caret is really there rather than only drawn there.
12103 assert_eq!(d.caret_pos(), (0, 6));
12104
12105 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
12106 d.move_word_left(false);
12107 assert_eq!(d.caret, 4);
12108 assert_eq!(d.caret_pos(), (0, 2));
12109 }
12110
12111 #[test]
12112 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
12113 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
12114 // inside the closing `**`, and left "a ** c\n" — delimiters with no
12115 // opener. Glyph space covers the word alone, which would leave
12116 // "a **** c": markup wrapped around nothing. The word and the styling
12117 // that was only ever the word's go together.
12118 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
12119 d.caret = 10;
12120 d.delete_word_back();
12121 assert_eq!(d.source, "a c\n");
12122 assert_eq!(d.caret, 2);
12123
12124 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
12125 d.caret = 4; // the "b"
12126 d.delete_word_forward();
12127 assert_eq!(d.source, "a c\n");
12128 }
12129
12130 #[test]
12131 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
12132 let src = "a ***bold*** c\n";
12133 let mut d = wysiwyg_doc("wys_word_del_nest", src);
12134 d.caret = src.find(" c").unwrap();
12135 d.delete_word_back();
12136 assert_eq!(
12137 d.source, "a c\n",
12138 "the emph inside the strong empties it too"
12139 );
12140
12141 let src = "a `code` c\n";
12142 let mut d = wysiwyg_doc("wys_word_del_code", src);
12143 d.caret = src.find(" c").unwrap();
12144 d.delete_word_back();
12145 assert_eq!(d.source, "a c\n");
12146 }
12147
12148 #[test]
12149 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
12150 // Only an *emptied* node goes. Take one word of two and the `**` still
12151 // has a job to do — over the word that's left, with the space the delete
12152 // pushed against the opening delimiter moved out in front of it, or the
12153 // run would be no run at all (`** words**` is literal asterisks — see
12154 // the mark-edge rule on `splice`).
12155 let src = "a **two words** c\n";
12156 let mut d = wysiwyg_doc("wys_word_del_partial", src);
12157 d.caret = src.find(" words").unwrap();
12158 d.delete_word_back();
12159 assert_eq!(d.source, "a **words** c\n");
12160 }
12161
12162 #[test]
12163 fn source_view_word_motion_still_walks_the_markup() {
12164 // The other half of the decision: in the source view the `**` are
12165 // characters like any other — they're on the screen, so word motion has
12166 // to stop at them and a word-delete has to leave them behind. Only
12167 // WYSIWYG hides them, so only WYSIWYG steps over them.
12168 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
12169 assert_eq!(
12170 g("src_word_motion", "a |**bold** c\n", |d| d
12171 .move_word_right(false)),
12172 "a **bold|** c\n"
12173 );
12174 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
12175 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
12176 assert_eq!(
12177 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
12178 "a **| c\n"
12179 );
12180 }
12181
12182 #[test]
12183 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
12184 // The single invariant both bugs violated: the caret draws and edits at
12185 // the same place only when it's on a stop. `debug_assert_on_a_stop`
12186 // makes the same claim in-place; this pins it from the outside, over a
12187 // document with every kind of thing the map has to be careful about.
12188 // At two widths: the wide one every other test builds at, where no
12189 // fixture folds, and one narrow enough that they all do. A soft wrap is
12190 // where an offset stops being on exactly one row, and testing only the
12191 // width that never wraps is how the caret came to be pinned at the first
12192 // one Down reached.
12193 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
12194 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
12195 // A table of named operations, which is what it looks like.
12196 #[allow(clippy::type_complexity)]
12197 let motions: [(&str, fn(&mut Doc)); 8] = [
12198 ("right", |d| d.move_right(false)),
12199 ("left", |d| d.move_left(false)),
12200 ("word_right", |d| d.move_word_right(false)),
12201 ("word_left", |d| d.move_word_left(false)),
12202 ("down", |d| d.move_down(false)),
12203 ("up", |d| d.move_up(false)),
12204 ("home", |d| d.move_home(false)),
12205 ("end", |d| d.move_end(false)),
12206 ];
12207 for width in [80, 12] {
12208 let mut d = wysiwyg_doc("stop_invariant", src);
12209 d.build_visual(width);
12210 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12211 assert!(stops.len() > 20, "fixture should have plenty of stops");
12212 for start in stops {
12213 for (name, motion) in &motions {
12214 d.caret = start;
12215 d.anchor = None;
12216 motion(&mut d);
12217 assert!(
12218 d.vmap.is_stop(d.caret),
12219 "{name} from {start} at width {width} landed at {} — not a caret stop",
12220 d.caret
12221 );
12222 }
12223 }
12224 }
12225 }
12226
12227 #[test]
12228 fn no_wysiwyg_motion_is_a_dead_end() {
12229 // Down held to the bottom of a document reaches the bottom, and Up held
12230 // to the top reaches the top — from anywhere, at a width that wraps. The
12231 // invariant above says a motion lands somewhere legal; this one says it
12232 // gets somewhere at all, which is what a caret pinned at a wrap boundary
12233 // was quietly failing to do while every assertion around it held.
12234 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
12235 - item one two three four five\n\nlast\n";
12236 for width in [80, 12] {
12237 let mut d = wysiwyg_doc("no_dead_end", src);
12238 d.build_visual(width);
12239 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12240 let (first, last) = (stops[0], stops[stops.len() - 1]);
12241 for &start in &stops {
12242 for (name, motion, want) in [
12243 (
12244 "down",
12245 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
12246 last,
12247 ),
12248 ("up", |d: &mut Doc| d.move_up(false), first),
12249 ] {
12250 d.caret = start;
12251 d.anchor = None;
12252 d.goal_col = None;
12253 // Every row, plus the presses the edges take, plus slack.
12254 for _ in 0..d.vmap.num_rows() + 4 {
12255 motion(&mut d);
12256 }
12257 assert_eq!(
12258 d.caret, want,
12259 "{name} held from {start} at width {width} never arrived"
12260 );
12261 }
12262 }
12263 }
12264 }
12265 // ── display columns ──────────────────────────────────────────────────────
12266 // A `col` is a terminal cell, not a character. The two are the same number
12267 // for the ASCII the fixtures above are written in, which is how they came
12268 // apart in the first place: `你` is one character drawn in two cells, so a
12269 // column counted in characters names a cell the text isn't in — one earlier
12270 // for every wide character to its left.
12271
12272 #[test]
12273 fn a_wide_character_is_two_columns_wide() {
12274 // The reproduction: `你` is one char and two cells, so the caret just
12275 // past it drew at column 1 — inside the character it had already left.
12276 for (view, tag) in VIEWS {
12277 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
12278 d.caret = "你".len();
12279 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
12280 d.caret = "你好".len();
12281 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
12282 }
12283 }
12284
12285 #[test]
12286 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
12287 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
12288 // two-cell — measuring six cells one at a time, but the character they
12289 // spell is drawn in two. Width belongs to the cluster, not the glyph,
12290 // and the frontends measure it the same way.
12291 let family = "👨👩👧";
12292 for (view, tag) in VIEWS {
12293 let src = format!("a{family}b\n");
12294 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
12295 d.caret = 1 + family.len();
12296 assert_eq!(
12297 d.caret_pos(),
12298 (0, 3),
12299 "{tag}: 'a' is one cell, the family two"
12300 );
12301 }
12302 }
12303
12304 #[test]
12305 fn both_cells_of_a_wide_character_mean_the_character() {
12306 // Clicking the far half of `好` is still clicking `好`: half a character
12307 // is not a place the caret can be, so it comes to rest at the
12308 // character's start — the column it would have been drawn at anyway.
12309 for (view, tag) in VIEWS {
12310 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
12311 for col in [2, 3] {
12312 d.caret = 0;
12313 d.click(0, col, false);
12314 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
12315 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
12316 }
12317 // Past the last cell is the line's end, as it is for ASCII.
12318 d.click(0, 9, false);
12319 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
12320 }
12321 }
12322
12323 #[test]
12324 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
12325 // The mapping is only a mapping if it inverts: the cell the caret is
12326 // drawn in has to be the cell that brings it back to the same offset.
12327 // Over a fixture where a character may be one cell or two, and one
12328 // codepoint or five.
12329 use unicode_segmentation::UnicodeSegmentation;
12330
12331 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
12332
12333 let mut d = doc_in(View::Source, "roundtrip_source", src);
12334 // Every offset the source view's caret can occupy: it steps by grapheme
12335 // cluster, so those are its boundaries.
12336 for (off, _) in src
12337 .grapheme_indices(true)
12338 .chain(std::iter::once((src.len(), "")))
12339 {
12340 d.caret = off;
12341 let (row, col) = d.caret_pos();
12342 d.click(row, col, false);
12343 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
12344 }
12345
12346 // And in WYSIWYG, where the offsets the caret can occupy are the map's
12347 // stops rather than every boundary.
12348 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
12349 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12350 assert!(stops.len() > 20, "fixture should have plenty of stops");
12351 for off in stops {
12352 d.caret = off;
12353 let (row, col) = d.caret_pos();
12354 d.click(row, col, false);
12355 assert_eq!(
12356 d.caret, off,
12357 "wysiwyg: {off} → ({row}, {col}) → {}",
12358 d.caret
12359 );
12360 }
12361 }
12362
12363 #[test]
12364 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
12365 // Down from under `世` lands under the glyph in that cell, not two
12366 // characters further along the line. The goal is a column, so a line of
12367 // wide characters and a line of ASCII line up the way they're drawn.
12368 //
12369 // The gap differs by view: a bare newline inside a paragraph is a soft
12370 // break, which WYSIWYG draws as a space on a single row. The views share
12371 // a grid only where the source's lines are the renderer's rows too.
12372 for (view, tag) in VIEWS {
12373 let gap = if view == View::Source { "\n" } else { "\n\n" };
12374 let src = format!("你好世{gap}abcdef\n");
12375 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
12376 d.caret = "你好".len();
12377 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
12378 d.move_down(false);
12379 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
12380 assert!(
12381 d.source[d.caret..].starts_with('e'),
12382 "{tag}: landed on the wrong glyph"
12383 );
12384 }
12385 }
12386
12387 #[test]
12388 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
12389 // Down from column 3 onto `你好`, whose characters start at columns 0
12390 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
12391 // between the cells of one character, so the caret rests on it — and on
12392 // its start, which is the only offset there that is a caret stop.
12393 for (view, tag) in VIEWS {
12394 let gap = if view == View::Source { "\n" } else { "\n\n" };
12395 let src = format!("abcdef{gap}你好\n");
12396 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
12397 let line = src.find('你').unwrap();
12398 d.caret = 3;
12399 d.move_down(false);
12400 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
12401 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
12402 }
12403 }
12404
12405 #[test]
12406 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
12407 // The column the cell's text is laid out in is measured in cells, so the
12408 // caret walking that text has to be too — the two agreeing is the whole
12409 // point of the grid staying square.
12410 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
12411 let at = d.source.find("你").unwrap();
12412 d.caret = at;
12413 let (row, col) = d.caret_pos();
12414 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
12415 // cells further along.
12416 assert_eq!(col, 2, "the cell's first character");
12417 d.move_right(false);
12418 assert_eq!(
12419 d.caret_pos(),
12420 (row, 4),
12421 "`好` is drawn past `你`'s two cells"
12422 );
12423 assert_eq!(d.caret, at + "你".len());
12424 }
12425
12426 // ── active inline marks ───────────────────────────────────────────────────
12427
12428 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
12429 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
12430 let (src, caret) = parse_caret(marked);
12431 let mut d = doc_in(view, name, &src);
12432 d.caret = caret;
12433 d.active_inline_marks().iter().collect()
12434 }
12435
12436 /// The marks over the selection `[start, end)`.
12437 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
12438 let mut d = doc_in(view, name, src);
12439 d.anchor = Some(start);
12440 d.caret = end;
12441 d.active_inline_marks().iter().collect()
12442 }
12443
12444 #[test]
12445 fn a_caret_in_a_mark_reports_it() {
12446 for (view, tag) in VIEWS {
12447 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
12448 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
12449 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
12450 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
12451 // Plain text under no mark lights nothing — the toolbar's resting state.
12452 assert_eq!(m("a| **bold** b"), [], "{tag}");
12453 assert!(m("plain t|ext").is_empty(), "{tag}");
12454 }
12455 }
12456
12457 #[test]
12458 fn nested_marks_all_report() {
12459 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
12460 // the ancestor chain is a chain, and every mark on it is in force.
12461 for (view, tag) in VIEWS {
12462 assert_eq!(
12463 marks(
12464 view,
12465 &format!("marks_nested_{tag}"),
12466 "**bold and *bo|th*** end"
12467 ),
12468 [InlineKind::Strong, InlineKind::Emph],
12469 "{tag}"
12470 );
12471 }
12472 }
12473
12474 #[test]
12475 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
12476 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
12477 // the first byte of its text and the byte after its last — both inside
12478 // the mark's span, both places typing lands inside the bold. The offset
12479 // past the closing delimiter is the next text, and reports nothing.
12480 let src = "a **bold** b";
12481 let inner_start = src.find("bold").unwrap(); // 4
12482 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
12483 for (view, tag) in VIEWS {
12484 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
12485 for off in [2, 3, inner_start, inner_end, 9] {
12486 d.caret = off;
12487 assert!(
12488 d.active_inline_marks().contains(InlineKind::Strong),
12489 "{tag}: offset {off} is inside the strong span"
12490 );
12491 }
12492 for off in [0, 1, 10, 11, 12] {
12493 d.caret = off;
12494 assert!(
12495 !d.active_inline_marks().contains(InlineKind::Strong),
12496 "{tag}: offset {off} is outside the strong run"
12497 );
12498 }
12499 }
12500 }
12501
12502 #[test]
12503 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
12504 // Regression: twig resolves an offset that is one node's end and the
12505 // next one's start to the node that *starts* there, so `**bold**|\n`
12506 // isn't bold. With nothing following there's no tie to break and the
12507 // chain still ended at the mark, which made a trailing `\n` — not the
12508 // text — decide whether the caret after a bold word reported bold. It's
12509 // the offset past the mark either way, and typing there is plain either
12510 // way. A blank document typed into is exactly this shape.
12511 for (view, tag) in VIEWS {
12512 let m = |name: String, marked| marks(view, &name, marked);
12513 assert_eq!(
12514 m(format!("marks_eob_{tag}"), "**bold**|"),
12515 [],
12516 "{tag}: no trailing newline"
12517 );
12518 assert_eq!(
12519 m(format!("marks_eol_{tag}"), "**bold**|\n"),
12520 [],
12521 "{tag}: with one"
12522 );
12523 // And the last offset that *is* in the mark still is.
12524 assert_eq!(
12525 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
12526 [InlineKind::Strong],
12527 "{tag}"
12528 );
12529 }
12530 }
12531
12532 #[test]
12533 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
12534 let src = "a **bold** b";
12535 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
12536 for (view, tag) in VIEWS {
12537 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
12538 // The whole bold word, and a slice of it.
12539 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
12540 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
12541 // Ending exactly at the closing delimiter's start is still all-bold:
12542 // an exclusive end sits *past* the last selected character, so the
12543 // question is asked of the character, not the boundary.
12544 assert_eq!(
12545 m(b, d_ + 2),
12546 [InlineKind::Strong],
12547 "{tag}: through the close"
12548 );
12549 // Half in, half out: Bold lit here would claim a press turns it off.
12550 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
12551 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
12552 }
12553 }
12554
12555 #[test]
12556 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
12557 // Both ends are bold, but the space between them isn't — two runs are two
12558 // nodes, which is exactly what the node id catches and a kind-only
12559 // comparison would not.
12560 let src = "**one** **two**";
12561 for (view, tag) in VIEWS {
12562 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
12563 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
12564 }
12565 }
12566
12567 #[test]
12568 fn marks_read_the_document_as_it_is_edited() {
12569 // The point of asking twig every frame instead of caching: the answer has
12570 // to follow the toggle that changed it.
12571 let mut d = wysiwyg_doc("marks_live", "one two\n");
12572 d.anchor = Some(0);
12573 d.caret = 3;
12574 assert!(d.active_inline_marks().is_empty(), "plain to start");
12575 d.toggle(InlineKind::Strong);
12576 assert_eq!(d.source, "**one** two\n");
12577 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
12578 assert!(d.active_inline_marks().contains(InlineKind::Strong));
12579 d.toggle(InlineKind::Strong);
12580 assert!(d.active_inline_marks().is_empty(), "and off again");
12581 }
12582
12583 #[test]
12584 fn a_link_is_not_an_inline_mark() {
12585 // `link`/`str` are inline nodes, but nothing on the inline toolbar
12586 // toggles them — a set with a "link mark" in it would have no button.
12587 for (view, tag) in VIEWS {
12588 assert_eq!(
12589 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
12590 [],
12591 "{tag}"
12592 );
12593 }
12594 }
12595
12596 // ── blank documents ───────────────────────────────────────────────────────
12597
12598 #[test]
12599 fn a_blank_document_is_untitled_empty_and_markdown() {
12600 let mut d = Doc::blank().unwrap();
12601 assert!(d.is_untitled());
12602 assert_eq!(d.path, PathBuf::new());
12603 assert_eq!(
12604 d.file_name(),
12605 "untitled",
12606 "the header has to show something"
12607 );
12608 assert_eq!(d.format_name(), "markdown");
12609 assert_eq!(d.source, "");
12610 assert!(!d.dirty, "nothing typed yet is nothing to lose");
12611 assert_eq!(d.disk_state(), DiskState::Untitled);
12612 // And it's a document you can be in: the default view renders it.
12613 d.build_visual(80);
12614 assert_eq!(d.caret, 0);
12615 }
12616
12617 #[test]
12618 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
12619 let mut d = Doc::blank().unwrap();
12620 d.insert("hello");
12621 assert!(d.dirty);
12622 d.save();
12623 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
12624 assert!(d.dirty, "it must not come away believing it saved");
12625 assert!(d.is_untitled(), "and it still has no file");
12626 }
12627
12628 #[test]
12629 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
12630 let p = temp_path("blank_save_as");
12631 let mut d = Doc::blank().unwrap();
12632 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
12633 // be kept literal (`\#`); this test is about save-as, not escaping (which
12634 // has its own test), so it types nothing that escaping would touch.
12635 d.insert("hi");
12636 d.save_as(p.clone());
12637 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
12638 assert!(!d.is_untitled());
12639 assert!(!d.dirty);
12640 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
12641 assert_eq!(
12642 d.disk_state(),
12643 DiskState::Unchanged,
12644 "the watermark is stamped"
12645 );
12646 // And ⌘S is a plain save from here on.
12647 d.insert("!");
12648 d.save();
12649 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
12650 let _ = std::fs::remove_file(&p);
12651 }
12652
12653 // ── a file that isn't there yet ───────────────────────────────────────────
12654
12655 /// A unique path in the temp dir with the given extension, guaranteed not to
12656 /// exist — what `leaf notes.md` is handed when the file has never been made.
12657 fn missing_path(name: &str, ext: &str) -> PathBuf {
12658 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12659 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12660 let mut p = std::env::temp_dir();
12661 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
12662 let _ = std::fs::remove_file(&p);
12663 p
12664 }
12665
12666 #[test]
12667 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
12668 let p = missing_path("named", "md");
12669 let mut d = Doc::open_or_create(p.clone()).unwrap();
12670
12671 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
12672 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
12673 assert!(
12674 !d.is_untitled(),
12675 "it has the name the user asked for — ^S must not detour to Save As"
12676 );
12677 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
12678 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
12679 assert!(!p.exists(), "and opening it wrote nothing");
12680 // And it's a document you can be in.
12681 d.build_visual(80);
12682 assert_eq!(d.caret, 0);
12683 }
12684
12685 #[test]
12686 fn a_new_file_is_created_by_its_first_save() {
12687 let p = missing_path("first_save", "md");
12688 let mut d = Doc::open_or_create(p.clone()).unwrap();
12689 d.insert("hello\n");
12690 assert!(d.dirty);
12691 d.save();
12692
12693 assert_eq!(
12694 std::fs::read_to_string(&p).unwrap(),
12695 "hello\n",
12696 "a plain ^S wrote it — no Save As, no name to invent"
12697 );
12698 assert!(!d.dirty);
12699 assert_eq!(d.disk_state(), DiskState::Unchanged);
12700 let _ = std::fs::remove_file(&p);
12701 }
12702
12703 #[test]
12704 fn a_new_file_takes_its_format_from_the_extension() {
12705 // The one thing `blank` can't do: with no name it has to assume Markdown,
12706 // and typing djot into a Markdown parse is the wrong buffer.
12707 let dj = missing_path("format", "dj");
12708 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
12709 let md = missing_path("format", "md");
12710 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
12711 }
12712
12713 #[test]
12714 fn a_new_file_reports_itself_missing_until_it_is_saved() {
12715 // Not `Untitled` — that's the answer for a document with no path, and it
12716 // would tell a frontend there is nothing a save could collide with. Here
12717 // there is a path, and the file simply isn't at it yet.
12718 let p = missing_path("disk_state", "md");
12719 let mut d = Doc::open_or_create(p.clone()).unwrap();
12720 assert_eq!(d.disk_state(), DiskState::Missing);
12721
12722 // Somebody else creates it while the buffer is open: that's an overwrite
12723 // the frontend has to be able to prompt about, exactly as for an opened
12724 // file. Their bytes, not ours, so `Changed`.
12725 std::fs::write(&p, "theirs\n").unwrap();
12726 assert_eq!(d.disk_state(), DiskState::Changed);
12727
12728 // Saving makes the file ours and re-stamps the watermark.
12729 d.insert("ours\n");
12730 d.save();
12731 assert_eq!(d.disk_state(), DiskState::Unchanged);
12732 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
12733 let _ = std::fs::remove_file(&p);
12734 }
12735
12736 #[test]
12737 fn open_or_create_still_opens_a_file_that_is_there() {
12738 let d = doc_with("open_or_create_existing", "body\n");
12739 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
12740 assert_eq!(reopened.source, "body\n");
12741 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
12742 }
12743
12744 #[test]
12745 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
12746 // A mistyped flag or a stray argument must not become a buffer promising
12747 // to save somewhere — the same refusal `open` gives a real file.
12748 let mut p = std::env::temp_dir();
12749 p.push("leaf_test_new_bad_ext.wat");
12750 assert!(Doc::open_or_create(p).is_err());
12751 let mut none = std::env::temp_dir();
12752 none.push("leaf_test_new_no_ext");
12753 assert!(Doc::open_or_create(none).is_err());
12754 }
12755
12756 #[test]
12757 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
12758 // Opening reads nothing, so there is nothing to fail on yet; the write is
12759 // where it fails, and it says so rather than claiming a save.
12760 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
12761 let mut d = Doc::open_or_create(p).unwrap();
12762 d.insert("x");
12763 d.save();
12764 assert!(
12765 d.status.as_deref().unwrap().starts_with("save failed:"),
12766 "got {:?}",
12767 d.status
12768 );
12769 assert!(d.dirty, "it must not come away believing it saved");
12770 }
12771
12772 // ── save as ───────────────────────────────────────────────────────────────
12773
12774 /// A unique path in the temp dir that no fixture wrote — a Save As target.
12775 fn temp_path(name: &str) -> PathBuf {
12776 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12777 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12778 let mut p = std::env::temp_dir();
12779 p.push(format!("leaf_test_target_{name}_{seq}.md"));
12780 let _ = std::fs::remove_file(&p);
12781 p
12782 }
12783
12784 #[test]
12785 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
12786 let mut d = doc_with("save_as_move", "original\n");
12787 let old = d.path.clone();
12788 let new = temp_path("save_as_move");
12789 d.insert("edited: ");
12790 d.save_as(new.clone());
12791
12792 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
12793 assert_eq!(
12794 std::fs::read_to_string(&old).unwrap(),
12795 "original\n",
12796 "Save As doesn't touch the file it came from"
12797 );
12798 assert_eq!(d.path, new, "the document moved");
12799 assert!(!d.dirty);
12800 assert_eq!(
12801 d.status.as_deref(),
12802 Some(&*format!("saved {}", d.file_name()))
12803 );
12804
12805 // Every later save follows it, which is the whole difference from a copy.
12806 d.caret = 0;
12807 d.insert("re-");
12808 d.save();
12809 assert_eq!(
12810 std::fs::read_to_string(&new).unwrap(),
12811 "re-edited: original\n"
12812 );
12813 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
12814 let _ = std::fs::remove_file(&new);
12815 }
12816
12817 #[test]
12818 fn save_as_overwrites_an_existing_target() {
12819 // The picker already asked; asking again down here is the same question
12820 // twice, and the second one has no way to be answered.
12821 let new = temp_path("save_as_over");
12822 std::fs::write(&new, "theirs\n").unwrap();
12823 let mut d = doc_with("save_as_over", "ours\n");
12824 d.save_as(new.clone());
12825 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
12826 let _ = std::fs::remove_file(&new);
12827 }
12828
12829 #[test]
12830 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
12831 let mut d = doc_with("save_as_fail", "body\n");
12832 let old = d.path.clone();
12833 d.insert("x");
12834 // A directory that doesn't exist: the write can't land.
12835 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
12836 d.save_as(bad);
12837
12838 assert_eq!(
12839 d.path, old,
12840 "the document must not move to a file that isn't there"
12841 );
12842 assert!(d.dirty, "and must not believe it saved");
12843 assert!(
12844 d.status.as_deref().unwrap().starts_with("save failed:"),
12845 "the same failure a plain save reports, got {:?}",
12846 d.status
12847 );
12848 // The original is still the document's file, and still saveable.
12849 d.save();
12850 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
12851 assert!(!d.dirty);
12852 }
12853
12854 #[test]
12855 fn save_as_renames_without_reparsing_the_format() {
12856 // `.dj` on the name doesn't make the buffer djot: it was parsed as
12857 // Markdown and still is, and saying otherwise would be a conversion the
12858 // user never asked for (and an undo history thrown away to do it).
12859 let mut d = doc_with("save_as_format", "**b**\n");
12860 let mut new = temp_path("save_as_format");
12861 new.set_extension("dj");
12862 d.save_as(new.clone());
12863 assert_eq!(d.format_name(), "markdown");
12864 let _ = std::fs::remove_file(&new);
12865 }
12866
12867 // ── external change / reload ──────────────────────────────────────────────
12868
12869 #[test]
12870 fn an_untouched_file_reports_unchanged() {
12871 let mut d = doc_with("disk_clean", "body\n");
12872 assert_eq!(d.disk_state(), DiskState::Unchanged);
12873 // Editing the buffer is not editing the file.
12874 d.insert("x");
12875 assert_eq!(d.disk_state(), DiskState::Unchanged);
12876 assert!(d.dirty);
12877 // Saving re-stamps the watermark rather than reporting our own bytes back.
12878 d.save();
12879 assert_eq!(d.disk_state(), DiskState::Unchanged);
12880 }
12881
12882 #[test]
12883 fn a_file_written_underneath_reports_changed() {
12884 let mut d = doc_with("disk_changed", "body\n");
12885 std::fs::write(&d.path, "someone else\n").unwrap();
12886 assert_eq!(d.disk_state(), DiskState::Changed);
12887 // Dirty *and* changed is the clobber: both halves are readable, and
12888 // leaf-core takes neither side.
12889 d.insert("x");
12890 assert!(d.dirty && d.disk_state() == DiskState::Changed);
12891 // Saving anyway is allowed — the frontend asked, or chose not to.
12892 d.save();
12893 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
12894 assert_eq!(d.disk_state(), DiskState::Unchanged);
12895 }
12896
12897 #[test]
12898 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
12899 // The hash is what makes this honest: the file was written (a fresh
12900 // mtime), and nothing about the document is stale.
12901 let d = doc_with("disk_same_bytes", "body\n");
12902 std::fs::write(&d.path, "body\n").unwrap();
12903 assert_eq!(d.disk_state(), DiskState::Unchanged);
12904 }
12905
12906 #[test]
12907 fn a_deleted_file_reports_missing() {
12908 let mut d = doc_with("disk_missing", "body\n");
12909 std::fs::remove_file(&d.path).unwrap();
12910 assert_eq!(d.disk_state(), DiskState::Missing);
12911 // A save recreates it, and the document is whole again.
12912 d.save();
12913 assert_eq!(d.disk_state(), DiskState::Unchanged);
12914 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
12915 }
12916
12917 #[test]
12918 fn reload_replaces_the_document_with_the_file() {
12919 for (view, tag) in VIEWS {
12920 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
12921 d.insert("edited ");
12922 assert!(d.dirty);
12923 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12924 d.reload();
12925
12926 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
12927 assert!(!d.dirty, "{tag}: the file is what we have");
12928 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
12929 assert_eq!(
12930 d.status.as_deref(),
12931 Some(&*format!("reloaded {}", d.file_name()))
12932 );
12933 // The reloaded tree is live, not the old parse.
12934 d.caret = d.source.find("three").unwrap();
12935 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
12936 }
12937 }
12938
12939 #[test]
12940 fn reload_clamps_the_caret_and_drops_the_selection() {
12941 let mut d = doc_with("reload_caret", "a long first line\n");
12942 d.caret = 12;
12943 d.anchor = Some(4);
12944 std::fs::write(&d.path, "short\n").unwrap();
12945 d.reload();
12946 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
12947 assert_eq!(
12948 d.anchor, None,
12949 "a selection over bytes that changed is a lie"
12950 );
12951 assert!(d.selection().is_none());
12952
12953 // A caret the file still has room for stays put.
12954 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
12955 d.caret = 2;
12956 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12957 d.reload();
12958 assert_eq!(d.caret, 2);
12959 }
12960
12961 /// A silent reload is something that happened *to* a reader — a formatter,
12962 /// a `git checkout` — so it has to be undoable like anything else that
12963 /// changes the document, and undoable as one step rather than as however
12964 /// many the file happens to differ by.
12965 #[test]
12966 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
12967 let mut d = doc_with("reload_undo", "body\n");
12968 d.insert("x");
12969 assert_eq!(d.source, "xbody\n");
12970 std::fs::write(&d.path, "replaced\n").unwrap();
12971 d.reload();
12972 assert_eq!(d.source, "replaced\n");
12973 assert!(!d.dirty, "a reload lands clean");
12974
12975 // One ^Z takes the whole swap off, and hands back the unsaved work it
12976 // replaced — which is unsaved again, because the file no longer says it.
12977 d.undo();
12978 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
12979 assert!(d.dirty, "and what it comes back to is unsaved");
12980 // …and the history under it is still there.
12981 d.undo();
12982 assert_eq!(
12983 d.source, "body\n",
12984 "the typing before the reload undoes too"
12985 );
12986 // Redo walks back up through the reload.
12987 d.redo();
12988 d.redo();
12989 assert_eq!(d.source, "replaced\n");
12990 }
12991
12992 /// A file rewritten with the bytes it already had is not an edit, so it
12993 /// must not leave an undo step behind for something nobody did.
12994 #[test]
12995 fn reloading_identical_bytes_pushes_no_undo_step() {
12996 let mut d = doc_with("reload_same", "body\n");
12997 d.insert("x");
12998 std::fs::write(&d.path, "xbody\n").unwrap();
12999 d.reload();
13000 assert_eq!(d.source, "xbody\n");
13001 assert!(!d.dirty, "the file now says what the buffer does");
13002 d.undo();
13003 assert_eq!(
13004 d.source, "body\n",
13005 "one step back is the typing, not a no-op"
13006 );
13007 }
13008
13009 #[test]
13010 fn a_reload_that_cant_read_leaves_the_document_alone() {
13011 let mut d = doc_with("reload_gone", "body\n");
13012 d.insert("x");
13013 std::fs::remove_file(&d.path).unwrap();
13014 d.reload();
13015 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
13016 assert!(d.dirty);
13017 assert!(
13018 d.status.as_deref().unwrap().starts_with("reload failed:"),
13019 "{:?}",
13020 d.status
13021 );
13022
13023 // And an untitled document has nothing to reload from.
13024 let mut d = Doc::blank().unwrap();
13025 d.insert("typed");
13026 d.reload();
13027 assert_eq!(d.source, "typed");
13028 assert_eq!(d.status.as_deref(), Some("no file to reload"));
13029 }
13030
13031 #[test]
13032 fn a_read_only_document_refuses_every_door() {
13033 let mut d = doc_with("readonly", "one two three\n");
13034 d.insert("x");
13035 assert!(d.dirty, "writable first, so the undo step exists");
13036 d.set_read_only(true);
13037 let before = d.source.clone();
13038 d.insert("y");
13039 d.backspace();
13040 d.undo();
13041 d.redo();
13042 assert_eq!(d.source, before, "no door moved a byte");
13043 d.set_read_only(false);
13044 d.undo();
13045 assert_ne!(d.source, before, "off again, the same doors work");
13046 }
13047
13048 #[test]
13049 fn a_selection_quote_carries_its_context_on_char_boundaries() {
13050 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
13051 let start = d.source.find("exact").unwrap();
13052 d.place_caret(start, false);
13053 d.place_caret(start + "exact".len(), true);
13054 let q = d.selection_quote(3).unwrap();
13055 assert_eq!(q.exact, "exact");
13056 assert_eq!(
13057 q.prefix, "你好 ",
13058 "chars, not bytes — the multibyte pair counts as two"
13059 );
13060 assert_eq!(q.suffix, " 世界");
13061 assert_eq!(&d.source[q.start..q.end], "exact");
13062 // At the edges the context clips rather than erring.
13063 d.place_caret(0, false);
13064 d.place_caret(6, true);
13065 let q = d.selection_quote(40).unwrap();
13066 assert_eq!(q.prefix, "");
13067 assert_eq!(q.exact, "before");
13068 // No selection is no quote.
13069 d.place_caret(0, false);
13070 assert!(d.selection_quote(3).is_none());
13071 }
13072
13073 #[test]
13074 fn highlights_are_kept_sorted_and_answer_point_queries() {
13075 let mut d = doc_with("hl", "one two three\n");
13076 d.set_highlights(vec![
13077 Highlight {
13078 start: 8,
13079 end: 13,
13080 id: "b".into(),
13081 color: None,
13082 marker: None,
13083 },
13084 Highlight {
13085 start: 0,
13086 end: 3,
13087 id: "a".into(),
13088 color: Some("#ffe066".into()),
13089 marker: None,
13090 },
13091 Highlight {
13092 start: 5,
13093 end: 5,
13094 id: "empty".into(),
13095 color: None,
13096 marker: None,
13097 },
13098 ]);
13099 assert_eq!(
13100 d.highlights()
13101 .iter()
13102 .map(|h| h.id.as_str())
13103 .collect::<Vec<_>>(),
13104 ["a", "b"],
13105 "sorted by start, the empty range dropped"
13106 );
13107 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
13108 assert_eq!(d.highlight_at(3), None, "end is exclusive");
13109 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
13110 d.set_highlights(Vec::new());
13111 assert!(d.highlights().is_empty(), "a replace is a replace");
13112 }
13113
13114 /// `Highlight::covering` and the cursor over it are what both painters ask
13115 /// per glyph, so they have to answer the same as the scan they replaced —
13116 /// including in the gaps, which is where most glyphs are.
13117 #[test]
13118 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
13119 let hl = |start: usize, end: usize, id: &str| Highlight {
13120 start,
13121 end,
13122 id: id.into(),
13123 color: None,
13124 marker: None,
13125 };
13126 // Disjoint, as search hits are: in a range, in a gap, and past the end.
13127 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
13128 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
13129 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
13130 assert_eq!(
13131 Highlight::covering(&hits, 105),
13132 None,
13133 "a gap covers nothing"
13134 );
13135 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
13136 assert_eq!(Highlight::covering(&hits, 9_999), None);
13137 assert_eq!(Highlight::covering(&[], 0), None);
13138
13139 // Nested: first by start, so a hit inside an annotation still resolves
13140 // to the annotation — and the range that stops short doesn't mask it.
13141 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
13142 assert_eq!(
13143 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
13144 Some("outer")
13145 );
13146 assert_eq!(
13147 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
13148 Some("outer")
13149 );
13150 }
13151
13152 /// The cursor is an optimisation, so the only thing worth asserting is that
13153 /// it is not also a change of answer — at every offset, over a list with a
13154 /// nest in it, walked forwards and then backwards.
13155 #[test]
13156 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
13157 let hl = |start: usize, end: usize, id: &str| Highlight {
13158 start,
13159 end,
13160 id: id.into(),
13161 color: None,
13162 marker: None,
13163 };
13164 let mut list = vec![
13165 hl(0, 20, "outer"),
13166 hl(5, 10, "inner"),
13167 hl(30, 33, "hit"),
13168 hl(40, 43, "hit"),
13169 ];
13170 list.sort_by_key(|h| (h.start, h.end));
13171
13172 let mut cursor = HighlightCursor::new(&list);
13173 for offset in 0..50 {
13174 assert_eq!(
13175 cursor.at(offset).map(|h| h.id.as_str()),
13176 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13177 "cursor disagrees at {offset}"
13178 );
13179 }
13180 // Backwards: the cursor re-seats rather than answering from where it
13181 // had got to, so a painter that revisits a row is still told the truth.
13182 for offset in (0..50).rev() {
13183 assert_eq!(
13184 cursor.at(offset).map(|h| h.id.as_str()),
13185 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13186 "cursor disagrees walking back at {offset}"
13187 );
13188 }
13189 }
13190}