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::style::MarkColor;
44use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
45
46/// Which view the body shows.
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum View {
49 /// The raw document with a caret in source bytes.
50 Source,
51 /// Markup resolved to real styles, caret riding the rendered glyphs.
52 Wysiwyg,
53}
54
55/// How much of the source markup the WYSIWYG view exposes — a per-editor
56/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
57/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
58/// terms, and every rung below is about *delimiters*, whatever grammar spells
59/// them. The examples are Markdown only because that is what most documents are.
60///
61/// A single ladder over two underlying axes, because only three of their four
62/// combinations are coherent:
63///
64/// | | authoring off | authoring on |
65/// |---|---|---|
66/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
67/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
68///
69/// The empty quadrant would show delimiters on the caret's line and then escape
70/// the ones you type — a surface that displays a syntax it refuses to accept.
71/// Someone who wants to read raw markup without authoring it has
72/// [`View::Source`], which is the better tool for it.
73///
74/// The two axes are read separately by the code that cares — see
75/// [`reveals_caret_line`](Self::reveals_caret_line) and
76/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
77/// as a ladder.
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
79pub enum MarkupMode {
80 /// Delimiters stay hidden even on the caret's line, and typed syntax stays
81 /// literal — twig escapes anything that would open markup, so formatting
82 /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
83 /// reading surface for people who don't write markup by hand; the default,
84 /// and what Diaryx ships.
85 #[default]
86 None,
87 /// Delimiters stay hidden, but typing them authors real markup: `*x*`
88 /// becomes italic and the asterisks disappear into the styling
89 /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
90 /// clean surface back once it has been applied.
91 Shortcuts,
92 /// The caret's line shows its raw markup while every other line renders
93 /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
94 /// — for people fluent in the document's grammar who want to see and edit
95 /// the delimiters they type.
96 Full,
97}
98
99impl MarkupMode {
100 /// Whether the rich view shows raw delimiters on the line holding the caret.
101 /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
102 /// WYSIWYG builder.
103 pub fn reveals_caret_line(self) -> bool {
104 matches!(self, MarkupMode::Full)
105 }
106
107 /// Whether typed markup characters author real formatting. The editing axis
108 /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
109 pub fn authors(self) -> bool {
110 !matches!(self, MarkupMode::None)
111 }
112}
113
114/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
115/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
116/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
117/// either flow. The renderer consults it when it lays a block's inline content
118/// into visual rows.
119#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
120pub enum LineFlow {
121 /// A soft break folds into a space and the paragraph reflows to the
122 /// viewport width — flowing prose, where the source's line wrapping is
123 /// insignificant. The default, and what Diaryx ships.
124 #[default]
125 Fold,
126 /// A soft break renders as a line break exactly where it was written, so
127 /// the author's source line structure shows on screen unchanged — the mode
128 /// for people who lay out their prose deliberately (one sentence or clause
129 /// per line, semantic line breaks). The break is still a soft break in the
130 /// source; only its rendering changes.
131 Preserve,
132}
133
134/// What the file behind a document looks like right now, against the bytes leaf
135/// last read from it or wrote to it — the question a frontend asks before it
136/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
137/// happen) or when its window regains focus. See [`Doc::disk_state`].
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum DiskState {
140 /// The file holds exactly the bytes leaf last read or wrote.
141 Unchanged,
142 /// Someone else wrote the file since. Saving overwrites their work; see
143 /// [`Doc::reload`] for the other direction.
144 Changed,
145 /// The file is gone — deleted or renamed away. A save recreates it.
146 Missing,
147 /// There is a path, but the file couldn't be read (permissions, a directory
148 /// in the way): leaf can't tell, and won't guess.
149 Unreadable,
150 /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
151 /// changed under a document that was never on disk.
152 Untitled,
153}
154
155/// The inline marks in force at a point in the document — what a toolbar
156/// lights up. A `Copy` bitset rather than a `HashSet`, because
157/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
158/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub struct InlineMarks(u8);
161
162impl InlineMarks {
163 /// Every kind, in the order [`InlineMarks::iter`] yields them.
164 const ALL: [InlineKind; 8] = [
165 InlineKind::Strong,
166 InlineKind::Emph,
167 InlineKind::Verbatim,
168 InlineKind::Mark,
169 InlineKind::Superscript,
170 InlineKind::Subscript,
171 InlineKind::Insert,
172 InlineKind::Delete,
173 ];
174
175 pub const fn empty() -> Self {
176 InlineMarks(0)
177 }
178
179 /// Private: the set is an *answer*, and adding a mark to it doesn't mark
180 /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
181 fn insert(&mut self, kind: InlineKind) {
182 self.0 |= Self::bit(kind);
183 }
184
185 /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
186 fn flip(&mut self, kind: InlineKind) {
187 self.0 ^= Self::bit(kind);
188 }
189
190 /// The symmetric difference: which marks differ between the two sets. Used
191 /// to resolve the marks already in force at the caret against the pending
192 /// delta — a bit set in the delta flips the base mark for the next keystroke.
193 fn xor(self, other: InlineMarks) -> InlineMarks {
194 InlineMarks(self.0 ^ other.0)
195 }
196
197 /// Whether `kind` is in force — the toolbar's "is Bold active?".
198 pub fn contains(self, kind: InlineKind) -> bool {
199 self.0 & Self::bit(kind) != 0
200 }
201
202 pub fn is_empty(self) -> bool {
203 self.0 == 0
204 }
205
206 /// The marks in force, for a frontend that renders whatever is on rather
207 /// than asking after a fixed list.
208 pub fn iter(self) -> impl Iterator<Item = InlineKind> {
209 Self::ALL.into_iter().filter(move |&k| self.contains(k))
210 }
211
212 fn bit(kind: InlineKind) -> u8 {
213 1 << match kind {
214 InlineKind::Strong => 0,
215 InlineKind::Emph => 1,
216 InlineKind::Verbatim => 2,
217 InlineKind::Mark => 3,
218 InlineKind::Superscript => 4,
219 InlineKind::Subscript => 5,
220 InlineKind::Insert => 6,
221 InlineKind::Delete => 7,
222 }
223 }
224}
225
226impl FromIterator<InlineKind> for InlineMarks {
227 fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
228 let mut m = InlineMarks::empty();
229 for k in iter {
230 m.insert(k);
231 }
232 m
233 }
234}
235
236/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
237/// into one undo step (a run of typed characters undoes together); `Other` never
238/// coalesces, so a paste, format toggle, or block change is always its own step.
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum EditKind {
241 Insert,
242 Delete,
243 /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
244 /// rather than `Insert`'s because a composition is not typing: each step
245 /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
246 /// though no two steps insert the same bytes, and it must not fold into the
247 /// typed characters on either side of it.
248 Compose,
249 Other,
250}
251
252/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
253/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
254/// the caret), `Forward` is Delete (one starting at it).
255#[derive(Clone, Copy)]
256enum BreakEdge {
257 Backward,
258 Forward,
259}
260
261/// A re-spelling of one inline mark run, held ready in case the edit about to
262/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
263/// Every offset in it is in the coordinates the document will have *after* the
264/// plain edit, since that is when it may be applied.
265struct MarkEdgeFix {
266 /// The run's kind, and an offset inside what was its content: together they
267 /// answer "did the plain edit actually break this mark?" — the question that
268 /// decides whether any of this is applied at all.
269 kind: InlineKind,
270 probe: usize,
271 /// The byte range to re-spell (the run's delimiters included) and its new
272 /// spelling, with the edge whitespace moved outside the delimiters.
273 start: usize,
274 end: usize,
275 text: String,
276 /// Where the caret belongs afterwards — the same place on screen it would
277 /// have had, which is now on the other side of a delimiter.
278 caret: usize,
279 /// The marks in force for text typed at that caret. The caret can land
280 /// outside a run it was inside, and the marks have to survive the move or
281 /// the toolbar goes dark mid-word.
282 want: InlineMarks,
283}
284
285/// The caret and selection at one moment — the part of a history step twig's
286/// `Change` cannot carry, because the caret is leaf's state and twig only knows
287/// about bytes. leaf serializes it into the opaque per-state blob twig now
288/// stores in its own undo history (see `record_caret`), so undo and redo hand
289/// back the caret that matches the source they restore.
290#[derive(Clone, Copy)]
291struct CaretState {
292 caret: usize,
293 anchor: Option<usize>,
294}
295
296impl CaretState {
297 /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
298 /// then an anchor-present flag and the anchor. twig copies these bytes and
299 /// never reads them.
300 fn to_blob(self) -> [u8; 17] {
301 let mut b = [0u8; 17];
302 b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
303 if let Some(a) = self.anchor {
304 b[8] = 1;
305 b[9..].copy_from_slice(&(a as u64).to_le_bytes());
306 }
307 b
308 }
309
310 /// Recover a state from twig's blob, or `None` when it is empty or the wrong
311 /// length — a state twig restored that never had a caret set on it, which
312 /// leaves the caller to fall back to the edit site.
313 fn from_blob(b: &[u8]) -> Option<Self> {
314 let b: &[u8; 17] = b.try_into().ok()?;
315 let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
316 let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
317 Some(CaretState { caret, anchor })
318 }
319}
320
321/// A footnote reference and the note it names — the answer to
322/// [`Doc::footnote_at`].
323///
324/// The two `Option`s move together: a reference whose definition is missing has
325/// neither a body to show nor a place to jump to, and one that resolved has
326/// both.
327#[derive(Clone, PartialEq, Eq, Debug)]
328pub struct FootnoteRef {
329 /// The reference's label — the `1` of `[^1]`, with neither the `^` that
330 /// spells it a footnote nor the brackets around it.
331 pub label: String,
332 /// The note's body as source bytes (see
333 /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
334 /// document defines no `[^label]:` to read one from.
335 pub text: Option<String>,
336 /// Where the note's *body* starts, for a "go to note" that moves the caret
337 /// there. `None` alongside a `None` `text`.
338 ///
339 /// The body rather than the definition, because this is an offset to put a
340 /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
341 /// aiming at the definition's first byte snaps to the nearest real stop,
342 /// which is up in the paragraph above the note. It is also simply where a
343 /// reader following a reference wants to land: at the note's first word,
344 /// ready to read or amend it.
345 pub offset: Option<usize>,
346 /// Where the note's body ends, exclusive — so a frontend can ask which
347 /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
348 ///
349 /// The rows are the note with its markup resolved: `see *later*` reaches a
350 /// frontend as an italic run, not as asterisks. `text` is the source bytes
351 /// and stays the honest answer for anything that wants the note as written
352 /// (a search index, a copy); this pair of offsets is for anything that wants
353 /// it as *read*. `None` alongside a `None` `offset`.
354 pub end: Option<usize>,
355}
356
357/// A footnote definition and the reference that sends a reader to it — the
358/// answer to [`Doc::footnote_definition_at`], and the other half of the round
359/// trip [`FootnoteRef`] starts.
360///
361/// A note is a place a reader *arrives*, so the useful thing to know while
362/// standing in one is the way back. Without this the jump to a note is a
363/// one-way door: the definitions sit at the foot of the document, so returning
364/// by hand means scrolling back up and finding the sentence again.
365#[derive(Clone, PartialEq, Eq, Debug)]
366pub struct FootnoteDef {
367 /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
368 /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
369 pub label: String,
370 /// Where the reference's *label* is, for a "back to reference" that moves
371 /// the caret there. `None` for a note nothing refers to — an orphan, which
372 /// is worth being able to say rather than silently doing nothing.
373 ///
374 /// The label rather than the reference's first byte, for
375 /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
376 /// and its label is the only part of it the caret can rest on.
377 ///
378 /// The *first* reference, when a label is cited more than once: a repeated
379 /// citation has no one true home, and the first is both the one a reader
380 /// most likely came from and the only choice that doesn't depend on how
381 /// they got here.
382 pub offset: Option<usize>,
383}
384
385/// Where a locator lands — the answer to [`Doc::locate`].
386///
387/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
388/// document, and a place is a span rather than a point: a reader following one
389/// wants the caret at its first byte, and a reader merely *peeking* at one wants
390/// the block it covers drawn. Both are served by carrying the whole span, and
391/// only one of the two can be recovered from an offset alone.
392#[derive(Clone, PartialEq, Eq, Debug)]
393pub struct Landing {
394 /// The first byte of the block the locator names — where a caret goes.
395 pub start: usize,
396 /// One past its last byte, so a frontend can map the pair through
397 /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
398 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
399 pub end: usize,
400}
401
402/// A selection cited out of the source: the text itself, up to a requested
403/// number of characters either side, and the byte range it came from. See
404/// [`Doc::selection_quote`].
405///
406/// The prefix and suffix are what make the quote *re-findable*: the same text
407/// can occur twice, and a little of what surrounded it is how a later reader —
408/// or the same document after an edit — tells the occurrences apart. The Web
409/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
410/// the name.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct Quote {
413 /// The selected source, verbatim.
414 pub exact: String,
415 /// What immediately preceded it — possibly empty, at the document's start.
416 pub prefix: String,
417 /// What immediately followed it — possibly empty, at the document's end.
418 pub suffix: String,
419 /// Byte offset in the source where the selection begins.
420 pub start: usize,
421 /// Byte offset where it ends (exclusive).
422 pub end: usize,
423}
424
425/// A host-painted range of the source — an annotation's footprint, a search
426/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
427/// glyphs whose source falls inside it) and hands back the `id` when the
428/// reader activates it; what the range *means* is entirely the host's.
429///
430/// Ranges are source bytes, like the caret and the selection, so a host that
431/// anchors quotes against the source ([`Doc::selection_quote`] is the other
432/// half of that loop) can paint what it found without any coordinate
433/// conversion. A range that drifts off the text it meant is the host's to
434/// re-anchor; leaf draws what it is told.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct Highlight {
437 /// Byte offset in the source where the wash begins.
438 pub start: usize,
439 /// Byte offset where it ends (exclusive).
440 pub end: usize,
441 /// The host's name for it, handed back on activation. Opaque to leaf.
442 pub id: String,
443 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
444 /// nothing for the theme's default wash.
445 pub color: Option<String>,
446 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
447 /// a marker gets a small glyph in the margin beside its first line, and
448 /// the glyph — not the wash — is what activates it: the wash is ink, the
449 /// marker is the control, which is what lets a reader put a caret in (or
450 /// copy from) annotated text without a card leaping at them. The name is
451 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
452 /// as a class.
453 pub marker: Option<String>,
454}
455
456impl Highlight {
457 /// The range covering source `offset` in a list [`Doc::set_highlights`]
458 /// sorted, first by start where several overlap — the one place that
459 /// question is answered, for the frontends that paint by asking it as well
460 /// as for [`Doc::highlight_at`].
461 ///
462 /// The list is sorted by `(start, end)`, so the scan can stop at the first
463 /// range starting past `offset` rather than running to the end. A painter
464 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
465 /// one-shot form, for the host asking what the reader just activated.
466 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
467 highlights
468 .iter()
469 .take_while(|h| h.start <= offset)
470 .find(|h| offset < h.end)
471 }
472}
473
474/// [`Highlight::covering`] for a caller walking the document in order — which
475/// is every painter, since a frontend draws rows top to bottom and glyphs left
476/// to right.
477///
478/// The one-shot form is a scan from the front of the list per glyph, and a
479/// document with two hundred search hits pays that two hundred times a row. A
480/// range that ends at or before an offset can never cover that offset *or any
481/// later one*, so the cursor retires those permanently and each glyph costs the
482/// ranges that actually reach it. The answer is identical to
483/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
484/// the part that was being redone dropped, not a cheaper approximation.
485///
486/// Offsets are expected to arrive non-decreasing. One that goes backwards is
487/// still answered correctly: the cursor re-seats to the front, since a painter
488/// that revisits a row is asking a question the retired ranges may own again.
489pub struct HighlightCursor<'a> {
490 highlights: &'a [Highlight],
491 /// The first range not yet retired.
492 at: usize,
493 /// The last offset asked about, to notice a caller going backwards.
494 last: usize,
495}
496
497impl<'a> HighlightCursor<'a> {
498 pub fn new(highlights: &'a [Highlight]) -> Self {
499 HighlightCursor {
500 highlights,
501 at: 0,
502 last: 0,
503 }
504 }
505
506 /// The range covering `offset`, advancing the cursor past every range that
507 /// can no longer cover anything.
508 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
509 if offset < self.last {
510 self.at = 0;
511 }
512 self.last = offset;
513 while self
514 .highlights
515 .get(self.at)
516 .is_some_and(|h| h.end <= offset)
517 {
518 self.at += 1;
519 }
520 Highlight::covering(&self.highlights[self.at..], offset)
521 }
522}
523
524/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
525/// purpose: the only useful question is whether two of them are the same map,
526/// and what is behind it — which `Doc` built it, and the (revision, wrap,
527/// reveal line) it was built from — is core's business.
528///
529/// The document is part of it because the rest is not unique to one: two
530/// documents opened at the same width are both at revision zero with no reveal
531/// line, and a frontend holding one copy of a map across the two would take
532/// the second's key for the first's and paint the wrong document.
533#[derive(Clone, PartialEq, Eq, Debug)]
534pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Range<usize>>)>);
535
536pub struct Doc {
537 editor: Editor,
538 pub format: Format,
539 pub path: PathBuf,
540 /// Current source, refreshed from the editor after every successful edit.
541 pub source: String,
542 /// The caret, as a byte offset into `source` (always on a char boundary).
543 pub caret: usize,
544 /// The selection's fixed end, if a selection is active; the moving end is
545 /// the caret. `None` means no selection.
546 pub anchor: Option<usize>,
547 pub dirty: bool,
548 pub status: Option<String>,
549 pub view: View,
550 /// Whether the document refuses to change — a *reading* surface over the
551 /// same rendering, selection, and navigation the editor has.
552 ///
553 /// Enforced here rather than by each frontend hiding its input paths,
554 /// because every mutation funnels through a few doors —
555 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
556 /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
557 /// verbs directly rather than through the splice (a typed literal, a link,
558 /// an image, a rule, a footnote, a cell's line break) — and guarded doors
559 /// are a guarantee where a frontend's suppressed keyboard is a hope. A
560 /// gated door reports exactly like a rolled-back splice, a path every
561 /// caller already handles. `a_read_only_document_refuses_every_door` is
562 /// the list; a new `self.editor.insert_*` call belongs on it.
563 read_only: bool,
564 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
565 /// State like the selection rather than like the text: no edit history,
566 /// no dirty bit, redrawn from whatever the host last set.
567 highlights: Vec<Highlight>,
568 /// How much of the source markup the rich view exposes — a frontend preference (see
569 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
570 /// [`reveal_line`](Self::reveal_line), the editing one by
571 /// [`insert`](Self::insert).
572 markup_mode: MarkupMode,
573 /// Whether soft breaks fold into the reflowed paragraph or render where
574 /// they were written (see [`LineFlow`]) — an independent frontend
575 /// preference the WYSIWYG builder consults when it lays out a block.
576 line_flow: LineFlow,
577 /// The kind of the last edit, for coalescing: twig owns the undo *history*
578 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
579 /// call, so leaf decides when a run continues and tells twig to coalesce.
580 last_edit_kind: Option<EditKind>,
581 /// The inline marks the user has toggled *at a collapsed caret* with no
582 /// selection — "start typing bold here". Held as the XOR delta from the marks
583 /// already in force at [`pending_at`](Self::pending_at): a set bit means
584 /// "flip this kind for the next typed text", so it both turns a mark on where
585 /// none is (type into bold) and off where one already covers the caret (type
586 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
587 /// freshly typed text and then clears it — a mark once realised is carried by
588 /// the caret sitting inside the run, not by this delta.
589 pending_marks: InlineMarks,
590 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
591 /// delta is live only while the caret still stands here with no selection;
592 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
593 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
594 pending_at: Option<usize>,
595 /// The source as of the last open/save — `dirty` is `source != clean_source`,
596 /// so undoing back to the saved state correctly clears the modified flag.
597 clean_source: String,
598 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
599 /// while the document has no file behind it. [`Doc::disk_state`] compares
600 /// the file against this to catch an edit made *outside* leaf before a save
601 /// silently overwrites it — `clean_source` only knows what leaf itself did.
602 ///
603 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
604 /// writes inside one filesystem timestamp tick are indistinguishable, a
605 /// clock that steps backwards (or a writer that restores an mtime) hides a
606 /// real change, and a `touch` invents one. The whole point of the watermark
607 /// is to not clobber someone's work, so it reads the bytes and compares what
608 /// is actually there. That costs a file read per question, which is why the
609 /// question is asked on a user event (focus, save) and not every frame.
610 disk_hash: Option<u64>,
611 /// The "sticky" display column vertical motion aims for, in the active
612 /// view's grid. Set on the first `move_up`/`move_down` of a run and
613 /// reused by every subsequent one in that run, so passing through a
614 /// shorter line doesn't permanently forget the original column. Any
615 /// horizontal motion or edit clears it.
616 ///
617 /// A column, not a character index: dropping down a line of `你好` onto one
618 /// of ASCII has to land under the glyph the caret was drawn beneath, which
619 /// is the only thing the user can see to aim by. Where the goal falls inside
620 /// a wide character on the target line, the mapping resolves it to that
621 /// character — the caret lands on it rather than between its cells.
622 goal_col: Option<usize>,
623 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
624 /// and clicks read it to stay in visible space.
625 pub vmap: VisualMap,
626 /// The syntax map for the source view; empty in the WYSIWYG view, which
627 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
628 /// frontend that never calls it paints raw source unstyled, which is what
629 /// every frontend did before this map existed.
630 pub smap: SourceMap,
631 /// The revision `smap` was built from, or `None` before the first build.
632 /// The map is a pure function of the text alone — no width, no caret, no
633 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
634 /// whole key.
635 smap_key: Option<u64>,
636 /// Everything the map is built from, as one number: bumped whenever the
637 /// document's text changes, and never by a motion, a selection, or a save.
638 /// A frontend can hold work against it — see [`Doc::revision`].
639 revision: u64,
640 /// How many history steps stand behind the caret, and how many ahead of
641 /// it — the answer to a native Edit menu's "may Undo be enabled?", which
642 /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
643 /// the funnel every edit comes through, and moved back and forth by
644 /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
645 /// an exact depth: a coalesced run of typing is one of twig's steps but
646 /// several of these, and twig's own cap on history is not mirrored here.
647 /// Neither error can make `can_undo` false while a step remains, which is
648 /// the only property a menu needs; the one place the bound can be wrong the
649 /// other way — the cap has retired every step — is reconciled the moment
650 /// twig reports nothing to undo.
651 undo_steps: usize,
652 redo_steps: usize,
653 /// What `vmap` was built from, or `None` before the first build. The map is
654 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
655 /// moved, rebuilding it produces the identical map — see
656 /// [`Doc::build_visual`].
657 ///
658 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
659 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
660 /// text and width alone, and a caret motion still rebuilds nothing.
661 vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
662 /// Which `Doc` this is, distinct from every other one built in this
663 /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
664 /// can never be mistaken for another document's — see
665 /// [`Doc::visual_key`]. Nothing else reads it.
666 identity: u64,
667 /// Per-block row cache backing the incremental rebuild: when the text
668 /// changes, only the top-level blocks whose bytes moved are re-rendered and
669 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
670 /// builds; a pure accelerator, so it's never read for correctness.
671 block_cache: wysiwyg::BlockCache,
672 /// How many visual rows each block image reserves, keyed by its destination —
673 /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
674 /// measured the pictures. Core does no image I/O, so this is the only way it
675 /// learns a picture's height; a destination not in the map reserves the bare
676 /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
677 /// sizes each placeholder, and folded into `vmap_key` so a height change
678 /// rebuilds the map.
679 media_rows: HashMap<String, usize>,
680
681 // View geometry the renderer stamps each frame, so mouse events can map a
682 // screen cell back to a byte offset.
683 pub scroll: usize,
684 pub body_origin: (u16, u16),
685 /// Width of the body rectangle last painted by the frontend. Zero means
686 /// unknown (used by tests or a frontend that has not drawn yet).
687 pub body_width: u16,
688 pub body_height: u16,
689 /// The caret as of the last frame drawn, or `None` before the first.
690 ///
691 /// Scrolling is the viewport's business, not the caret's: the view follows
692 /// the caret when the caret *moves*, but a wheel that doesn't touch the
693 /// caret has to be free to scroll away from it — otherwise the view is
694 /// pinned to the caret and stops dead at the edge of the document you can
695 /// see. Comparing against this is what tells the two apart, and it catches a
696 /// caret set by any route, including a frontend assigning the field itself.
697 pub drawn_caret: Option<usize>,
698}
699
700/// The Markdown extensions every leaf document is parsed with — four of them,
701/// each departing from twig's defaults for a reason leaf can state.
702///
703/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
704/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
705/// node the frontends can frame and rasterize instead of opaque `raw_block`
706/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
707/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
708/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
709/// plain tinted container, agnostic of `name`.
710///
711/// `highlight` and `highlight_colors` are the pair that makes Markdown read
712/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
713/// `data-color`. leaf already had somewhere to put both: the
714/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
715/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
716/// from a Djot one it was converted from — the button wrote `==…==` and the
717/// reparse read it straight back as text.
718/// They are on together because a colour is inert without the highlight itself,
719/// and a document that writes `==🔴 x==` means the colour by it.
720///
721/// Every flag is inert for non-Markdown formats, so it's safe to pass them
722/// unconditionally. Threading this through every constructor (not just `open`)
723/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
724/// way — twig reparses with these same flags after each edit.
725pub(crate) fn parse_extensions() -> MarkdownExtensions {
726 MarkdownExtensions {
727 html_elements: true,
728 directives: true,
729 highlight: true,
730 highlight_colors: true,
731 ..Default::default()
732 }
733}
734
735/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
736/// mapping twig's error into the `anyhow` context every constructor shares.
737fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
738 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
739}
740
741/// Does `format` spell a table as a **pipe table** — the one grid twig's table
742/// editor knows how to emit?
743///
744/// This is the single capability leaf still has to answer for itself, and the
745/// only hand-maintained format list left in this file. Every other gesture is
746/// [`Format::supports`], which is twig's own answer read across the C ABI — but
747/// twig deliberately leaves the table ops out of that query, because they read
748/// no `Syntax` table at all. They rewrite a grid that is already in the source
749/// and refuse on *position*, never on format. Handed a caret inside an HTML
750/// `<table>`, `table_insert_row` therefore re-emits the whole element as
751/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
752/// `dirty` flag, and nothing downstream able to tell it from a good edit.
753///
754/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
755/// wildcard answers "no" for a format leaf has never heard of: a new twig
756/// language that *does* spell pipe tables loses its grid controls until this
757/// line is updated, which shows up as a missing button. The other default hands
758/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
759fn spells_pipe_tables(format: Format) -> bool {
760 matches!(format, Format::Markdown | Format::Djot)
761}
762
763/// Which of leaf's authoring controls this document's format can actually
764/// spell — one flag per toolbar button, resolved once so a frontend can build
765/// its chrome instead of discovering each refusal on a click.
766///
767/// Every field but [`table`](Self::table) is `Format::supports_with` on the
768/// gesture the matching [`Doc`] method calls, so this record cannot drift from
769/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
770/// doesn't export.
771///
772/// `supports_with` rather than `supports` because two of these are facts about
773/// the *parse options* as much as about the format. `Format::supports` answers
774/// for twig's defaults, and leaf never parses with those — it parses with
775/// [`parse_extensions`], and a document's toolbar has to describe the document
776/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
777/// without it would mint bytes its own reparse hands back as plain text, which
778/// is why twig asks before it writes.
779///
780/// **The formats are ragged, and that is the point.** A single per-document
781/// boolean was enough while the two authorable formats were Markdown and djot
782/// and everything else spelled nothing. HTML is neither: it writes seven of the
783/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
784/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
785/// pair; it spells no line prefix, no fence, no task box, no link — because
786/// its versions of those have a different *shape*, not a different alphabet.
787/// So ⌘B and ⌘1 work in an HTML document and the quote button does not, and
788/// no one flag can say that. Markdown and djot differ from each other too:
789/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
790#[derive(Clone, Copy, Debug, Eq, PartialEq)]
791pub struct Capabilities {
792 /// ⌘B — `InlineKind::Strong`.
793 pub bold: bool,
794 /// ⌘I — `InlineKind::Emph`.
795 pub italic: bool,
796 /// Inline code — `InlineKind::Verbatim`.
797 pub code: bool,
798 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
799 /// under the `highlight` extension [`parse_extensions`] turns on: the
800 /// button writes `==text==`, which is what the reparse reads back.
801 pub mark: bool,
802 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
803 pub underline: bool,
804 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
805 /// out of the box, since twig parses it out of the box.
806 pub strike: bool,
807 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
808 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
809 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
810 /// so a toolbar offering the swatches wherever the button lights would offer
811 /// them in a document that cannot write one. Pair with
812 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
813 /// a highlight to colour as much as a format that spells one.
814 pub mark_color: bool,
815 pub superscript: bool,
816 pub subscript: bool,
817 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
818 pub heading: bool,
819 pub blockquote: bool,
820 pub bullet_list: bool,
821 pub ordered_list: bool,
822 /// The checkbox controls: giving an item a box, and ticking one.
823 pub task: bool,
824 pub link: bool,
825 /// Covers [`Doc::insert_media`] too — see the note there on why the three
826 /// media kinds stand or fall together.
827 pub image: bool,
828 /// The horizontal-rule button. HTML spells this one (`<hr>`).
829 pub thematic_break: bool,
830 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
831 /// the pair; HTML has no footnote of its own, so the button goes away rather
832 /// than writing brackets that would render as brackets.
833 pub footnote: bool,
834 /// Setting a fenced block's language — a control only ever offered with the
835 /// caret already in a fence.
836 pub code_language: bool,
837 /// The grid controls: insert/delete/move a row or column, set a column's
838 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
839 /// question — an HTML `<table>` holds the caret and still can't be edited.
840 pub table: bool,
841 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
842 /// idiomatic in-cell break.
843 pub cell_line_break: bool,
844}
845
846impl Capabilities {
847 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
848 /// twig computes each from a static table — but a frontend that wants to
849 /// hold them can.
850 ///
851 /// The extensions are not a parameter because they are not a choice a
852 /// caller makes: every leaf document is parsed with [`parse_extensions`],
853 /// so the format is the whole of what varies.
854 pub fn of(format: Format) -> Self {
855 let exts = parse_extensions();
856 let supports = |g| format.supports_with(exts, g);
857 let inline = |k| supports(Gesture::ToggleInline(k));
858 let container = |k| supports(Gesture::ToggleBlockContainer(k));
859 Self {
860 bold: inline(InlineKind::Strong),
861 italic: inline(InlineKind::Emph),
862 code: inline(InlineKind::Verbatim),
863 mark: inline(InlineKind::Mark),
864 underline: inline(InlineKind::Insert),
865 strike: inline(InlineKind::Delete),
866 mark_color: supports(Gesture::SetMarkColor),
867 superscript: inline(InlineKind::Superscript),
868 subscript: inline(InlineKind::Subscript),
869 heading: supports(Gesture::SetBlock),
870 blockquote: container(BlockContainerKind::BlockQuote),
871 bullet_list: container(BlockContainerKind::BulletList),
872 ordered_list: container(BlockContainerKind::OrderedList),
873 // Both halves of the checkbox story, and leaf offers no control that
874 // needs only one: the item gesture mints the box, the checked one
875 // ticks it, and a format spelling a `task_marker` spells both.
876 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
877 link: supports(Gesture::InsertLink),
878 image: supports(Gesture::InsertImage),
879 thematic_break: supports(Gesture::InsertThematicBreak),
880 footnote: supports(Gesture::InsertFootnote),
881 code_language: supports(Gesture::SetCodeLanguage),
882 table: spells_pipe_tables(format),
883 cell_line_break: supports(Gesture::InsertLineBreak),
884 }
885 }
886}
887
888/// The source of [`Doc::identity`], one per document ever built.
889static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
890
891impl Doc {
892 #[cfg(feature = "fs")]
893 pub fn open(path: PathBuf) -> Result<Self> {
894 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
895 Self::from_disk_bytes(path, bytes)
896 }
897
898 /// An empty document *named* `path`, for a file that isn't there yet — what
899 /// every other terminal editor gives you when you name a file that doesn't
900 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
901 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
902 /// the header shows the name the user asked for.
903 ///
904 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
905 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
906 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
907 /// parse is still an error: a mistyped flag or a stray argument should say
908 /// so, not open a buffer promising to save somewhere.
909 ///
910 /// The watermark is the hash of *no bytes*, not `None`, and that is the
911 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
912 /// answering [`DiskState::Untitled`] for a document that has a path and
913 /// intends to write to it. Hashing `""` instead makes the answers the true
914 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
915 /// recreates it, which is exactly what this is for), and
916 /// [`DiskState::Changed`] if somebody creates it underneath us between
917 /// launch and save, so the frontend's overwrite prompt guards a new file as
918 /// it guards an opened one.
919 ///
920 /// Nothing is written here. A buffer that is never typed into never touches
921 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
922 /// open — the write is where that fails, and it says so then.
923 #[cfg(feature = "fs")]
924 pub fn create(path: PathBuf) -> Result<Self> {
925 Self::from_disk_bytes(path, Vec::new())
926 }
927
928 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
929 /// the call a CLI frontend wants for its path argument.
930 ///
931 /// The decision is made from the failed read itself rather than a `exists()`
932 /// check first, so there is no window between the two for the file to appear
933 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
934 /// a directory in the way is still an error, because pretending those are
935 /// "no file yet" would offer to save over something leaf couldn't read.
936 #[cfg(feature = "fs")]
937 pub fn open_or_create(path: PathBuf) -> Result<Self> {
938 match std::fs::read(&path) {
939 Ok(bytes) => Self::from_disk_bytes(path, bytes),
940 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
941 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
942 }
943 }
944
945 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
946 /// stand in for) the file at `path`, parsed as the format its extension
947 /// names. Keeping the two on one path is what makes a new file's document
948 /// identical in every respect to an opened one but its contents.
949 #[cfg(feature = "fs")]
950 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
951 let format = detect_format(&path)?;
952 let editor = new_editor(&bytes, format)?;
953 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
954 let disk_hash = Some(hash_bytes(source.as_bytes()));
955 // Store the document's *absolute* path. A relative one (`leaf README.md`)
956 // has an empty parent, so a frontend can't resolve a relative image
957 // destination (``) against the document's directory and the
958 // picture silently falls back to its text placeholder. `absolute` is
959 // purely lexical — it prefixes the current directory and normalizes, but
960 // reads nothing and resolves no symlinks — so `file_name` and save are
961 // unchanged; it only gives `path.parent()` something to join against.
962 let path = std::path::absolute(&path).unwrap_or(path);
963 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
964 }
965
966 /// Build a document from an in-memory string, the format named explicitly —
967 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
968 /// path and sniffs the format from its extension). A wasm or FFI host, which
969 /// has no path to read, uses this: it hands over bytes it fetched however it
970 /// could, and later persists [`Doc::source`] however it can (a browser
971 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
972 ///
973 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
974 /// true) exactly like a [`Doc::blank`] that has been given content.
975 pub fn from_source(source: String, format: Format) -> Result<Self> {
976 let editor = new_editor(source.as_bytes(), format)?;
977 Ok(Doc::from_parts(
978 editor,
979 format,
980 PathBuf::new(),
981 source,
982 None,
983 ))
984 }
985
986 /// An untitled, empty document — the `+` button and a `leaf` launched with
987 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
988 ///
989 /// It is Markdown, because a format has to be chosen before a name exists to
990 /// read one from: `detect_format` reads the extension and an untitled
991 /// document has neither. Markdown is what leaf's own files are, what its
992 /// block markers are already written for (`insert_block_prefix`), and the
993 /// extension a Save As will overwhelmingly pick — a wrong guess here would
994 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
995 /// *doesn't* revisit this: see [`Doc::save_as`].
996 pub fn blank() -> Result<Self> {
997 let format = Format::Markdown;
998 let editor = new_editor(b"", format)?;
999 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1000 // field two frontends already read; making it an `Option` to say this
1001 // would break both). `is_untitled` is the question to ask, not the
1002 // representation to copy.
1003 Ok(Doc::from_parts(
1004 editor,
1005 format,
1006 PathBuf::new(),
1007 String::new(),
1008 None,
1009 ))
1010 }
1011
1012 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1013 /// apart in the ones neither of them has an opinion about.
1014 // `identity` is taken from a counter rather than from the `Doc`'s address,
1015 // which moves — a session that holds one is moved into and out of
1016 // containers freely, and an identity that changed with it would defeat the
1017 // one comparison it exists for.
1018 fn from_parts(
1019 editor: Editor,
1020 format: Format,
1021 path: PathBuf,
1022 source: String,
1023 disk_hash: Option<u64>,
1024 ) -> Self {
1025 Doc {
1026 editor,
1027 format,
1028 path,
1029 disk_hash,
1030 clean_source: source.clone(),
1031 source,
1032 caret: 0,
1033 anchor: None,
1034 dirty: false,
1035 status: None,
1036 read_only: false,
1037 highlights: Vec::new(),
1038 // leaf opens in the rich-text (WYSIWYG) view by default — the
1039 // markup-resolved surface is leaf's differentiator. Frontends can
1040 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1041 // toggles at runtime.
1042 view: View::Wysiwyg,
1043 // `None` by default — the clean surface Diaryx ships, with typed
1044 // syntax kept literal; a markup-fluent frontend can climb the
1045 // ladder to `Shortcuts` or `Full`.
1046 markup_mode: MarkupMode::default(),
1047 // Fold by default — flowing prose that reflows to the viewport, the
1048 // behaviour every frontend had before this preference existed.
1049 line_flow: LineFlow::default(),
1050 last_edit_kind: None,
1051 pending_marks: InlineMarks::empty(),
1052 pending_at: None,
1053 goal_col: None,
1054 vmap: VisualMap::default(),
1055 smap: SourceMap::default(),
1056 // No map yet — the first `build_source` always builds.
1057 smap_key: None,
1058 revision: 0,
1059 undo_steps: 0,
1060 redo_steps: 0,
1061 // No map yet — the first `build_visual` always builds.
1062 vmap_key: None,
1063 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1064 block_cache: wysiwyg::BlockCache::default(),
1065 media_rows: HashMap::new(),
1066 scroll: 0,
1067 body_origin: (0, 0),
1068 body_width: 0,
1069 body_height: 0,
1070 drawn_caret: None,
1071 }
1072 }
1073
1074 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1075 /// has never been saved. The question a ⌘S handler asks to know it should
1076 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1077 /// header asks to know the name it shows is a placeholder.
1078 pub fn is_untitled(&self) -> bool {
1079 self.path.as_os_str().is_empty()
1080 }
1081
1082 pub fn toggle_view(&mut self) {
1083 self.view = match self.view {
1084 View::Source => View::Wysiwyg,
1085 View::Wysiwyg => View::Source,
1086 };
1087 self.scroll = 0;
1088 self.status = None;
1089 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1090 // lift it to the first rendered offset.
1091 self.clamp_caret();
1092 }
1093
1094 /// The current markup-exposure preference (see [`MarkupMode`]).
1095 pub fn markup_mode(&self) -> MarkupMode {
1096 self.markup_mode
1097 }
1098
1099 /// Set the markup-exposure preference. Both of its axes take effect at
1100 /// once: the editing one on the next [`insert`](Self::insert), and the
1101 /// rendering one on the next build — which is why this drops the cached
1102 /// visual map and the per-block render cache, exactly as
1103 /// [`set_line_flow`](Self::set_line_flow) does.
1104 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1105 if self.markup_mode == mode {
1106 return;
1107 }
1108 self.markup_mode = mode;
1109 // Neither cache is keyed on the mode, and moving between `Full` and the
1110 // hidden modes changes every row the caret's line renders to — so
1111 // invalidate both explicitly.
1112 self.vmap_key = None;
1113 self.block_cache = wysiwyg::BlockCache::default();
1114 }
1115
1116 /// The source byte range of the line the caret sits on, when that line
1117 /// should render its raw delimiters — `None` in every mode and view that
1118 /// hides them, which is what the builder reads as "reveal nothing".
1119 ///
1120 /// A *source* line (newline to newline), not a visual row: a wrapped
1121 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1122 /// line across several rows, and revealing half a delimiter pair because the
1123 /// other half wrapped would be worse than revealing neither. The range
1124 /// excludes the terminating newline and is empty-but-present on a blank
1125 /// line, which reveals nothing but still keys the caches correctly.
1126 ///
1127 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1128 /// there is nothing there to reveal.
1129 pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1130 if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1131 return None;
1132 }
1133 Some(source_line_range(&self.source, self.caret))
1134 }
1135
1136 /// The current soft-break flow preference (see [`LineFlow`]).
1137 pub fn line_flow(&self) -> LineFlow {
1138 self.line_flow
1139 }
1140
1141 /// Set the soft-break flow preference. The mode changes how every block lays
1142 /// out, so a change drops the cached visual map and the per-block render
1143 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1144 ///
1145 /// [`build_visual`]: Self::build_visual
1146 pub fn set_line_flow(&mut self, mode: LineFlow) {
1147 if self.line_flow == mode {
1148 return;
1149 }
1150 self.line_flow = mode;
1151 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1152 // so invalidate them explicitly, or the next build would reuse rows laid
1153 // out under the old flow.
1154 self.vmap_key = None;
1155 self.block_cache = wysiwyg::BlockCache::default();
1156 }
1157
1158 pub fn view_name(&self) -> &'static str {
1159 match self.view {
1160 View::Source => "source",
1161 View::Wysiwyg => "wysiwyg",
1162 }
1163 }
1164
1165 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1166 /// (called by the renderer each frame it's in the WYSIWYG view).
1167 /// Build the WYSIWYG map, wrapped at `width` display columns.
1168 ///
1169 /// Cheap to call every frame, which is what both frontends do: the map is a
1170 /// pure function of the document and the wrap width, so a call that would
1171 /// rebuild the same map returns the one already built. Only an edit (or a
1172 /// resize) pays.
1173 ///
1174 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1175 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1176 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1177 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1178 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1179 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1180 pub fn build_visual(&mut self, width: usize) {
1181 self.build_map(Some(width));
1182 }
1183
1184 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1185 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1186 /// than a fixed character column.
1187 pub fn build_visual_unwrapped(&mut self) {
1188 self.build_map(None);
1189 }
1190
1191 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1192 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1193 /// styling for [`View::Wysiwyg`].
1194 ///
1195 /// A frontend calls this before painting raw source. One that doesn't gets
1196 /// an empty map and paints unstyled text, so this is additive: nothing
1197 /// breaks by not calling it.
1198 ///
1199 /// Built at most once per revision, and the revision is the whole key — the
1200 /// map has no width and no caret in it, so it survives every resize, every
1201 /// motion, and every selection change.
1202 ///
1203 /// The builds it does do cost a whole-arena marshal, which is precisely what
1204 /// the WYSIWYG path works to avoid, so this has no incremental path where
1205 /// that one has two. From `cargo run --release -p leaf-core --example
1206 /// bench`, per keystroke, against the WYSIWYG build the source view is
1207 /// *not* doing:
1208 ///
1209 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1210 /// |------:|-------:|--------:|----------------:|-------------------:|
1211 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1212 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1213 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1214 ///
1215 /// Linear, two thirds of it the marshal, and the build itself five to seven
1216 /// times cheaper than the one it stands in for at every size. Comfortable
1217 /// well past any document a person edits in a terminal — a megabyte is where
1218 /// it would want [`Editor::dirty_range`] and the same splice treatment
1219 /// `build_spliced` gives the other map. The door is open; nothing has needed
1220 /// it yet.
1221 pub fn build_source(&mut self) {
1222 if self.smap_key == Some(self.revision) {
1223 return;
1224 }
1225 let nodes = self.nodes();
1226 self.smap = source::build(&nodes, &self.source);
1227 self.smap_key = Some(self.revision);
1228 }
1229
1230 /// Tell the model how many visual rows each block image should reserve, keyed
1231 /// by the image's destination. A terminal frontend calls this once it has
1232 /// decoded and measured its pictures — core does no image I/O, so this is the
1233 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1234 /// placeholder out that tall (the label row plus blank filler rows the
1235 /// frontend paints the raster over). A destination left out of the map falls
1236 /// back to the bare one-row placeholder, which is also what a frontend that
1237 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1238 /// by never calling this.
1239 ///
1240 /// Cheap to call every frame with the same map: only a *change* invalidates
1241 /// the built map (and the block-row cache, since a height isn't part of a
1242 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1243 /// no-op, so a frontend can just hand over its current measurements each frame.
1244 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1245 if self.media_rows == rows {
1246 return;
1247 }
1248 self.media_rows = rows;
1249 // A height lives outside the block's source bytes, so the content-keyed
1250 // block cache would hand back the old-height rows on a hit. Drop it (and
1251 // the splice layout it carries) so the next build re-renders every block
1252 // at the new heights, and force that build by clearing the map key.
1253 self.block_cache = wysiwyg::BlockCache::default();
1254 self.vmap_key = None;
1255 }
1256
1257 /// The revision the document's text is at — bumped by every edit, undo,
1258 /// redo, and reload, and by nothing else. A frontend caches against this to
1259 /// tell a repaint that needs new work from one that doesn't.
1260 ///
1261 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1262 /// lands on the same text two revisions later. Work is only ever rebuilt
1263 /// needlessly, never wrongly reused.
1264 pub fn revision(&self) -> u64 {
1265 self.revision
1266 }
1267
1268 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1269 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1270 /// unbuilt map before the first one.
1271 ///
1272 /// This is *not* [`revision`](Self::revision). The revision says where the
1273 /// text is; this says where the map is, and the two part company the moment
1274 /// an edit lands, until something rebuilds. A frontend that keeps its own
1275 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1276 /// under an oversized heading — compares this against the value it held when
1277 /// it took the copy, and learns whether `vmap` is still the map it stashed
1278 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1279 /// map would paint a stale document; restoring nothing hands core's
1280 /// incremental rebuild a map it never built.
1281 ///
1282 /// "Somebody else" includes another document. The key names the `Doc`
1283 /// as well as the build, so a frontend that draws two documents through
1284 /// one stash — a host with several buffers, or one that opens the next
1285 /// document where the last one stood — never has the copy it took of one
1286 /// accepted by the other, however alike their builds are.
1287 pub fn visual_key(&self) -> VisualKey {
1288 VisualKey(self.identity, self.vmap_key.clone())
1289 }
1290
1291 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1292 /// runs on every call: the caret moves without the document changing, and
1293 /// keeping it on a legal stop is this function's job either way.
1294 fn build_map(&mut self, wrap: Option<usize>) {
1295 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1296 // as well as the text, so the line joins the key: moving within a line
1297 // still reuses the map, and crossing into another one rebuilds it. In
1298 // every other mode `reveal_line` is `None` and the key is what it was,
1299 // so caret motion goes on costing nothing.
1300 let reveal = self.reveal_line();
1301 let key = (self.revision, wrap, reveal.clone());
1302 if self.vmap_key.as_ref() != Some(&key) {
1303 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1304 // A subtree is pulled only for the block(s) that actually changed, so
1305 // the FFI marshal shrinks from O(document) to O(edited block).
1306 let top = self.top_blocks();
1307
1308 // Fast path: when twig reports a dirty byte range, try to patch the
1309 // previous map in place — a single-block edit moves the prefix,
1310 // shifts the suffix, and re-renders only one block. `build_spliced`
1311 // returns `None` (and we fall back to the always-correct full rebuild)
1312 // whenever the edit reshaped the block structure, hit a table, or
1313 // there's no previous map to patch.
1314 // Preserve soft breaks as written when the flow preference asks for
1315 // it — the builder renders each as its own visual row instead of
1316 // folding it into the reflowed paragraph.
1317 let preserve_soft = self.line_flow == LineFlow::Preserve;
1318 let spliced = match self.editor.dirty_range() {
1319 Some(dirty) => {
1320 let prev = std::mem::take(&mut self.vmap);
1321 let source = &self.source;
1322 let cache = &mut self.block_cache;
1323 let media_rows = &self.media_rows;
1324 let editor = &mut self.editor;
1325 wysiwyg::build_spliced(
1326 prev,
1327 source,
1328 wrap,
1329 preserve_soft,
1330 &top,
1331 dirty,
1332 media_rows,
1333 reveal.clone(),
1334 cache,
1335 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1336 )
1337 }
1338 None => None,
1339 };
1340 self.vmap = spliced.unwrap_or_else(|| {
1341 let source = &self.source;
1342 let cache = &mut self.block_cache;
1343 let media_rows = &self.media_rows;
1344 let editor = &mut self.editor;
1345 wysiwyg::build_cached(
1346 &top,
1347 source,
1348 wrap,
1349 preserve_soft,
1350 media_rows,
1351 reveal,
1352 cache,
1353 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1354 )
1355 });
1356 // Acknowledge the dirty range so the next edit's range starts fresh.
1357 self.editor.clear_dirty();
1358 self.vmap_key = Some(key);
1359 }
1360 self.clamp_caret();
1361 }
1362
1363 fn nodes(&mut self) -> Vec<FlatNode> {
1364 self.editor.nodes().unwrap_or_default()
1365 }
1366
1367 /// The document's top-level blocks for the incremental render. See
1368 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1369 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1370 wysiwyg::top_blocks(&mut self.editor)
1371 }
1372
1373 pub fn format_name(&self) -> &'static str {
1374 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1375 // required. It also covers `Asciidoc`, which twig parses but cannot
1376 // serialize — leaf never opens a document in it (see `Doc::open`).
1377 match self.format {
1378 Format::Djot => "djot",
1379 Format::Markdown => "markdown",
1380 Format::Xml => "xml",
1381 Format::Html => "html",
1382 _ => "unknown",
1383 }
1384 }
1385
1386 /// Whether this document's format offers *any* door in — `false` only for a
1387 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1388 /// a frontend may as well open the file read-only.
1389 ///
1390 /// This is a much weaker claim than the name suggests, and driving per-button
1391 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1392 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1393 /// while a heading, a quote, a list, a task box, a link and a code fence all
1394 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1395 /// [`supports`](Self::supports) — per control.
1396 pub fn authorable(&self) -> bool {
1397 self.format.is_authorable()
1398 }
1399
1400 /// Whether this document can spell `gesture`, which is twig's own answer
1401 /// rather than a copy of it: `Format::supports_with` reads the same
1402 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1403 /// the very [`parse_extensions`] this document's editor reparses with — so
1404 /// what the toolbar offers and what the splice will accept are one table.
1405 ///
1406 /// It is a fact about the *document*, not about the caret. `true` does not
1407 /// promise the gesture succeeds where it is standing — a link over a table
1408 /// border still fails — only that it will not fail with
1409 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1410 /// will work here".
1411 pub fn supports(&self, gesture: Gesture) -> bool {
1412 self.format.supports_with(parse_extensions(), gesture)
1413 }
1414
1415 /// Every control's enabled state in one read — what a toolbar builds itself
1416 /// from when a document opens or its format changes. See [`Capabilities`].
1417 pub fn capabilities(&self) -> Capabilities {
1418 Capabilities::of(self.format)
1419 }
1420
1421 /// Refuse a gesture this document's format cannot spell, saying so in the
1422 /// status line. `true` means the caller must return without calling twig.
1423 ///
1424 /// Most of these refusals duplicate one twig would make anyway, and they are
1425 /// made here regardless because a message naming the *document's* format
1426 /// reads better than one naming twig's internals. Two of them are not
1427 /// duplicates and are the reason this is a guard rather than an error
1428 /// translation:
1429 ///
1430 /// - The table family (see [`table_op`](Self::table_op)) consults no
1431 /// `Syntax` table, so twig does not refuse it at all.
1432 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1433 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1434 /// could not keep.
1435 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1436 self.refuse_unless(what, self.supports(gesture))
1437 }
1438
1439 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1440 /// answers itself — today only [`spells_pipe_tables`].
1441 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1442 if supported {
1443 return false;
1444 }
1445 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1446 true
1447 }
1448
1449 /// The name to show for this document. An untitled one has no file to name
1450 /// it, and both frontends put this straight on screen — an empty path
1451 /// renders as an empty header, so it says so instead.
1452 pub fn file_name(&self) -> String {
1453 if self.is_untitled() {
1454 return "untitled".into();
1455 }
1456 self.path
1457 .file_name()
1458 .map(|s| s.to_string_lossy().into_owned())
1459 .unwrap_or_else(|| self.path.display().to_string())
1460 }
1461
1462 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1463 /// caret and anchor coincide (an empty selection is no selection).
1464 pub fn selection(&self) -> Option<(usize, usize)> {
1465 self.anchor
1466 .map(|a| (a.min(self.caret), a.max(self.caret)))
1467 .filter(|(s, e)| s != e)
1468 }
1469
1470 /// The selected text, or `None` when there's no selection — the source
1471 /// slice a copy/cut hands to the system clipboard.
1472 pub fn selected_text(&self) -> Option<&str> {
1473 self.selection().map(|(s, e)| &self.source[s..e])
1474 }
1475
1476 /// The selection as a quote with a little of what surrounds it — the shape
1477 /// a host that cites, annotates, or searches for a passage wants, cut from
1478 /// the **source** rather than from anything rendered, so the quote is
1479 /// findable in the document again by plain string search.
1480 ///
1481 /// `context` is a count of characters (not bytes) on each side, clipped at
1482 /// the document's edges; the slices land on char boundaries by
1483 /// construction. `None` when nothing is selected.
1484 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1485 let (start, end) = self.selection()?;
1486 let mut before = start;
1487 for _ in 0..context {
1488 match self.source[..before].chars().next_back() {
1489 Some(c) => before -= c.len_utf8(),
1490 None => break,
1491 }
1492 }
1493 let mut after = end;
1494 for _ in 0..context {
1495 match self.source[after..].chars().next() {
1496 Some(c) => after += c.len_utf8(),
1497 None => break,
1498 }
1499 }
1500 Some(Quote {
1501 exact: self.source[start..end].to_string(),
1502 prefix: self.source[before..start].to_string(),
1503 suffix: self.source[end..after].to_string(),
1504 start,
1505 end,
1506 })
1507 }
1508
1509 /// Whether the document refuses to change — see the field.
1510 pub fn read_only(&self) -> bool {
1511 self.read_only
1512 }
1513
1514 /// Turn the read-only gate on or off. A frontend preference like
1515 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1516 /// itself changes, only what may be done to it from here on.
1517 pub fn set_read_only(&mut self, on: bool) {
1518 self.read_only = on;
1519 }
1520
1521 /// The host-painted ranges, sorted by start — see [`Highlight`].
1522 pub fn highlights(&self) -> &[Highlight] {
1523 &self.highlights
1524 }
1525
1526 /// Replace the host-painted ranges wholesale. The whole set each time,
1527 /// rather than add/remove verbs: the host owns the list (it derives it
1528 /// from its own state — annotations, search hits), and a replace can
1529 /// never leave the two disagreeing about what should be on screen.
1530 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1531 highlights.retain(|h| h.start < h.end);
1532 highlights.sort_by_key(|h| (h.start, h.end));
1533 self.highlights = highlights;
1534 }
1535
1536 /// The highlight covering source `offset`, if one does — first by start
1537 /// when several overlap, which makes overlapping washes resolvable rather
1538 /// than undefined. What a frontend asks when the reader activates a spot.
1539 ///
1540 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1541 /// asking the same question per glyph, against a slice they were handed
1542 /// rather than against a `Doc`, and one answer for both is what keeps a
1543 /// wash and an activation agreeing about which range a spot is in.
1544 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1545 Highlight::covering(&self.highlights, offset)
1546 }
1547
1548 /// The AST breadcrumb at the caret (root → deepest), e.g.
1549 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1550 pub fn breadcrumb(&mut self) -> String {
1551 match self.editor.ancestors_at(self.caret) {
1552 Ok(chain) => chain
1553 .iter()
1554 .map(|m| m.kind.as_str())
1555 .collect::<Vec<_>>()
1556 .join(" › "),
1557 Err(_) => String::new(),
1558 }
1559 }
1560
1561 // ── editing ──────────────────────────────────────────────────────────────
1562
1563 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1564 /// after it. The public form of the internal splice — a pixel frontend that
1565 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1566 /// edits through this, the same twig `edit_range` the caret ops use.
1567 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1568 self.splice(start, end, text, EditKind::Other);
1569 }
1570
1571 /// Insert typed `text` at the caret, replacing the selection if there is one.
1572 /// A single typed character coalesces with the run of typing before it; a
1573 /// newline or a multi-character insert is its own undo step.
1574 ///
1575 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1576 pub fn insert(&mut self, text: &str) {
1577 // The read-only gate, up front: the paths below reach twig by several
1578 // verbs, not all of them through the splice — see the field.
1579 if self.read_only {
1580 return;
1581 }
1582 // Typing against a block picture would dissolve it — see
1583 // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1584 // what the caret was standing beside stays a picture.
1585 self.open_paragraph_at_block_media(text);
1586 // Armed sticky marks (⌘b with no selection) turn the next typed text
1587 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1588 // the exception: it takes no mark of its own and keeps the delta armed
1589 // for the character behind it — see `insert_space_with_marks`.
1590 let pending = self.pending_here();
1591 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1592 if text.trim().is_empty() {
1593 self.insert_space_with_marks(self.caret, text, pending);
1594 } else {
1595 self.insert_with_marks(self.caret, text, pending);
1596 }
1597 return;
1598 }
1599 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1600 // anything that would open markup, so a Diaryx user never mints
1601 // formatting by keyboard (it comes from commands instead). The other two
1602 // rungs of the ladder author markup from what you type, which is the
1603 // whole difference between them and this one. Only in the rendered view
1604 // (source view is for typing raw markup) and only where the format has a
1605 // literal spelling at all: escaping is a backslash before a byte from the
1606 // format's own alphabet, and a format with no such alphabet (HTML escapes
1607 // with entities, XML spells nothing) would have `\&` written into it,
1608 // which is two literal characters and not an escape. Marks (⌘b) still
1609 // format — that path returned above; and leaf's own structural inserts go
1610 // through `insert_raw`, never here, so a list marker or quote gutter is
1611 // written as the markup it is.
1612 if !self.markup_mode.authors()
1613 && self.view == View::Wysiwyg
1614 && !text.is_empty()
1615 && self.supports(Gesture::InsertLiteral)
1616 {
1617 self.insert_literal_typed(text);
1618 return;
1619 }
1620 self.insert_raw(text);
1621 }
1622
1623 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1624 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1625 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1626 /// markup by design and must not be escaped.
1627 fn insert_raw(&mut self, text: &str) {
1628 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1629 self.splice(s, e, text, typed_edit_kind(text));
1630 }
1631
1632 /// Open a paragraph for text about to be inserted at one of a block media's
1633 /// two caret stops, and leave the caret standing in it.
1634 ///
1635 /// A block image is a paragraph whose entire content is the picture, and the
1636 /// caret's only homes on it are in front of it and just past it (see
1637 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1638 /// *that* paragraph — and a paragraph holding anything besides the image is
1639 /// no longer a block image but a line of text with an inline one in it. The
1640 /// frontend that was painting a photo there paints a text run instead; the
1641 /// picture is still in the file, and nothing said a word. Those two offsets
1642 /// are also exactly where a click on the picture lands, so the whole accident
1643 /// is one tap and one keystroke.
1644 ///
1645 /// So the break goes in first and the text lands in the new empty paragraph —
1646 /// what pressing Return before typing would have done, which is a habit no
1647 /// one should have to learn from losing a photo. A no-op everywhere else, and
1648 /// over a selection (which is replaced, not joined into).
1649 ///
1650 /// A picture inside a quote or a list leaves its container, because `\n\n`
1651 /// ends the block. The alternative is worse: the `\n> ` / next-item
1652 /// continuation [`newline`](Self::newline) writes stays in the same
1653 /// *paragraph*, which is the thing being prevented.
1654 ///
1655 /// Only in the rendered view. Source view is for typing raw markup, where
1656 /// putting a character against an image is exactly what it looks like.
1657 fn open_paragraph_at_block_media(&mut self, text: &str) {
1658 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1659 return;
1660 }
1661 if self.selection().is_some() {
1662 return;
1663 }
1664 // The map may be a revision behind (nothing has drawn since the last
1665 // edit), and this asks it about offsets — a stale answer would splice a
1666 // break into the wrong place. Free when it is already current, which it
1667 // is whenever a frontend drew a frame between keystrokes.
1668 self.rebuild_map();
1669 let at = self.caret;
1670 let Some((side, _)) = self.vmap.block_media_stop(at) else {
1671 return;
1672 };
1673 if !self.splice(at, at, "\n\n", EditKind::Other) {
1674 return;
1675 }
1676 // The break is part of the keystroke, not an edit of its own: leave the
1677 // run marked as typing so the character about to arrive folds into it and
1678 // one undo puts the document back the way it was found. (A paste, or a
1679 // multi-character insert, is `EditKind::Other` and stays its own step —
1680 // as it would have been anywhere else in the document.)
1681 self.last_edit_kind = Some(EditKind::Insert);
1682 if side == MediaStop::Before {
1683 // The break went in above the picture and the caret rode to the end
1684 // of it — which is still hard against the picture. Step back onto the
1685 // blank line it opened, so the text lands above rather than in front.
1686 self.caret = at;
1687 }
1688 }
1689
1690 /// A delete key pressed at one of a block picture's two caret stops, handled
1691 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1692 /// the key was consumed.
1693 ///
1694 /// The caret rests in front of a block image and just past it, never inside
1695 /// its markup — which the rendered view doesn't show. So the byte a delete
1696 /// key nominally takes there is one the writer cannot see, and taking it
1697 /// leaves the picture as broken markup rather than as anything anyone asked
1698 /// for: Backspace at the stop past `` removes the closing paren, and
1699 /// a photo becomes the literal text `
1702 /// prevents from the typing side, and it cost this repository's own test vault
1703 /// a photo before it was found.
1704 ///
1705 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1706 /// when it is behind the caret, Delete when it is in front — which is what
1707 /// every editor does with an embed, and one undo away. The key aimed *away*
1708 /// from it would otherwise delete the paragraph break and merge a neighbour
1709 /// into the picture's own paragraph, which dissolves it just as surely; it
1710 /// steps the caret over the boundary instead and leaves the
1711 /// next press to delete in the block it has reached — the same "first press
1712 /// steps out of the atom, second press deletes" every delete key here gets,
1713 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1714 /// above, and reaches it on the second press rather than taking the break and
1715 /// the picture with it on the first).
1716 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1717 // The map answers about offsets, so it has to be this revision's — see
1718 // the same call in `open_paragraph_at_block_media`.
1719 self.rebuild_map();
1720 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1721 return false;
1722 };
1723 let aimed_at_it = side
1724 == if forward {
1725 MediaStop::Before
1726 } else {
1727 MediaStop::After
1728 };
1729 if !aimed_at_it {
1730 let over = if forward {
1731 self.vmap.stop_after(self.caret)
1732 } else {
1733 self.vmap.stop_before(self.caret)
1734 };
1735 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1736 self.caret = off;
1737 self.anchor = None;
1738 self.goal_col = None;
1739 }
1740 return true;
1741 }
1742 // Take the break that held the picture apart from its neighbour with it,
1743 // so the delete doesn't leave a blank paragraph standing where the
1744 // picture was. The last arm is a picture that is the whole document.
1745 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1746 (span.start - 2, span.end)
1747 } else if self.source[span.end..].starts_with("\n\n") {
1748 (span.start, span.end + 2)
1749 } else {
1750 (span.start, span.end)
1751 };
1752 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1753 true
1754 }
1755
1756 /// The Hidden-mode typing path: replace any selection, then insert `text`
1757 /// escaped so it stays literal. When it replaces a selection the two edits
1758 /// fold into one undo step, so an overwrite undoes atomically (and restores
1759 /// the selection) exactly as a plain one does.
1760 fn insert_literal_typed(&mut self, text: &str) {
1761 let kind = typed_edit_kind(text);
1762 match self.selection() {
1763 Some((s, e)) => {
1764 if !self.splice(s, e, "", EditKind::Other) {
1765 return;
1766 }
1767 // Typing over a whole marked run takes its delimiters with it
1768 // (the empty content couldn't hold them — see
1769 // `repair_mark_edges`) and leaves its marks armed at the caret.
1770 // The text taking the run's place inherits them, exactly as it
1771 // would have by landing inside a run that survived.
1772 let pending = self.pending_here();
1773 if !pending.is_empty() && !text.trim().is_empty() {
1774 self.insert_with_marks(self.caret, text, pending);
1775 return;
1776 }
1777 self.insert_literal_at(self.caret, text, kind, true);
1778 }
1779 None => {
1780 self.insert_literal_at(self.caret, text, kind, false);
1781 }
1782 }
1783 }
1784
1785 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1786 /// at a collapsed caret, but only while the caret still stands where they
1787 /// were armed and nothing is selected. Empty otherwise, so a stale delta
1788 /// never styles text it wasn't meant for.
1789 fn pending_here(&self) -> InlineMarks {
1790 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1791 self.pending_marks
1792 } else {
1793 InlineMarks::empty()
1794 }
1795 }
1796
1797 /// Drop the armed sticky marks — any caret motion, selection, or edit does
1798 /// this, so "start bold here" only ever applies at the exact spot it was
1799 /// asked for.
1800 fn clear_pending(&mut self) {
1801 self.pending_marks = InlineMarks::empty();
1802 self.pending_at = None;
1803 }
1804
1805 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1806 /// force is wrapped around the freshly typed text; a mark the caret already
1807 /// stands inside is *shed* — the text is inserted past the run's end so it
1808 /// lands unmarked ("type normally again"). The caret comes to rest inside any
1809 /// added runs, so continued typing inherits the marks with no re-wrapping,
1810 /// and the delta is cleared: the marks now live in the document, not here.
1811 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1812 let base = self.mark_spans_at(at);
1813 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1814 // Nothing to shed, and a run of exactly these marks standing just behind
1815 // the caret: carry on writing *that* run rather than opening a second
1816 // one beside it.
1817 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1818 return;
1819 }
1820 // Shed the marks we're turning off: step the insertion point past the
1821 // end of each run the caret sits in, so the new text falls outside it.
1822 let mut ins_at = at;
1823 for (kind, span) in &base {
1824 if marks.contains(*kind) {
1825 ins_at = ins_at.max(span.end);
1826 }
1827 }
1828 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1829 return;
1830 }
1831 // The plain splice inserted exactly `text` at `ins_at`; that byte range
1832 // is the content every added mark wraps.
1833 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1834 for kind in marks.iter() {
1835 if !base_set.contains(kind) {
1836 let (ncs, nce) = self.wrap_span(cs, ce, kind);
1837 cs = ncs;
1838 ce = nce;
1839 }
1840 }
1841 self.caret = ce.min(self.source.len());
1842 self.anchor = None;
1843 self.last_edit_kind = None;
1844 // Realised: the marks are in the document now, and the caret sits inside
1845 // them, so there is no delta left to carry. Arm nothing, but remember the
1846 // spot so a *further* toggle before typing starts a clean delta here.
1847 self.pending_marks = InlineMarks::empty();
1848 self.pending_at = Some(self.caret);
1849 self.clamp_caret();
1850 self.record_caret();
1851 }
1852
1853 /// Carry on the marked run just behind `at` — moving its closing delimiters
1854 /// out past the new text — instead of opening a second run of the same marks
1855 /// beside it. Returns whether it did.
1856 ///
1857 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1858 /// A space typed after a bold word steps the caret out of the run, because
1859 /// `**bold **` is not bold; the next character has to step back *in*, or the
1860 /// writer who typed one bold phrase is left with `**bold** **and**` — two
1861 /// runs that read the same to a reader but spell the file in a way nobody
1862 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1863 /// words it isn't marking), and the marks behind it must be exactly the ones
1864 /// armed — a run of *some* other kind is a neighbour, not this phrase.
1865 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1866 if text.is_empty() || text.trim() != text {
1867 return false;
1868 }
1869 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1870 // Walk in through the delimiters stacked at that point, innermost last:
1871 // `***both*** ` closes two runs with one `***`, and rejoining means
1872 // getting behind all of them.
1873 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1874 while let Some((kind, content_end)) = self
1875 .editor
1876 .ancestors_at(prev_boundary(&self.source, cut))
1877 .unwrap_or_default()
1878 .into_iter()
1879 .filter(|m| m.span.end == cut)
1880 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1881 {
1882 if content_end >= cut {
1883 break; // a mark with no closing delimiter to step behind
1884 }
1885 kinds.insert(kind);
1886 cut = content_end;
1887 }
1888 if cut == gap_at || kinds != marks {
1889 return false;
1890 }
1891 // Re-spell the tail: the gap, then the new text, then the delimiters that
1892 // used to close in front of them — read out of the document rather than
1893 // written from a table, so whatever twig spells them with is what moves.
1894 let tail = format!(
1895 "{}{text}{}",
1896 &self.source[gap_at..at],
1897 &self.source[cut..gap_at]
1898 );
1899 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1900 return false;
1901 }
1902 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1903 self.anchor = None;
1904 self.last_edit_kind = None;
1905 self.pending_marks = InlineMarks::empty();
1906 self.pending_at = Some(self.caret);
1907 self.clamp_caret();
1908 self.record_caret();
1909 true
1910 }
1911
1912 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1913 /// never itself wrapped: a mark around a space draws nothing a reader can
1914 /// see, and in Markdown and Djot it draws its own delimiters instead
1915 /// (`** **`). So the space goes in unmarked — outside any run the armed
1916 /// marks are shedding — and the marks stay armed for the character after it,
1917 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1918 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1919 let base = self.mark_spans_at(at);
1920 // What the *next* character carries: the armed delta resolved against the
1921 // marks in force here, which the space must not quietly drop.
1922 let want = base
1923 .iter()
1924 .map(|(k, _)| *k)
1925 .collect::<InlineMarks>()
1926 .xor(marks);
1927 let mut ins_at = at;
1928 for (kind, span) in &base {
1929 if marks.contains(*kind) {
1930 ins_at = ins_at.max(span.end);
1931 }
1932 }
1933 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1934 return;
1935 }
1936 self.rearm(want);
1937 self.record_caret();
1938 }
1939
1940 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1941 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1942 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1943 /// added split evenly around the content — half the growth on each side.
1944 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1945 // The read-only gate — this door reaches twig without the splice.
1946 if self.read_only {
1947 return (s, e);
1948 }
1949 match self.editor.toggle_inline(s, e, kind) {
1950 Ok(change) => {
1951 self.last_edit_kind = None;
1952 self.refresh();
1953 self.dirty = self.source != self.clean_source;
1954 let added = (change.new.end - change.new.start).saturating_sub(e - s);
1955 let half = added / 2;
1956 (change.new.start + half, change.new.end - half)
1957 }
1958 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1959 // rather than lose the keystroke.
1960 Err(e2) => {
1961 self.status = Some(format!("{kind:?}: {e2}"));
1962 (s, e)
1963 }
1964 }
1965 }
1966
1967 /// The safe offset to splice a block-level break at, given a caret that may
1968 /// sit exactly between an inline mark's content and its own closing
1969 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1970 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1971 /// with nothing following it on the line: the closing `**` renders no
1972 /// glyph of its own, so the caret's "end of line" offset lands right
1973 /// before it). Splicing a paragraph/list/quote break at `off` itself would
1974 /// sever the delimiter from its content, stranding it alone on the new
1975 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1976 /// nested marks closing at the same point (`**_x_**`) all clear together.
1977 /// A no-op everywhere else — mid-run, or past real trailing content, no
1978 /// mark's `content_span` ends exactly at `off`.
1979 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1980 let off = off.min(self.source.len());
1981 self.editor
1982 .ancestors_at(off)
1983 .unwrap_or_default()
1984 .into_iter()
1985 .filter(|m| inline_kind(&m.kind).is_some())
1986 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1987 .map(|m| m.span.end)
1988 .max()
1989 .unwrap_or(off)
1990 }
1991
1992 /// The offset a *delete* aimed at the character before `off` should stop at,
1993 /// when `off` is the start of a run's text and the bytes behind it are that
1994 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1995 /// byte behind the caret at the start of a bold word is not a character the
1996 /// writer can see, let alone one they aimed Backspace at: taking it leaves
1997 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1998 /// delete steps over the whole delimiter to the visible character in front of
1999 /// it instead. Walks out to the *outermost* mark opening there, so
2000 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2001 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2002 let off = off.min(self.source.len());
2003 self.editor
2004 .ancestors_at(off)
2005 .unwrap_or_default()
2006 .into_iter()
2007 .filter(|m| inline_kind(&m.kind).is_some())
2008 .filter(|m| {
2009 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2010 })
2011 .map(|m| m.span.start)
2012 .min()
2013 .unwrap_or(off)
2014 }
2015
2016 /// `off` moved *inside* the run whose closing delimiters end there — the
2017 /// other offset the rich view draws in the same place, since a `**` renders
2018 /// no glyph of its own. `**bold**` has a caret home on each side of its
2019 /// closing delimiter, one column apart on screen and eight bytes and a whole
2020 /// run apart in the file, and a plain ← lands on the outer one whenever a
2021 /// space follows the phrase. The inner one is what the writer is pointing at
2022 /// there: the end of their bold word. Walks in through every mark closing at
2023 /// that point, innermost last, so `***both***` lands inside both. A no-op
2024 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2025 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2026 let mut off = off.min(self.source.len());
2027 loop {
2028 let inner = self
2029 .editor
2030 .ancestors_at(prev_boundary(&self.source, off))
2031 .unwrap_or_default()
2032 .into_iter()
2033 .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
2034 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2035 .filter(|&end| end < off)
2036 .max();
2037 match inner {
2038 Some(end) => off = end,
2039 None => return off,
2040 }
2041 }
2042 }
2043
2044 /// The mirror at the opening edge: `off` moved inside the run whose
2045 /// delimiters *start* there, onto the first character of its text. See
2046 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2047 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2048 let mut off = off.min(self.source.len());
2049 loop {
2050 let inner = self
2051 .editor
2052 .ancestors_at(off)
2053 .unwrap_or_default()
2054 .into_iter()
2055 .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
2056 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2057 .filter(|&start| start > off)
2058 .min();
2059 match inner {
2060 Some(start) => off = start,
2061 None => return off,
2062 }
2063 }
2064 }
2065
2066 /// The inline mark kinds whose span covers `off`, each with that span — the
2067 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2068 /// ids instead. Used to shed a mark by stepping past the end of its run.
2069 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2070 let off = off.min(self.source.len());
2071 self.editor
2072 .ancestors_at(off)
2073 .unwrap_or_default()
2074 .into_iter()
2075 .filter(|m| off < m.span.end)
2076 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2077 .collect()
2078 }
2079
2080 /// Insert clipboard `text` at the caret, replacing the selection if there is
2081 /// one — always its own undo step, whatever its length.
2082 ///
2083 /// Provenance is the whole point, and only the caller has it. `insert` reads
2084 /// a lone character as a keystroke and folds it into the run around it,
2085 /// which is right for typing and wrong for a one-character paste: that paste
2086 /// would vanish mid-run on an undo it was never part of, and the characters
2087 /// the user actually typed would go with it. Length can't tell the two
2088 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2089 /// caller comes through is what says which happened.
2090 pub fn paste(&mut self, text: &str) {
2091 // Pasting against a block picture dissolves it exactly as typing does,
2092 // and for the same reason — see `open_paragraph_at_block_media`.
2093 self.open_paragraph_at_block_media(text);
2094 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2095 self.splice(s, e, text, EditKind::Other);
2096 }
2097
2098 /// Replace `[start, end)` with `text` as one step of an IME composition —
2099 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2100 /// folds into a single undo.
2101 ///
2102 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2103 /// dozen calls here, each replacing the last one's provisional bytes, and an
2104 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2105 /// unspools backwards through kana — the intermediate states were never text
2106 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2107 /// look like any other edit), so the door the caller comes through is what
2108 /// says so, exactly as it is for [`paste`](Self::paste) versus
2109 /// [`insert`](Self::insert).
2110 ///
2111 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2112 /// composition folds into this one.
2113 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2114 self.splice(start, end, text, EditKind::Compose);
2115 }
2116
2117 /// Close the open composition run, so the next one is its own undo step.
2118 /// Call when the IME commits or withdraws a composition.
2119 ///
2120 /// Only clears a *composition* run: a frontend that reports an end it never
2121 /// began (some IMEs unmark unprompted) would otherwise split the run of
2122 /// typing around it into two undo steps for no reason the user can see.
2123 pub fn end_composition(&mut self) {
2124 if self.last_edit_kind == Some(EditKind::Compose) {
2125 self.last_edit_kind = None;
2126 }
2127 }
2128
2129 // ── the clipboard's rich flavor ──────────────────────────────────────────
2130
2131 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2132 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2133 /// nothing is selected, or when the selection doesn't render (the caller
2134 /// still has [`selected_text`](Self::selected_text), which is what to publish
2135 /// as `text/plain` either way).
2136 ///
2137 /// **The fragment is a source substring, and that is the honest limit here.**
2138 /// It's parsed standalone, so a selection whose meaning depends on its
2139 /// surroundings converts as what it literally says rather than what it looks
2140 /// like on screen: half a list item is a paragraph, a row torn out of a table
2141 /// is the text of a row, the `**` of a bold run selected without its closing
2142 /// `**` is two asterisks. Every one of those still *renders* — there's no
2143 /// error to report — it just renders as the fragment and not as the document.
2144 /// Widening the range to whole blocks would publish text the user didn't
2145 /// select, which is a worse lie than a fragment being a fragment; the plain
2146 /// flavor has the same substring, so the two flavors at least agree.
2147 pub fn selection_html(&mut self) -> Option<String> {
2148 let (start, end) = self.selection()?;
2149 let inline = self.selection_is_inline(start, end);
2150 let html = html::render_fragment(&self.source[start..end], self.format)?;
2151 Some(match inline {
2152 true => html::strip_sole_paragraph(html),
2153 false => html,
2154 })
2155 }
2156
2157 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2158 /// format first. Its own undo step, like any [`paste`](Self::paste).
2159 ///
2160 /// Returns whether it landed. `false` means the HTML didn't convert to
2161 /// anything worth pasting — the caller should fall back to the plain flavor
2162 /// rather than treat it as an error. The `html` module has the full list of
2163 /// what that covers: a table twig won't build, markup it doesn't recognise,
2164 /// an empty result.
2165 pub fn paste_html(&mut self, html: &str) -> bool {
2166 match html::parse_fragment(html, self.format) {
2167 Some(source) => {
2168 self.paste(&source);
2169 true
2170 }
2171 None => false,
2172 }
2173 }
2174
2175 /// Does the selection live *inside* a single top-level block?
2176 ///
2177 /// The question [`selection_html`](Self::selection_html) needs and the
2178 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2179 /// whether the user selected one word of a sentence or a whole paragraph, and
2180 /// only the document knows which. Selecting a word and pasting into Docs
2181 /// should extend the line you paste into; selecting the paragraph should make
2182 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2183 /// is an artifact of standalone parsing), and one that covers a whole block —
2184 /// or spans two — keeps its structure.
2185 ///
2186 /// Reads the block from twig rather than guessing from the bytes:
2187 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2188 /// block containing an offset, and two ends inside the same one cannot have
2189 /// crossed a block boundary.
2190 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2191 // The last *character*, not `end - 1`: the selection's end is exclusive
2192 // and may sit mid-codepoint's-worth of bytes past the last char.
2193 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2194 return false;
2195 };
2196 let (Some(head), Some(tail)) =
2197 (self.top_block_span(start), self.top_block_span(start + off))
2198 else {
2199 return false;
2200 };
2201 head == tail && !(start <= head.start && end >= head.end)
2202 }
2203
2204 /// The byte span of the top-level block containing `offset`, or `None` at an
2205 /// offset that belongs to no block (the blank line between two of them).
2206 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2207 self.editor
2208 .ancestors_at(offset)
2209 .ok()?
2210 .get(1)
2211 .map(|m| m.span.clone())
2212 }
2213
2214 // ── indentation ──────────────────────────────────────────────────────────
2215
2216 /// One indent level.
2217 ///
2218 /// Two spaces, not the four both frontends type for Tab today, because in a
2219 /// markdown document four columns isn't a width — it's a *meaning*. Four
2220 /// spaces at the head of a line is markdown's indented-code-block marker, so
2221 /// one Tab on a paragraph would reparse it into code and style it as such;
2222 /// two cannot, and the line stays the prose it was. Two is also exactly
2223 /// where a `- ` bullet's content starts, so an indented line lands under its
2224 /// parent item's text instead of beside it — the column a list-aware indent
2225 /// has to hit anyway, which keeps this width from being relitigated later.
2226 const INDENT: &'static str = " ";
2227
2228 /// Indent the selected lines — or the caret's line, with no selection — by
2229 /// one level (Tab).
2230 pub fn indent(&mut self) {
2231 self.reindent(true);
2232 // Nesting changes an ordered list's numbering (the nested item restarts,
2233 // its old siblings resume) — keep the source markers in step.
2234 self.renumber_here();
2235 // Nesting an empty `-` item under a text line reparses that text as a
2236 // setext heading; swap the dash for a `*` before it can (a no-op unless
2237 // the collapse actually happened).
2238 self.avoid_setext_collapse();
2239 }
2240
2241 /// Take one indent level back off the selected lines, or the caret's line
2242 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2243 ///
2244 /// A line with *less* than a full level gives back what it has rather than
2245 /// refusing: outdent's job is to walk a line left, and real documents — hand
2246 /// written, or reflowed by some other editor — are full of indentation that
2247 /// was never a clean multiple of anything. Refusing there would strand the
2248 /// line at a depth Shift+Tab couldn't undo.
2249 pub fn outdent(&mut self) {
2250 self.reindent(false);
2251 self.renumber_here();
2252 }
2253
2254 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2255 ///
2256 /// One splice across the whole line range, never one per line: a Tab is one
2257 /// thing the user did, so it has to be one undo step and one reparse. Per
2258 /// line, twig would reparse the document once per line and leave a stack of
2259 /// steps that Shift+⌘Z walks back one line at a time.
2260 fn reindent(&mut self, add: bool) {
2261 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2262 let start = source_line_range(&self.source, sel_start).start;
2263 let end = source_line_range(&self.source, sel_end).end;
2264 let region = self.source[start..end].to_string();
2265 let lines: Vec<&str> = region.split('\n').collect();
2266 // A blank line has no text to move, and padding it would leave nothing
2267 // but trailing whitespace — but Tab on a blank line *is* a request for
2268 // indentation to type into, so the skip only applies where the op has
2269 // other lines to do real work on.
2270 let skip_blank = add && lines.len() > 1;
2271
2272 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2273 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2274 let mut line_off = start;
2275 for (i, full) in lines.iter().enumerate() {
2276 if i > 0 {
2277 out.push('\n');
2278 }
2279 // A list item moves by having its whole leading prefix *replaced*,
2280 // never by having spaces pushed in front of the line. twig spells
2281 // both prefixes, so the quote markers, the parent's indent and an
2282 // ordered marker's extra column all come out right without leaf
2283 // measuring any of them — and a line that only looks like an item
2284 // (a Djot continuation) reports no marker and is left to the plain
2285 // path, where a Tab is just a Tab.
2286 let marker = self.list_marker_on_line(line_off);
2287 let own = marker
2288 .as_ref()
2289 .map(|m| m.marker_start - m.line_start)
2290 .unwrap_or(0);
2291 let delta = if add {
2292 if skip_blank && full.trim().is_empty() {
2293 out.push_str(full);
2294 0
2295 } else if marker.is_some() && self.first_item_of_list(line_off) {
2296 // The first item of a list has no preceding sibling to nest
2297 // under, so a Tab here can't spell a sub-list — twig would
2298 // reparse the shoved-over marker as the same list, only
2299 // indented, which Shift+Tab then can't cleanly undo. Leave the
2300 // item where it is, the way every list editor refuses to
2301 // over-indent a list's first line.
2302 out.push_str(full);
2303 0
2304 } else if marker.is_some() {
2305 // Nesting means standing where a *continuation* of this line
2306 // would stand: past the parent's marker, inside its content
2307 // column. That is `continuation_prefix`, less a checkbox.
2308 let new = self.nesting_prefix_at(line_off);
2309 let delta = new.len() as isize - own as isize;
2310 out.push_str(&new);
2311 out.push_str(&full[own..]);
2312 delta
2313 } else {
2314 out.push_str(Self::INDENT);
2315 out.push_str(full);
2316 Self::INDENT.len() as isize
2317 }
2318 } else if marker.is_some() {
2319 // Unnesting is the mirror: stand where the parent item's own
2320 // line starts, which drops exactly the level it contributed.
2321 let new = self.outdent_prefix_at(line_off);
2322 let delta = new.len() as isize - own as isize;
2323 out.push_str(&new);
2324 out.push_str(&full[own..]);
2325 delta
2326 } else {
2327 // A plain line gives back the ordinary step.
2328 let strip = outdent_width(full, Self::INDENT.len());
2329 out.push_str(&full[strip..]);
2330 -(strip as isize)
2331 };
2332 deltas.push(delta);
2333 line_off += full.len() + 1;
2334 }
2335 // Nothing to give back. Returning before the splice keeps an outdent at
2336 // column zero from spending an undo step on a document it never changed.
2337 if deltas.iter().all(|d| *d == 0) {
2338 return;
2339 }
2340
2341 // Every line's text keeps its offset *within the line*, so the caret is
2342 // remapped by its column, not by its byte offset — which the prefixes on
2343 // the lines above it have already invalidated.
2344 let remap = |off: usize| -> usize {
2345 let (mut old_ls, mut new_ls) = (start, start);
2346 for (line, delta) in lines.iter().zip(&deltas) {
2347 let old_le = old_ls + line.len();
2348 let new_len = (line.len() as isize + delta) as usize;
2349 if off <= old_le {
2350 let col = (off - old_ls) as isize;
2351 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2352 }
2353 old_ls = old_le + 1;
2354 new_ls += new_len + 1;
2355 }
2356 start + out.len()
2357 };
2358 let placed = match self.selection() {
2359 // Keep the rewritten region selected, the way a container toggle
2360 // keeps its own: it leaves a second Tab aimed at the same lines
2361 // rather than at whatever the shifted offsets now happen to cover.
2362 Some(_) => (start + out.len(), Some(start)),
2363 None => (remap(self.caret), None),
2364 };
2365
2366 // A rolled-back splice leaves the old source in place, where every offset
2367 // computed above addresses text that was never written.
2368 if !self.splice(start, end, &out, EditKind::Other) {
2369 return;
2370 }
2371 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2372 // rewrite is the last line's end — nowhere the caret was. Place it, then
2373 // re-record the caret so this is the state redo restores, not the one
2374 // `splice` left behind from the `Change`.
2375 self.caret = placed.0.min(self.source.len());
2376 self.anchor = placed.1;
2377 self.clamp_caret();
2378 self.record_caret();
2379 }
2380
2381 /// The Enter key.
2382 ///
2383 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2384 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2385 /// caret is in decides what actually gets written.
2386 ///
2387 /// - paragraph → twig's [`Editor::split_block`], which parts the
2388 /// block at the caret and reopens its container
2389 /// - list item → likewise: the next item, its indent, quote
2390 /// prefix and `[ ]` box all reproduced by twig —
2391 /// except an *empty* item, which exits the list
2392 /// - block quote → likewise: a new paragraph inside the quote
2393 /// - heading → a new *paragraph*, not another heading
2394 /// - code block → a literal newline (stay in the block)
2395 /// - blank line → a literal newline (one Backspace undoes it)
2396 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2397 /// visible line
2398 ///
2399 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2400 /// and it is better at it: it drops the whitespace the caret was sitting in
2401 /// front of instead of stranding it at the head of the second half, and it
2402 /// knows continuations leaf's marker scan never covered — a checklist item
2403 /// continues as an *unchecked* checklist item rather than a plain bullet.
2404 ///
2405 /// The exceptions above are exceptions because `split_block` is either wrong
2406 /// there or refuses: parting a fence yields two fences with the code split
2407 /// between them, parting a heading yields a second heading where every editor
2408 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2409 /// table all report an error rather than a split.
2410 pub fn newline(&mut self) {
2411 if self.view == View::Source {
2412 self.insert_raw("\n");
2413 return;
2414 }
2415 // Enter over a selection replaces it with a paragraph break.
2416 if let Some((s, e)) = self.selection() {
2417 self.splice(s, e, "\n\n", EditKind::Other);
2418 return;
2419 }
2420 // A caret resting exactly between an inline mark's content and its own
2421 // closing delimiter (`**bold**` with nothing after it on the line —
2422 // the WYSIWYG caret's natural end-of-line position) must not splice a
2423 // block break there: every path below eventually does via
2424 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2425 // delimiter would strand it alone on the new line.
2426 self.caret = self.skip_trailing_close_delims(self.caret);
2427 // The block the caret is in. `block_offset_for_caret` nudges off a line
2428 // end (where the caret sits at the doc level); on a bare line (e.g. an
2429 // empty list item) fall back to the caret so the enclosing list/quote is
2430 // still visible in the ancestors.
2431 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2432 let kinds: Vec<Kind> = self
2433 .editor
2434 .ancestors_at(off)
2435 .map(|c| c.into_iter().map(|m| m.kind).collect())
2436 .unwrap_or_default();
2437 let has = |k: Kind| kinds.contains(&k);
2438
2439 if has(Kind::CodeBlock) {
2440 self.insert_raw("\n");
2441 return;
2442 }
2443 // An *empty* list item exits the list — the standard double-Enter — which
2444 // `split_block` reports as an error rather than a split (there is no
2445 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2446 // the AST gate — it answers from the tree, so a `- ` that reads as a
2447 // marker byte-for-byte but opens no item (a setext underline, a Djot
2448 // continuation line) never reaches here.
2449 if let Some(marker) = self.list_marker_on_line(self.caret)
2450 && self.item_is_empty(&marker)
2451 {
2452 self.exit_list(&marker);
2453 return;
2454 }
2455 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2456 // not another full paragraph break — so it moves down one line and one
2457 // Backspace undoes it, not two. (`split_block` errors here too.)
2458 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2459 let line_end = self.source[self.caret..]
2460 .find('\n')
2461 .map_or(self.source.len(), |i| self.caret + i);
2462 if self.source[line_start..line_end].trim().is_empty() {
2463 self.insert_raw("\n");
2464 return;
2465 }
2466 // In `Preserve` flow a soft break is a *visible* line the author means to
2467 // make, so Enter writes a single `\n` and typing continues the same
2468 // paragraph on the next line — the behaviour of an ordinary text editor.
2469 // A second Enter then lands on the blank line above and takes the
2470 // empty-line branch, so double-Enter still promotes to a full paragraph
2471 // break; and Backspace, which deletes a lone `\n` over a soft break,
2472 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2473 // render as an invisible space, so Enter keeps making the paragraph break
2474 // that actually shows.
2475 //
2476 // Only in running prose. A list or a quote has a continuation of its own
2477 // to write, and a `\n` there is not a soft line but a lost container.
2478 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2479 if self.line_flow == LineFlow::Preserve && !in_container {
2480 self.insert_raw("\n");
2481 return;
2482 }
2483 // A heading gets a *paragraph*, never a second heading: Enter at the end
2484 // of a title is how every editor is asked for the body under it, and
2485 // `split_block` would repeat the `#` instead. Whitespace at the split
2486 // point goes with the break rather than opening the new paragraph, which
2487 // is what `split_block` does everywhere else.
2488 if has(Kind::Heading) {
2489 let mut end = self.caret;
2490 while self.source.as_bytes().get(end) == Some(&b' ') {
2491 end += 1;
2492 }
2493 self.splice(self.caret, end, "\n\n", EditKind::Other);
2494 return;
2495 }
2496 self.split_block_here();
2497 }
2498
2499 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2500 /// the caret in the second half.
2501 ///
2502 /// twig reopens whatever the first half was inside of — the bullet with its
2503 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2504 /// reason this replaced the markup leaf used to spell from the line's bytes.
2505 /// It renumbers nothing, though: a new item mid-list is written with its
2506 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2507 /// behind it, folded into the same undo step.
2508 ///
2509 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2510 /// shape still moves the caret down rather than swallowing the keystroke.
2511 fn split_block_here(&mut self) {
2512 // The read-only gate — this door reaches twig without the splice.
2513 if self.read_only {
2514 return;
2515 }
2516 match self.editor.split_block(self.caret) {
2517 Ok(change) => {
2518 self.last_edit_kind = None;
2519 self.refresh();
2520 self.anchor = None;
2521 self.caret = change.new.end;
2522 self.dirty = self.source != self.clean_source;
2523 self.status = None;
2524 self.clamp_caret();
2525 self.record_caret();
2526 // Aimed at the new block's *start*: the caret twig leaves is one
2527 // past the marker it wrote, where there is no list in reach.
2528 self.renumber_at(change.new.start);
2529 }
2530 Err(_) => self.insert_raw("\n\n"),
2531 }
2532 }
2533
2534 /// Whether the item on the marker's line carries no content — the shape
2535 /// double-Enter reads as "I'm done with this list."
2536 fn item_is_empty(&self, line: &ListMarker) -> bool {
2537 let content_start = line.content_start().min(self.source.len());
2538 let line_end = self.source[self.caret..]
2539 .find('\n')
2540 .map(|i| self.caret + i)
2541 .unwrap_or(self.source.len());
2542 self.source[content_start..line_end.max(content_start)]
2543 .trim()
2544 .is_empty()
2545 }
2546
2547 /// Leave the list: replace the empty item's marker with a blank line, so the
2548 /// caret lands in a fresh paragraph below it.
2549 ///
2550 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2551 /// quote), and the caret's new line keeps the `> ` it was already behind —
2552 /// leaving the list without also leaving the quote.
2553 fn exit_list(&mut self, line: &ListMarker) {
2554 let prefix = self.quote_prefix_at(line.marker_start);
2555 let blank = prefix.trim_end();
2556 self.splice(
2557 line.line_start,
2558 self.caret,
2559 &format!("{blank}\n{prefix}"),
2560 EditKind::Other,
2561 );
2562 }
2563
2564 /// What a line continuing the containers at `off` has to open with — the
2565 /// quote markers reproduced, each enclosing item's marker as its width in
2566 /// spaces. Also the column a nested item's marker stands in, which is what
2567 /// makes it Tab's answer.
2568 fn continuation_prefix_at(&mut self, off: usize) -> String {
2569 self.editor
2570 .document()
2571 .and_then(|mut d| d.continuation_prefix(off))
2572 .map(|p| p.text)
2573 .unwrap_or_default()
2574 }
2575
2576 /// The column a *nested list* may open at inside the item at `off` — which
2577 /// is not always where the item's own text continues.
2578 ///
2579 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2580 /// it is markup a rich view hides, and the item's own wrapped text does
2581 /// stand past it. But a nested list may only open at the *list* marker's
2582 /// column, and four columns further in is an indented continuation of the
2583 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2584 /// So the box's own width goes back.
2585 ///
2586 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2587 /// reports the list marker's column apart from the box; `checked` is what
2588 /// says a box is there at all, so only its width is being measured here.
2589 fn nesting_prefix_at(&mut self, off: usize) -> String {
2590 let cont = self.continuation_prefix_at(off);
2591 let Some(item) = self.innermost_list_item(off) else {
2592 return cont;
2593 };
2594 if item.checked.is_none() {
2595 return cont;
2596 }
2597 let box_width = item
2598 .marker_span
2599 .and_then(|m| self.source.get(m))
2600 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2601 .unwrap_or(0);
2602 // The trailing columns are the ones the item's own marker contributed,
2603 // so trimming from the end leaves any quote prefix standing.
2604 cont[..cont.len().saturating_sub(box_width)].to_string()
2605 }
2606
2607 /// Where the line of the item *containing* the item at `off` begins — the
2608 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2609 /// parent contributed. The quote prefix alone for a top-level item, which
2610 /// has no level left to give.
2611 fn outdent_prefix_at(&mut self, off: usize) -> String {
2612 let items: Vec<usize> = self
2613 .editor
2614 .document()
2615 .and_then(|mut d| d.ancestors_at_caret(off))
2616 .map(|c| {
2617 c.into_iter()
2618 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2619 .map(|m| m.span.start)
2620 .collect()
2621 })
2622 .unwrap_or_default();
2623 // The second-innermost item is the parent; its own line's indent is the
2624 // target. `list_marker_on_line` gives that line's prefix directly.
2625 let parent = items.len().checked_sub(2).map(|i| items[i]);
2626 match parent.and_then(|p| self.list_marker_on_line(p)) {
2627 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2628 None => self.quote_prefix_at(off),
2629 }
2630 }
2631
2632 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2633 /// inside one, `"> > "` inside two.
2634 ///
2635 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2636 /// the `>` and the space after it are twig's spelling rather than leaf's.
2637 /// The whole line prefix can't answer this: it also carries the indent of
2638 /// whatever the quote holds, which a blank separator line must *not* repeat.
2639 fn quote_prefix_at(&mut self, off: usize) -> String {
2640 let Ok(chain) = self
2641 .editor
2642 .document()
2643 .and_then(|mut d| d.ancestors_at_caret(off))
2644 else {
2645 return String::new();
2646 };
2647 let quotes: Vec<usize> = chain
2648 .iter()
2649 .filter(|m| m.kind == Kind::BlockQuote)
2650 .map(|m| m.node_id as usize)
2651 .collect();
2652 let Ok(nodes) = self.editor.nodes() else {
2653 return String::new();
2654 };
2655 quotes
2656 .iter()
2657 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2658 .filter_map(|s| self.source.get(s))
2659 .collect()
2660 }
2661
2662 /// Whether the item at `off` sits inside another one — the test Backspace
2663 /// uses to choose between outdenting and dropping the marker.
2664 ///
2665 /// Counted from the AST rather than from the line's leading whitespace,
2666 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2667 fn item_is_nested(&mut self, off: usize) -> bool {
2668 self.editor
2669 .document()
2670 .and_then(|mut d| d.ancestors_at_caret(off))
2671 .map(|c| {
2672 c.into_iter()
2673 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2674 .count()
2675 > 1
2676 })
2677 .unwrap_or(false)
2678 }
2679
2680 /// The innermost list item containing `probe`, under twig's **caret**
2681 /// containment rule — a block's end is inside it.
2682 ///
2683 /// Half-open containment can't answer this. An empty item's span is exactly
2684 /// its marker, so the caret sitting after `- ` is one past the end and the
2685 /// item it is plainly in tests as out of reach; that is the shape
2686 /// double-Enter has to recognise to leave the list.
2687 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2688 let chain = self
2689 .editor
2690 .document()
2691 .and_then(|mut d| d.ancestors_at_caret(probe))
2692 .ok()?;
2693 let id = chain
2694 .iter()
2695 .rev()
2696 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2697 .node_id as usize;
2698 self.editor.nodes().ok()?.get(id).cloned()
2699 }
2700
2701 /// The list marker opening `off`'s line, per twig — `None` when that line
2702 /// opens no list item.
2703 ///
2704 /// [`Document::line_prefix`] is the whole hidden run from the line start:
2705 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
2706 /// and it is `None` on a *continuation* line, which opens nothing. That last
2707 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
2708 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2709 /// a paragraph and ` - b` is literal text — identical bytes, and only the
2710 /// parser knows which document it is looking at.
2711 ///
2712 /// The item's own marker is separated out via its
2713 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2714 /// the containers around it contribute and what the item does.
2715 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2716 let off = off.min(self.source.len());
2717 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2718 // The prefix belongs to a list only when an item's marker closes it —
2719 // a heading's `# ` or a bare quote's `> ` is a prefix too.
2720 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2721 let marker = item.marker_span.clone()?;
2722 if marker.end != prefix.end {
2723 return None;
2724 }
2725 Some(ListMarker {
2726 line_start: prefix.start,
2727 marker_start: marker.start,
2728 text: self.source.get(prefix)?.to_string(),
2729 })
2730 }
2731
2732 /// Whether the list item on `line_start`'s line is the **first item** of its
2733 /// list — the one Tab must not nest, because nesting needs a preceding
2734 /// sibling to become the new parent and a first item has none. `false` for a
2735 /// line that isn't a list item, and for an item with a sibling above it (the
2736 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2737 /// the same in a setext underline that opens no list at all.
2738 fn first_item_of_list(&mut self, line_start: usize) -> bool {
2739 let Some(marker) = self.list_marker_on_line(line_start) else {
2740 return false;
2741 };
2742 // Probe just inside the marker, where the item's own node is in reach —
2743 // the marker offset itself can resolve to the enclosing list, not the
2744 // `list_item`, whose span starts at the marker.
2745 let probe = marker.content_start().min(self.source.len());
2746 let Some(item) = self.innermost_list_item(probe) else {
2747 return false;
2748 };
2749 let Ok(nodes) = self.editor.nodes() else {
2750 return false;
2751 };
2752 match item.parent {
2753 // First when the parent list opens with this very item.
2754 Some(pid) => nodes
2755 .get(pid.0 as usize)
2756 .is_some_and(|p| p.first_child == Some(item.id)),
2757 // A parentless item is trivially the first (and only) one.
2758 None => true,
2759 }
2760 }
2761
2762 pub fn backspace(&mut self) {
2763 if let Some((s, e)) = self.selection() {
2764 self.splice(s, e, "", EditKind::Other);
2765 return;
2766 }
2767 // WYSIWYG: Backspace at the very start of a list item's content is a
2768 // structural key, not a character delete — it walks the "un-indent, then
2769 // un-list" ladder every list editor gives that keystroke (outdent a
2770 // nested item, strip a top-level one's marker to a paragraph). In source
2771 // view the `- ` is visible text the user is deleting a byte of, so it
2772 // keeps its literal meaning there, like Enter does.
2773 if self.view != View::Source && self.backspace_list_start() {
2774 return;
2775 }
2776 // WYSIWYG: and the same at the start of a heading's content — the `# `
2777 // there is markup the rich view hides, not text the user typed.
2778 if self.view != View::Source && self.backspace_heading_start() {
2779 return;
2780 }
2781 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2782 // the markup apart under a caret that cannot see it — see
2783 // `delete_around_block_media`.
2784 if self.view != View::Source && self.delete_around_block_media(false) {
2785 return;
2786 }
2787 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2788 // stop, not a single newline. On a line with no text of its own, the byte
2789 // before the caret is a `\n` that spells part of a block boundary — the gap
2790 // between two blocks, drawn but never a caret home. Removing just it strands
2791 // the caret in that gap and leaves an odd blank line the eye reads as one
2792 // separator but the caret can't land on: the "extra newline" left behind
2793 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2794 // Deleting to the previous stop instead collapses the whole break at once,
2795 // landing the caret at the end of the block above. Two blank lines in a row
2796 // are one stop apart, so this still removes exactly one — the lone-Enter /
2797 // lone-Backspace symmetry the empty-line case is built on is untouched.
2798 if self.view != View::Source
2799 && self.caret > self.caret_floor()
2800 && self.caret_on_blank_line()
2801 && let Some(stop) = self.vmap.stop_before(self.caret)
2802 {
2803 let stop = stop.max(self.caret_floor());
2804 if stop < self.caret {
2805 self.splice(stop, self.caret, "", EditKind::Delete);
2806 return;
2807 }
2808 }
2809 if self.caret > self.caret_floor() {
2810 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2811 // takes the whole tag — a single-byte step would leave a broken `<br`
2812 // showing in the cell. Rich view only (source view edits the literal).
2813 if self.view != View::Source
2814 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2815 {
2816 let start = start.max(self.caret_floor());
2817 if start < end {
2818 self.splice(start, end, "", EditKind::Delete);
2819 return;
2820 }
2821 }
2822 // Aim the delete at the character the writer can *see* behind the
2823 // caret, never at a delimiter the rich view drew nothing for. Two
2824 // steps, and either can apply: from the far side of a run's closing
2825 // `**` step back into the run (the caret is drawn at the end of its
2826 // word), and at the start of a run's text step out past its opening
2827 // `**` to the character in front of it, leaving the run standing.
2828 // Without them a plain Backspace unspells the phrase it is editing
2829 // and leaves a literal asterisk on screen.
2830 let end = if self.view == View::Source {
2831 self.caret
2832 } else {
2833 let inside = self.step_inside_close_delims(self.caret);
2834 self.skip_leading_open_delims(inside)
2835 .max(self.caret_floor())
2836 };
2837 // Never delete back across the floor — that would eat hidden
2838 // frontmatter the WYSIWYG caret can't even see.
2839 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2840 // Take a hidden escape backslash with the char it escapes: the rich
2841 // view draws `\*` as a single `*`, so Backspace over it must delete
2842 // both bytes, never strand the `\` as a lone visible backslash (the
2843 // mirror of the Hidden-mode typing that wrote the escape). Source view
2844 // shows the `\`, so there it is an ordinary character.
2845 if self.view != View::Source
2846 && prev > self.caret_floor()
2847 && self.is_hidden_escape(prev - 1)
2848 {
2849 prev -= 1;
2850 }
2851 if prev < end {
2852 self.splice(prev, end, "", EditKind::Delete);
2853 }
2854 }
2855 }
2856
2857 /// Whether the caret's own source line holds nothing but whitespace — an
2858 /// empty paragraph, or the blank line a block boundary is spelled with. The
2859 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2860 /// no text of its own, so the newline before the caret belongs to the gap
2861 /// between blocks rather than to any word the caret is editing.
2862 fn caret_on_blank_line(&self) -> bool {
2863 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2864 let line_end = self.source[self.caret..]
2865 .find('\n')
2866 .map_or(self.source.len(), |i| self.caret + i);
2867 self.source[line_start..line_end].trim().is_empty()
2868 }
2869
2870 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2871 /// `edge` side — the byte range to delete whole. A table row is one source
2872 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2873 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2874 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2875 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2876 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2877 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
2878 /// apart — no ancestor walk needed. Rich view only; source view shows the
2879 /// literal tag and deletes it a byte at a time.
2880 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2881 let caret = self.caret;
2882 let nodes = self.nodes();
2883 let src = self.source.as_bytes();
2884 nodes
2885 .iter()
2886 .find(|n| {
2887 n.kind == Kind::HardBreak
2888 && n.span.start < n.span.end
2889 && src.get(n.span.start) == Some(&b'<')
2890 && match edge {
2891 BreakEdge::Backward => n.span.end == caret,
2892 BreakEdge::Forward => n.span.start == caret,
2893 }
2894 })
2895 .map(|n| (n.span.start, n.span.end))
2896 }
2897
2898 /// Whether the source byte at `off` is a backslash twig consumed as an escape
2899 /// (hidden in the rich view), as against a literal backslash (drawn). A
2900 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2901 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2902 /// round-trip needed.
2903 fn is_hidden_escape(&self, off: usize) -> bool {
2904 let b = self.source.as_bytes();
2905 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2906 }
2907
2908 /// Backspace's list behaviour: when the caret sits exactly at the start of a
2909 /// list item's content (right after its marker), outdent the item if it's
2910 /// nested, else strip the marker so it becomes a paragraph. Returns whether
2911 /// it acted — `false` leaves Backspace its ordinary character delete.
2912 fn backspace_list_start(&mut self) -> bool {
2913 let Some(marker) = self.list_marker_on_line(self.caret) else {
2914 return false;
2915 };
2916 // Only right after the marker. That the line opens a real item is
2917 // already settled: `list_marker_on_line` answers from the tree.
2918 if self.caret != marker.content_start() {
2919 return false;
2920 }
2921 if self.item_is_nested(marker.marker_start) {
2922 // Nested: give back one level, keeping the marker and carrying the
2923 // caret with it.
2924 self.outdent();
2925 } else {
2926 // Top level: drop the marker, leaving a paragraph, then renumber the
2927 // siblings the removed item was counted among. Only the marker goes —
2928 // a quote prefix in front of it still has a quote to hold up.
2929 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2930 self.renumber_here();
2931 }
2932 true
2933 }
2934
2935 /// Backspace's heading behaviour: with the caret exactly at the start of an
2936 /// ATX heading's content — right after the `#` marker the rich view hides —
2937 /// strip the marker so the line becomes a paragraph. The peer of
2938 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2939 /// reasoning: hidden block markup is structure, so the keystroke over it is
2940 /// structural.
2941 ///
2942 /// Without this the ordinary delete takes the space out of `# Title` and
2943 /// leaves `#Title`, which is no longer a heading at all — the hash the view
2944 /// had been hiding surfaces as literal text the user has to delete a second
2945 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2946 /// other end) goes with the marker for the same reason.
2947 ///
2948 /// Returns whether it acted; `false` leaves Backspace its character delete.
2949 fn backspace_heading_start(&mut self) -> bool {
2950 let caret = self.caret;
2951 // The heading whose content opens exactly at the caret. A bare `#` has no
2952 // content span at all — its content starts (and ends) where the line does.
2953 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2954 let (start, end) = match &n.content_span {
2955 Some(c) => (c.start, c.end),
2956 None => (n.span.end, n.span.end),
2957 };
2958 (n.kind == Kind::Heading && start == caret)
2959 .then(|| (n.span.clone(), end, n.marker_span.clone()))
2960 }) else {
2961 return false;
2962 };
2963 // twig reports the marker's own extent, so there is nothing to walk back
2964 // over and no `#` in this file. A setext heading has no marker — its
2965 // content opens the line — so it falls through to the ordinary delete,
2966 // as does anything else sitting at a content start.
2967 // `m.end == caret` is what excludes a setext heading, whose marker is the
2968 // underline *after* the content rather than a prefix before it.
2969 let Some(marker) = marker.filter(|m| m.end == caret) else {
2970 return false;
2971 };
2972 let start = marker.start;
2973 // A closing `#` sequence is hidden too, so it can't be left behind. Only
2974 // when the tail really is one: trailing spaces alone are nothing to strip.
2975 let tail = &self.source[content_end..span.end];
2976 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2977 let kept = self.source[caret..content_end].to_string();
2978 self.splice(start, span.end, &kept, EditKind::Other);
2979 // The splice leaves the caret past the text it re-wrote; the caret
2980 // belongs where the content now starts, which is where it already was.
2981 self.caret = start;
2982 self.record_caret();
2983 } else {
2984 self.splice(start, caret, "", EditKind::Other);
2985 }
2986 true
2987 }
2988
2989 pub fn delete_forward(&mut self) {
2990 if let Some((s, e)) = self.selection() {
2991 self.splice(s, e, "", EditKind::Other);
2992 } else if self.caret < self.source.len() {
2993 // The mirror of Backspace's: forward-delete in front of a picture
2994 // would eat the `!` off its markup and leave a link where a photo was.
2995 if self.view != View::Source && self.delete_around_block_media(true) {
2996 return;
2997 }
2998 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2999 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3000 // strands a broken `<br` in the cell.
3001 if self.view != View::Source
3002 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3003 {
3004 self.splice(start, end, "", EditKind::Delete);
3005 return;
3006 }
3007 // The mirror of Backspace's two steps: from in front of a run's
3008 // opening `**` step into it, onto the first letter of its text, and
3009 // at the end of a run's text step out past its closing `**` to the
3010 // character beyond. Either way Delete takes the character it looks
3011 // like it is pointing at, and never a delimiter drawn as nothing.
3012 // The caret then settles back inside the run it was standing in —
3013 // see `settle_inside_close_delims`.
3014 let from = if self.view == View::Source {
3015 self.caret
3016 } else {
3017 let inside = self.step_inside_open_delims(self.caret);
3018 self.skip_trailing_close_delims(inside)
3019 };
3020 let next = next_boundary(&self.source, from);
3021 if from < next {
3022 self.splice(from, next, "", EditKind::Delete);
3023 }
3024 }
3025 }
3026
3027 /// Delete from the caret back to the start of the previous word (⌥⌫ /
3028 /// Ctrl+⌫). Deletes the selection instead when one is active.
3029 pub fn delete_word_back(&mut self) {
3030 if let Some((s, e)) = self.selection() {
3031 self.splice(s, e, "", EditKind::Other);
3032 } else {
3033 // A word back from just past a picture is a word *of its markup*, and
3034 // a word back from in front of one runs through the paragraph break
3035 // into the prose above — dissolving the picture either way. See
3036 // `delete_around_block_media`.
3037 if self.view != View::Source && self.delete_around_block_media(false) {
3038 return;
3039 }
3040 let start = self.word_left_from(self.caret).max(self.caret_floor());
3041 if start < self.caret {
3042 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3043 self.splice(s, e, "", EditKind::Delete);
3044 }
3045 }
3046 }
3047
3048 /// Delete from the caret forward to the end of the next word (⌥⌦ /
3049 /// Ctrl+Del). Deletes the selection instead when one is active.
3050 pub fn delete_word_forward(&mut self) {
3051 if let Some((s, e)) = self.selection() {
3052 self.splice(s, e, "", EditKind::Other);
3053 } else {
3054 // The mirror: a word forward from in front of a picture is its markup.
3055 if self.view != View::Source && self.delete_around_block_media(true) {
3056 return;
3057 }
3058 let end = self.word_right_from(self.caret);
3059 if end > self.caret {
3060 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3061 self.splice(s, e, "", EditKind::Delete);
3062 }
3063 }
3064 }
3065
3066 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3067 /// selection instead when one is active, as every other delete here does.
3068 ///
3069 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3070 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3071 /// stops at the first character and this takes the indentation with it, the
3072 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3073 /// leave an indent behind that nothing can then ask to delete, where a caret
3074 /// left at column 0 is one press of Home away from either.
3075 pub fn delete_to_line_start(&mut self) {
3076 if let Some((s, e)) = self.selection() {
3077 self.splice(s, e, "", EditKind::Other);
3078 return;
3079 }
3080 // Never back across the floor: hidden frontmatter isn't on this line, or
3081 // on any line the WYSIWYG caret can see.
3082 let (start, _) = self.line_span();
3083 let start = start.max(self.caret_floor());
3084 if start < self.caret {
3085 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3086 self.splice(s, e, "", EditKind::Delete);
3087 }
3088 }
3089
3090 /// Kill from the caret to the end of its line (^K). Deletes the selection
3091 /// instead when one is active.
3092 ///
3093 /// At the end of the line it does nothing, rather than pulling the line
3094 /// below up into this one. Joining has no meaning to give it in both views
3095 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3096 /// there is nothing there to delete, while the newline a *source* line ends
3097 /// with is only half of the blank line that separates two paragraphs —
3098 /// deleting one leaves a soft break, which is not the join it looks like.
3099 /// The views agreeing is worth more than emacs' second press, and Delete is
3100 /// already the key that joins.
3101 pub fn delete_to_line_end(&mut self) {
3102 if let Some((s, e)) = self.selection() {
3103 self.splice(s, e, "", EditKind::Other);
3104 return;
3105 }
3106 let (_, end) = self.line_span();
3107 if end > self.caret {
3108 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3109 self.splice(s, e, "", EditKind::Delete);
3110 }
3111 }
3112
3113 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3114 ///
3115 /// A glyph-space range covers what the user can see, which for `**bold**` is
3116 /// the word and never the delimiters around it — so deleting the word on its
3117 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3118 /// word, and the styling was the word's; the two go together. Only the
3119 /// node's delimiters are taken, and those are hidden here anyway, so nothing
3120 /// visible outside the range is lost.
3121 ///
3122 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3123 /// the strong, and only then is the strong empty too.
3124 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3125 if self.view == View::Source {
3126 return (start, end);
3127 }
3128 let nodes = self.nodes();
3129 let (mut s, mut e) = (start, end);
3130 loop {
3131 let mut grew = false;
3132 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3133 let Some(text) = inline_content_span(n, &self.source) else {
3134 continue;
3135 };
3136 // Some of its text survives, so the node still has a job.
3137 if text.start < s || text.end > e {
3138 continue;
3139 }
3140 if n.span.start < s || n.span.end > e {
3141 s = s.min(n.span.start);
3142 e = e.max(n.span.end);
3143 grew = true;
3144 }
3145 }
3146 if !grew {
3147 return (s, e);
3148 }
3149 }
3150 }
3151
3152 /// One splice of document text, keeping the **mark-edge rule**: an inline
3153 /// mark's content never begins or ends with whitespace. In Markdown and Djot
3154 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3155 /// is four literal asterisks around a word, and a rich view drawing the
3156 /// document faithfully has no choice but to show them. That is correct
3157 /// rendering of what the file says, and nobody typing a space after a bold
3158 /// word meant to say it.
3159 ///
3160 /// So the space goes *outside* the run instead — `**bold** ` — which is the
3161 /// same document to a reader and a live one to a parser. The caret follows it
3162 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3163 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3164 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3165 ///
3166 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3167 /// through here, so the rule holds however the whitespace arrives at the
3168 /// edge. The repair is decided *after* the plain edit, by asking whether the
3169 /// mark actually died: a code span's backticks aren't whitespace-sensitive
3170 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3171 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3172 let fix = self.mark_edge_fix(start, end, text);
3173 if !self.splice_exact(start, end, text, kind) {
3174 return false;
3175 }
3176 if let Some(fix) = fix {
3177 self.repair_mark_edges(fix);
3178 }
3179 if text.is_empty() && end > start {
3180 self.settle_inside_close_delims();
3181 }
3182 true
3183 }
3184
3185 /// After a delete, take a caret left standing past a run's closing delimiters
3186 /// back inside the run.
3187 ///
3188 /// A delete leaves the caret where the deleted bytes began, and when those
3189 /// bytes were the last thing after a marked phrase — the space the mark-edge
3190 /// rule pushed out of `**bold** `, say — that spot is the far side of the
3191 /// closing `**`. The rich view has nothing to draw there: the delimiters are
3192 /// hidden, so the caret shows at the end of the word either way, and the two
3193 /// offsets are one place on screen with two different meanings. Typing at the
3194 /// outer one lands past the run, so the writer who backspaced a space out of
3195 /// their bold phrase watches the next character come out plain, and the
3196 /// toolbar button go dark, with the caret never appearing to move.
3197 ///
3198 /// The end of the run's text is the caret's home there — a delete that took
3199 /// away everything after a phrase leaves the caret at the end of that phrase,
3200 /// which is inside it — so it settles onto that
3201 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3202 /// walk, through every mark closing at the point): the word stays bold, the
3203 /// button stays lit, and the next character carries on the phrase.
3204 ///
3205 /// Rich view only, and only where a mark really closes at the caret — mid-run
3206 /// or in plain prose no span ends there and the caret stays put. The opening
3207 /// edge is left alone on purpose: a caret in front of a run inherits from the
3208 /// text on its left, which is the plain text outside.
3209 fn settle_inside_close_delims(&mut self) {
3210 if self.view != View::Wysiwyg {
3211 return;
3212 }
3213 let at = self.step_inside_close_delims(self.caret);
3214 if at != self.caret {
3215 self.caret = at;
3216 self.clear_pending();
3217 self.record_caret();
3218 }
3219 }
3220
3221 /// The splice exactly as asked, with no mark-edge repair — for the callers
3222 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3223 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3224 /// the bytes they inserted.
3225 ///
3226 /// One `edit_range` through twig, then re-anchor the caret from the returned
3227 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3228 /// Markdown/Djot) leaves the document untouched and reports.
3229 ///
3230 /// Returns whether the edit landed — for a caller that has offsets of its
3231 /// own to place afterwards, which a rolled-back splice would leave pointing
3232 /// into text that never came to exist.
3233 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3234 // The read-only gate, for every edit at once — see the field.
3235 if self.read_only {
3236 return false;
3237 }
3238 // twig records an undo step for every edit; when this one continues a
3239 // run of the same kind (typing, deleting), tell twig to fold it into the
3240 // step before it so the whole run undoes at once.
3241 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3242 // Hand twig the pre-edit caret before the splice, so the undo step it
3243 // retires carries where the caret was standing.
3244 self.record_caret();
3245 match self.editor.edit_range(start, end, text) {
3246 Ok(change) => {
3247 if coalesce {
3248 let _ = self.editor.coalesce_last_undo();
3249 }
3250 self.last_edit_kind = Some(kind);
3251 self.refresh();
3252 self.caret = change.new.end;
3253 self.anchor = None;
3254 self.goal_col = None;
3255 self.clear_pending();
3256 self.dirty = self.source != self.clean_source;
3257 self.status = None;
3258 // And the post-edit caret, so a later redo restores it.
3259 self.record_caret();
3260 true
3261 }
3262 // The edit was rolled back, so twig's history did not move and
3263 // neither may ours: pushing here would leave a step with no edit
3264 // under it and shift every later undo onto the wrong caret.
3265 Err(e) => {
3266 self.status = Some(format!("edit: {e}"));
3267 false
3268 }
3269 }
3270 }
3271
3272 /// The re-spelling that would keep the mark-edge rule for the edit
3273 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3274 /// against a delimiter and the plain splice is already right. Computed
3275 /// *before* the edit, while the run's spans and delimiters can still be read
3276 /// off the document; applied afterwards, and only if the mark really died —
3277 /// see [`repair_mark_edges`](Self::repair_mark_edges).
3278 ///
3279 /// Rich view only. Source view is for typing raw markup, where a space put
3280 /// against a `**` is exactly the character it looks like.
3281 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3282 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3283 return None;
3284 }
3285 // Every inline mark standing over the edit, outermost first, with the
3286 // content span that says where its delimiters are.
3287 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3288 .editor
3289 .ancestors_at(start)
3290 .unwrap_or_default()
3291 .into_iter()
3292 .filter_map(|m| {
3293 let kind = inline_kind(&m.kind)?;
3294 let content = m.content_span.clone()?;
3295 Some((kind, m.span.clone(), content))
3296 })
3297 .collect();
3298 // The innermost run whose *content* holds the whole edit: the one whose
3299 // text is being changed, rather than one the edit merely sits under.
3300 let (kind, span, content) = chain
3301 .iter()
3302 .rev()
3303 .find(|(_, _, c)| c.start <= start && end <= c.end)?
3304 .clone();
3305 // What that content becomes. Whitespace at either end of it is what
3306 // would put out the mark.
3307 let body = format!(
3308 "{}{text}{}",
3309 &self.source[content.start..start],
3310 &self.source[end..content.end]
3311 );
3312 let (lead, trail) = if body.trim().is_empty() {
3313 // Nothing but whitespace left: there is no content to mark at all,
3314 // and the delimiters go with it rather than closing on a space.
3315 (body.len(), 0)
3316 } else {
3317 (
3318 body.len() - body.trim_start().len(),
3319 body.len() - body.trim_end().len(),
3320 )
3321 };
3322 // Nothing against a delimiter, and something still between them: the
3323 // plain edit stands. An emptied run is broken just as surely (`**b**`
3324 // with the `b` deleted is the literal `****`) and is re-spelt as the
3325 // nothing it now says.
3326 if lead == 0 && trail == 0 && !body.is_empty() {
3327 return None;
3328 }
3329 // Marks that open or close exactly where this one does — `***both***` is
3330 // two runs sharing an edge — spell their delimiters as one run of bytes,
3331 // so the whitespace has to clear all of them together.
3332 let (mut open_at, mut close_at) = (span.start, span.end);
3333 for _ in 0..chain.len() {
3334 match chain.iter().find(|(_, _, c)| c.start == open_at) {
3335 Some((_, s, _)) => open_at = s.start,
3336 None => break,
3337 }
3338 }
3339 for _ in 0..chain.len() {
3340 match chain.iter().find(|(_, _, c)| c.end == close_at) {
3341 Some((_, s, _)) => close_at = s.end,
3342 None => break,
3343 }
3344 }
3345 let open = &self.source[open_at..content.start];
3346 let close = &self.source[content.end..close_at];
3347 let core = &body[lead..body.len() - trail];
3348 let respelt = if core.is_empty() {
3349 body.clone()
3350 } else {
3351 format!(
3352 "{}{open}{core}{close}{}",
3353 &body[..lead],
3354 &body[body.len() - trail..]
3355 )
3356 };
3357 // The caret sits just past the inserted text within the new content —
3358 // which, when that lands in the whitespace, is now outside the delimiters.
3359 let pos = (start - content.start) + text.len();
3360 let caret = if core.is_empty() || pos <= lead {
3361 open_at + pos
3362 } else if pos >= lead + core.len() {
3363 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3364 } else {
3365 open_at + lead + open.len() + (pos - lead)
3366 };
3367 Some(MarkEdgeFix {
3368 kind,
3369 probe: content.start,
3370 start: open_at,
3371 end: close_at + text.len() - (end - start),
3372 text: respelt,
3373 caret,
3374 // The marks in force here, resolved against any armed sticky delta —
3375 // what the writer is typing in, and so what has to still be true on
3376 // the far side of the delimiter the caret just stepped over.
3377 want: chain
3378 .iter()
3379 .filter(|(_, s, _)| start < s.end)
3380 .map(|(k, _, _)| *k)
3381 .collect::<InlineMarks>()
3382 .xor(self.pending_here()),
3383 })
3384 }
3385
3386 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3387 /// did break the mark. Whether whitespace at a delimiter is fatal is the
3388 /// format's business, not leaf's: `**bold **` is no longer strong, while
3389 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3390 /// don't care either. Asking the parser afterwards settles it for every kind
3391 /// and format at once, and costs a re-spelling only where one is due.
3392 ///
3393 /// The repair rides along with the edit that caused it — one undo step puts
3394 /// back what the writer typed, not a delimiter shuffle they never saw.
3395 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3396 if fix.end > self.source.len() {
3397 return;
3398 }
3399 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3400 return; // still a mark: these delimiters don't mind the whitespace
3401 }
3402 let resumed = self.last_edit_kind;
3403 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3404 return;
3405 }
3406 let _ = self.editor.coalesce_last_undo();
3407 // The keystroke owns the undo step, so the run of typing it belongs to
3408 // keeps coalescing over the repair rather than breaking in two here.
3409 self.last_edit_kind = resumed;
3410 self.caret = fix.caret.min(self.source.len());
3411 self.anchor = None;
3412 self.goal_col = None;
3413 self.rearm(fix.want);
3414 self.clamp_caret();
3415 self.record_caret();
3416 }
3417
3418 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3419 /// writer is typing in, carried across an edit that moved the caret out of
3420 /// the run holding them. Arms nothing when the caret already stands in
3421 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3422 /// a clean delta here (see [`toggle`](Self::toggle)).
3423 fn rearm(&mut self, want: InlineMarks) {
3424 let here: InlineMarks = self
3425 .marks_at(self.caret)
3426 .into_iter()
3427 .map(|(k, _)| k)
3428 .collect();
3429 self.pending_marks = want.xor(here);
3430 self.pending_at = Some(self.caret);
3431 }
3432
3433 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3434 /// which backslash-escapes any character that would otherwise open markup in
3435 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3436 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3437 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3438 /// a collapsed point — a selection is deleted by the caller first, since
3439 /// `insert_literal` inserts rather than replaces.
3440 fn insert_literal_at(
3441 &mut self,
3442 at: usize,
3443 text: &str,
3444 kind: EditKind,
3445 force_coalesce: bool,
3446 ) -> bool {
3447 // The read-only gate: this door goes to twig directly, not through
3448 // `splice_exact`, so it guards itself — see the field.
3449 if self.read_only {
3450 return false;
3451 }
3452 // `force_coalesce` folds this into the immediately preceding edit (the
3453 // selection-delete of an overwrite) so the pair is one undo step; else it
3454 // coalesces only when it continues a run of the same-kind typing.
3455 let coalesce =
3456 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3457 // The mark-edge rule holds for typed text however it is spelled — see
3458 // `splice`. Only an insert twig passed through unchanged can use it,
3459 // since a fix is measured in the bytes that actually land, and an escape
3460 // adds bytes this couldn't have counted.
3461 let fix = self.mark_edge_fix(at, at, text);
3462 self.record_caret();
3463 match self.editor.insert_literal(at, text) {
3464 Ok(change) => {
3465 if coalesce {
3466 let _ = self.editor.coalesce_last_undo();
3467 }
3468 self.last_edit_kind = Some(kind);
3469 self.refresh();
3470 self.caret = change.new.end;
3471 self.anchor = None;
3472 self.goal_col = None;
3473 self.clear_pending();
3474 self.dirty = self.source != self.clean_source;
3475 self.status = None;
3476 self.record_caret();
3477 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3478 self.repair_mark_edges(fix);
3479 }
3480 true
3481 }
3482 Err(e) => {
3483 self.status = Some(format!("edit: {e}"));
3484 false
3485 }
3486 }
3487 }
3488
3489 /// After a structural list edit (a new item, a nest/unnest), renumber the
3490 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3491 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3492 /// renumber as its own edit; fold it into the edit that triggered it so the
3493 /// two undo as one, and only when it actually changed the source (a no-op or
3494 /// a caret outside any ordered list must not coalesce the real edit into the
3495 /// step before it).
3496 fn renumber_here(&mut self) {
3497 self.renumber_at(self.caret);
3498 }
3499
3500 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3501 /// caret — for an edit that leaves the caret one past the item it just wrote,
3502 /// where twig resolves no list to renumber.
3503 fn renumber_at(&mut self, off: usize) {
3504 // The read-only gate — this door reaches twig without the splice.
3505 if self.read_only {
3506 return;
3507 }
3508 let before = self.source.clone();
3509 if self.editor.renumber_ordered_lists(off).is_err() {
3510 return; // not inside an ordered list — nothing to renumber
3511 }
3512 self.refresh();
3513 if self.source != before {
3514 let _ = self.editor.coalesce_last_undo();
3515 self.dirty = self.source != self.clean_source;
3516 self.clamp_caret();
3517 self.record_caret();
3518 }
3519 }
3520
3521 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3522 /// sub-item written directly beneath a text line reparses that text as a
3523 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
3524 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3525 /// bullets can't underline anything, so swap the dash for a `*`: the item
3526 /// stays an empty nested bullet, the parent stays prose, and the source
3527 /// round-trips instead of hiding a heading the user never asked for. Folded
3528 /// into the triggering edit's undo step, the way renumbering is.
3529 ///
3530 /// Gated on the collapse having actually happened (the swapped dash was
3531 /// swallowed into a `heading`), so a real setext heading the author wrote —
3532 /// or a `- x` with content, which can't underline anything — is never
3533 /// touched. This has to live in the *edit*, not the renderer: leaving the
3534 /// hazardous bytes on disk and only painting over them would ship a file
3535 /// every other CommonMark tool reads as a heading.
3536 ///
3537 /// This one keeps its own byte scan, and has to: the hazard is precisely
3538 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3539 /// which asks twig which lines open an item — reports nothing here. There is
3540 /// no node to ask about. It is also the last Markdown spelling leaf writes on
3541 /// purpose rather than for want of an answer; once twig spells continuations
3542 /// itself, avoiding the trap becomes twig's, and this goes.
3543 ///
3544 /// [`list_marker_on_line`]: Self::list_marker_on_line
3545 fn avoid_setext_collapse(&mut self) {
3546 let caret = self.caret.min(self.source.len());
3547 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3548 let bytes = self.source.as_bytes();
3549 let mut dash = line_start;
3550 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3551 dash += 1;
3552 }
3553 // A dash bullet is the only marker that doubles as a setext underline.
3554 if bytes.get(dash) != Some(&b'-') {
3555 return;
3556 }
3557 // Only an *empty* item is a bare underline; `- x` carries content and
3558 // can't fold the line above into a heading.
3559 let line_end = self.source[dash..]
3560 .find('\n')
3561 .map_or(self.source.len(), |i| dash + i);
3562 if !self.source[dash + 1..line_end].trim().is_empty() {
3563 return;
3564 }
3565 // The tell: that dash was swallowed into a `heading`. A properly nested
3566 // empty item sits under a `list_item`, with no heading in reach. Probe
3567 // the dash byte itself (well inside the heading), not the caret, whose
3568 // end-of-line offset can fall on the half-open span boundary.
3569 let collapsed = self
3570 .editor
3571 .ancestors_at(dash)
3572 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3573 .unwrap_or(false);
3574 if !collapsed {
3575 return;
3576 }
3577 let caret = self.caret;
3578 if self.splice(dash, dash + 1, "*", EditKind::Other) {
3579 // Same width, so the caret keeps its column; fold into the edit that
3580 // triggered this so Tab stays one undo step.
3581 let _ = self.editor.coalesce_last_undo();
3582 self.caret = caret.min(self.source.len());
3583 self.clamp_caret();
3584 self.record_caret();
3585 }
3586 }
3587
3588 fn snapshot(&self) -> CaretState {
3589 CaretState {
3590 caret: self.caret,
3591 anchor: self.anchor,
3592 }
3593 }
3594
3595 /// Hand twig the current caret and selection as the blob for the live
3596 /// document state. Called before an edit — so the step twig retires records
3597 /// where the caret was, and undo can restore it — and again once the op has
3598 /// placed the caret, so redo restores where the edit left it.
3599 ///
3600 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3601 /// caret through its own history, so coalescing falls out for free (folding
3602 /// two twig steps into one drops the intermediate blob, keeping the run's
3603 /// first) and the parallel stacks that had to march in lockstep — and could
3604 /// silently drift out of it — are gone.
3605 fn record_caret(&mut self) {
3606 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3607 }
3608
3609 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3610 /// the toggled region selected so a second press cleanly reverses it.
3611 pub fn toggle(&mut self, kind: InlineKind) {
3612 // The read-only gate — this door reaches twig without the splice.
3613 if self.read_only {
3614 return;
3615 }
3616 // Ahead of the no-selection branch below: arming a mark for text not yet
3617 // typed is a promise `insert` cannot keep in a format with no delimiters
3618 // to spell it with. Per *kind*, not per format — Markdown spells five
3619 // of the eight marks (highlight among them, under the `highlight`
3620 // extension leaf parses with), djot all eight, HTML seven.
3621 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3622 return;
3623 }
3624 let Some((s, e)) = self.selection() else {
3625 // No selection: arm the mark for the next text typed here, the way a
3626 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3627 // in the flow of typing without ever selecting anything — the delta
3628 // is realised onto the freshly typed text by `insert`. A fresh caret
3629 // position starts the delta over from the marks actually in force.
3630 if self.pending_at != Some(self.caret) {
3631 self.pending_marks = InlineMarks::empty();
3632 self.pending_at = Some(self.caret);
3633 }
3634 self.pending_marks.flip(kind);
3635 self.status = None;
3636 return;
3637 };
3638 // Whitespace at the edge of a selection is not part of what was chosen —
3639 // a double-click takes the space after the word with it — and a mark
3640 // cannot close against one anyway: `**word **` is four literal asterisks
3641 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3642 let picked = &self.source[s..e];
3643 let (s, e) = (
3644 s + (picked.len() - picked.trim_start().len()),
3645 e - (picked.len() - picked.trim_end().len()),
3646 );
3647 if s >= e {
3648 self.status = Some(format!("{kind:?}: nothing selected to mark"));
3649 return;
3650 }
3651 // Styling a selection is a one-shot act, not a sticky mode.
3652 self.clear_pending();
3653 self.record_caret();
3654 match self.editor.toggle_inline(s, e, kind) {
3655 Ok(change) => {
3656 self.last_edit_kind = None; // structural edit is its own undo step
3657 self.refresh();
3658 self.anchor = Some(change.new.start);
3659 self.caret = change.new.end;
3660 self.dirty = self.source != self.clean_source;
3661 self.status = None;
3662 self.record_caret();
3663 }
3664 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3665 }
3666 }
3667
3668 /// Whether the caret stands in a highlight — what a frontend asks to enable
3669 /// or disable its highlight-colour controls, the way
3670 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
3671 ///
3672 /// A fact about the *caret*, and the other half of
3673 /// [`Capabilities::mark_color`], which is the fact about the format. A
3674 /// frontend needs both: djot spells a highlight and no colour for it, so a
3675 /// caret standing in `{=word=}` answers `true` here and still has no palette
3676 /// to offer.
3677 ///
3678 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
3679 /// the palette appears exactly where the Highlight button is lit — with one
3680 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
3681 /// into lights the button and answers `false` here, because there is no node
3682 /// to colour until the text exists.
3683 pub fn caret_in_mark(&mut self) -> bool {
3684 self.mark_offset().is_some()
3685 }
3686
3687 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
3688 /// standing in the highlight the gesture means — or `None` when neither end
3689 /// of what is selected is in one.
3690 ///
3691 /// The caret first, and the selection's *start* after it, because of what
3692 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
3693 /// whole, with the caret at its far edge, one past the closing `==` and so
3694 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
3695 /// and colour it — the two presses a coloured highlight is made of — would
3696 /// otherwise refuse on the second, having just written the highlight the
3697 /// author is pointing at.
3698 fn mark_offset(&mut self) -> Option<usize> {
3699 let in_mark = |d: &mut Self, off: usize| {
3700 d.marks_at(off)
3701 .into_iter()
3702 .any(|(k, _)| k == InlineKind::Mark)
3703 .then_some(off)
3704 };
3705 let caret = self.caret.min(self.source.len());
3706 in_mark(self, caret).or_else(|| {
3707 let start = self.selection()?.0;
3708 in_mark(self, start)
3709 })
3710 }
3711
3712 /// The colour of the highlight at the caret — `None` both when the caret is
3713 /// in no highlight and when the highlight it is in names no colour, which
3714 /// are the same answer to "which swatch is lit".
3715 ///
3716 /// The innermost mark, by span, for the same reason
3717 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
3718 /// what the caret is *in* is the deepest node containing it. A `data-color`
3719 /// naming a colour this build has no variant for reads as `None` — the
3720 /// renderer already draws that as a plain highlight rather than guessing,
3721 /// and the toolbar agrees with the renderer.
3722 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
3723 let at = self.mark_offset()?;
3724 self.mark_color_at(at)
3725 }
3726
3727 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
3728 /// the innermost `mark` covering it, and the colour it names.
3729 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
3730 self.nodes()
3731 .into_iter()
3732 .filter(|n| n.kind == Kind::Mark)
3733 .filter(|n| n.span.start <= off && off < n.span.end)
3734 .min_by_key(|n| n.span.end - n.span.start)
3735 .and_then(|n| MarkColor::from_attrs(&n.attrs))
3736 }
3737
3738 /// Colour the highlight at the caret, or clear its colour with `None` — the
3739 /// palette behind a toolbar's Highlight button.
3740 ///
3741 /// Markdown only, and the one gesture whose availability is a fact about the
3742 /// *parse extensions* rather than about the format alone: the colour is
3743 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
3744 /// records as the mark's `data-color`, and only an editor parsing with
3745 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
3746 /// document) reads it back that way. Djot spells the highlight and no colour
3747 /// for it, so this refuses there — see [`Capabilities::mark_color`].
3748 ///
3749 /// **A colour is a property of a highlight that already exists.** There is
3750 /// no "highlight this in red" here, because that is two splices and would be
3751 /// two undo steps under one press; a frontend that wants it calls
3752 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
3753 /// order the two buttons already sit in. With no highlight at the caret this
3754 /// says so in the status line and writes nothing.
3755 ///
3756 /// The caret keeps its place in the *text*: the splice is entirely in the
3757 /// prefix between the opening `==` and the first word, so an offset past it
3758 /// rides the emoji's width, and one standing on the prefix itself lands
3759 /// where the prefix now ends.
3760 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
3761 // The read-only gate — this door reaches twig without the splice.
3762 if self.read_only {
3763 return;
3764 }
3765 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
3766 return;
3767 }
3768 let Some(at) = self.mark_offset() else {
3769 self.status = Some("highlight colour: no highlight at the caret".into());
3770 return;
3771 };
3772 // Clearing a colour a highlight hasn't got is twig's one *successful*
3773 // no-op, and the `Change` it hands back then describes whatever edit came
3774 // before it — a stale span that would drag the caret somewhere it never
3775 // was. Answer it here, where the question is cheap, rather than trusting
3776 // a change that isn't one.
3777 if color.is_none() && self.mark_color_at(at).is_none() {
3778 self.status = None;
3779 return;
3780 }
3781 self.record_caret();
3782 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
3783 Ok(change) => {
3784 // Re-anchored from the offsets as they were, *before* `refresh`
3785 // sees the new bytes: the caret it clamps is one standing inside
3786 // a prefix that didn't exist a moment ago, and walking it back to
3787 // a char boundary of the emoji loses the place this is restoring.
3788 let caret = reanchor(self.caret, &change);
3789 let anchor = self.anchor.map(|a| reanchor(a, &change));
3790 self.last_edit_kind = None; // structural edit is its own undo step
3791 self.refresh();
3792 self.caret = caret;
3793 self.anchor = anchor;
3794 self.dirty = self.source != self.clean_source;
3795 self.status = None;
3796 self.clamp_caret();
3797 self.record_caret();
3798 }
3799 Err(e) => self.status = Some(format!("highlight colour: {e}")),
3800 }
3801 }
3802
3803 /// One press of a colour swatch: colour the highlight at the caret, or —
3804 /// over a selection that isn't highlighted yet — highlight it and colour it,
3805 /// as **one** undo step.
3806 ///
3807 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
3808 /// one splice; this is the compound every toolbar actually presses, and it
3809 /// lives here rather than in each frontend because the rule it encodes —
3810 /// what a swatch means when there is no highlight under it yet — is one
3811 /// answer, not one per frontend. The two splices are folded into a single
3812 /// history step, so the press that made a red highlight is taken back by a
3813 /// single undo rather than leaving an uncoloured one behind.
3814 ///
3815 /// `None` clears the colour, and over an unhighlighted selection means
3816 /// simply "highlight this" — the same thing the Highlight button does.
3817 /// A bare caret in no highlight is left alone with a status line, because
3818 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
3819 /// colour cannot be armed with it.
3820 pub fn highlight(&mut self, color: Option<MarkColor>) {
3821 if self.caret_in_mark() || self.selection().is_none() {
3822 self.set_mark_color(color);
3823 return;
3824 }
3825 self.toggle(InlineKind::Mark);
3826 // The format may not spell a highlight at all (`toggle` said so), and
3827 // there is nothing to colour if it doesn't.
3828 if self.status.is_some() {
3829 return;
3830 }
3831 let before = self.revision;
3832 self.set_mark_color(color);
3833 // Only fold when the colour really spliced. `highlight(None)` over a
3834 // fresh highlight is a no-op by design, and coalescing there would eat
3835 // the *previous* edit into the toggle instead.
3836 if self.revision != before {
3837 let _ = self.editor.coalesce_last_undo();
3838 }
3839 }
3840
3841 /// Convert the block at the caret to a heading level or paragraph.
3842 pub fn set_block(&mut self, kind: BlockKind) {
3843 // The read-only gate — this door reaches twig without the splice.
3844 if self.read_only {
3845 return;
3846 }
3847 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3848 return;
3849 }
3850 self.record_caret();
3851 // A blank line has no node to convert, and twig opens a block there
3852 // rather than declining — so the caret's own offset is the right thing
3853 // to hand it when `block_offset_for_caret` finds nothing.
3854 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3855 match self.editor.set_block(offset, kind) {
3856 Ok(change) => {
3857 self.last_edit_kind = None;
3858 self.refresh();
3859 // Opening a block on a blank line writes a marker the caret
3860 // belongs *after*; converting an existing one moves nothing.
3861 self.caret = self.caret.max(change.new.end);
3862 self.clamp_caret();
3863 self.anchor = None;
3864 self.dirty = self.source != self.clean_source;
3865 self.status = None;
3866 self.record_caret();
3867 }
3868 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3869 }
3870 }
3871
3872 /// Whether `off` is inside a text block (paragraph, heading, code block…).
3873 fn has_block_at(&mut self, off: usize) -> bool {
3874 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3875 chain
3876 .iter()
3877 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3878 })
3879 }
3880
3881 /// The offset to hand twig's `set_block`: the caret when it is already inside
3882 /// a block, otherwise nudged onto the previous character (a caret at a line
3883 /// end sits at the doc level, outside the block). `None` when the caret is on
3884 /// a blank line — a new paragraph with no block node to convert.
3885 fn block_offset_for_caret(&mut self) -> Option<usize> {
3886 let caret = self.caret.min(self.source.len());
3887 if self.has_block_at(caret) {
3888 return Some(caret);
3889 }
3890 // Nudge to the previous character — but never across a newline: that would
3891 // target the previous block, and a blank line genuinely has no block.
3892 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3893 && ch != '\n'
3894 && self.has_block_at(i)
3895 {
3896 return Some(i);
3897 }
3898 None
3899 }
3900
3901 /// The heading level of the text block at the caret, or `None` when that
3902 /// block is not a heading.
3903 pub fn current_heading_level(&mut self) -> Option<u32> {
3904 let caret = self.caret;
3905 self.nodes()
3906 .into_iter()
3907 .filter(|n| n.kind == Kind::Heading)
3908 .find(|n| n.span.start <= caret && caret <= n.span.end)
3909 .and_then(|n| n.level)
3910 }
3911
3912 /// The inline marks in force at the caret (or over the selection) — what a
3913 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3914 /// inline counterpart. Cheap enough to call every frame: one twig
3915 /// `ancestors_at` query per caret (two with a selection), each walking root
3916 /// → deepest node at one offset. It never snapshots the tree the way
3917 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3918 /// the only allocation is twig's own small ancestor `Vec`.
3919 ///
3920 /// **A selection reports a mark only when the mark covers *all* of it.**
3921 /// That's what every real toolbar means by an active button — Bold lit over
3922 /// a half-bold selection would claim a press turns bold *off*, when
3923 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3924 /// Whole-coverage is asked as "is the same mark node standing over both the
3925 /// first and the last character?": inline nodes are contiguous, so one node
3926 /// covering both ends covers every byte between them. Two touching runs
3927 /// (`**a****b**`) are two nodes, and correctly light nothing.
3928 ///
3929 /// At a bare caret a mark is active when the caret stands inside the mark's
3930 /// span — `span.start <= caret < span.end`, delimiters included, which is
3931 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3932 /// opening `*` (2) through the last byte of the closing `**` (9) are all
3933 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3934 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3935 /// typing would actually land inside the marked run. The offset one past the
3936 /// mark (10) is the text after it and reports nothing, at the end of the
3937 /// buffer exactly as in the middle.
3938 pub fn active_inline_marks(&mut self) -> InlineMarks {
3939 let Some((start, end)) = self.selection() else {
3940 // The marks actually in force at the caret, flipped by any armed
3941 // sticky delta — so `⌘b` at a bare caret lights the Bold button
3942 // immediately, before a single character is typed.
3943 let base: InlineMarks = self
3944 .marks_at(self.caret)
3945 .into_iter()
3946 .map(|(k, _)| k)
3947 .collect();
3948 return base.xor(self.pending_here());
3949 };
3950 // The selection's *last character*, not its exclusive end: `end` is the
3951 // offset one past the selection, which for a selection ending exactly at
3952 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3953 // entirely bold, but offset 10 is the space after).
3954 let last = prev_boundary(&self.source, end);
3955 let head = self.marks_at(start);
3956 let tail = self.marks_at(last);
3957 head.into_iter()
3958 .filter(|m| tail.contains(m))
3959 .map(|(k, _)| k)
3960 .collect()
3961 }
3962
3963 /// The inline marks whose span covers `off`, each with the id of the node
3964 /// carrying it — the id is what lets a selection tell one mark node from
3965 /// another of the same kind.
3966 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3967 let off = off.min(self.source.len());
3968 self.editor
3969 .ancestors_at(off)
3970 .unwrap_or_default()
3971 .into_iter()
3972 // `span.end` is the offset one *past* the mark, so it isn't in it.
3973 // twig already resolves a boundary to whatever starts there — in
3974 // `**bold** x` offset 8 is the following text, not the strong — but
3975 // when nothing follows, the tie has nobody to break for and the
3976 // chain still ends at the mark. That would make the answer at the
3977 // last offset of the document depend on whether the file happens to
3978 // end in a newline; the rule is `span.start <= off < span.end`, and
3979 // it's the same rule at the end of a buffer as in the middle.
3980 .filter(|m| off < m.span.end)
3981 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3982 .collect()
3983 }
3984
3985 /// Toggle a heading at the caret: if the block is already this heading level,
3986 /// revert it to a paragraph; otherwise convert it to this heading level.
3987 /// This gives the heading commands the same toggle feel as bold/italic/code —
3988 /// re-applying a heading a line already has turns it back into body text.
3989 pub fn toggle_heading(&mut self, level: u32) {
3990 if self.current_heading_level() == Some(level) {
3991 self.set_block(BlockKind::Paragraph);
3992 } else {
3993 self.set_block(BlockKind::Heading(level));
3994 }
3995 }
3996
3997 /// Toggle a block quote around the selection, or around the block at the
3998 /// caret — the toolbar's Quote button.
3999 pub fn toggle_blockquote(&mut self) {
4000 self.toggle_container(BlockContainerKind::BlockQuote);
4001 }
4002
4003 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
4004 /// over the block at the caret — one op with the kind as a flag, the way
4005 /// `toggle_heading` takes its level, so a frontend needs no twig type to
4006 /// name the two buttons.
4007 ///
4008 /// Pressing the *other* list's button while in a list converts in place
4009 /// rather than nesting, so the pair reads as one three-state control
4010 /// (bulleted / numbered / neither) rather than two independent wrappers.
4011 pub fn toggle_list(&mut self, ordered: bool) {
4012 self.toggle_container(if ordered {
4013 BlockContainerKind::OrderedList
4014 } else {
4015 BlockContainerKind::BulletList
4016 });
4017 }
4018
4019 // ── Task list items ──────────────────────────────────────────────────────
4020 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
4021 // inline content of the item's first paragraph rather than part of its
4022 // marker, so adding or removing one must leave the item's continuation
4023 // indentation alone, and an item inside a quote is found past the quote
4024 // markers. leaf names the gesture and the offset; the spelling is twig's.
4025
4026 /// Whether the list item at the caret carries a checkbox, and which way it
4027 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
4028 /// item or no item at all. What a toolbar reads to light its checkbox button.
4029 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
4030 self.task_checked_at(self.caret)
4031 }
4032
4033 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
4034 /// offset — what a frontend asks before deciding a click landed on a box.
4035 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
4036 self.innermost_list_item(offset.min(self.source.len()))?
4037 .checked
4038 }
4039
4040 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
4041 /// A no-op with a reported reason when the caret is in no task item — minting
4042 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
4043 pub fn toggle_task_checked(&mut self) {
4044 self.toggle_task_at(self.caret);
4045 }
4046
4047 /// Tick or untick the task item covering `offset` — what a *click* on a
4048 /// rendered checkbox is. Separate from the caret form because a click carries
4049 /// its own offset and must not first move the caret there: ticking a box
4050 /// three paragraphs away should not take the cursor with it.
4051 pub fn toggle_task_at(&mut self, offset: usize) {
4052 // The read-only gate — this door reaches twig without the splice.
4053 if self.read_only {
4054 return;
4055 }
4056 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
4057 return;
4058 }
4059 let offset = offset.min(self.source.len());
4060 self.record_caret();
4061 match self.editor.toggle_task_checked(offset) {
4062 Ok(_) => self.after_task_edit(),
4063 Err(e) => self.status = Some(format!("task: {e}")),
4064 }
4065 }
4066
4067 /// Give the list item at the caret a checkbox, or take its checkbox away —
4068 /// the gesture that converts between a plain bullet and a task. A new box
4069 /// arrives unticked.
4070 pub fn toggle_task_item(&mut self) {
4071 // The read-only gate — this door reaches twig without the splice.
4072 if self.read_only {
4073 return;
4074 }
4075 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
4076 return;
4077 }
4078 let caret = self.caret.min(self.source.len());
4079 self.record_caret();
4080 match self.editor.toggle_task_item(caret) {
4081 Ok(_) => self.after_task_edit(),
4082 Err(e) => self.status = Some(format!("task: {e}")),
4083 }
4084 }
4085
4086 /// Settle after a task gesture. The caret rides its old byte offset and is
4087 /// clamped back in: a box is three or four bytes on the item's first line, so
4088 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
4089 /// real stop either way.
4090 fn after_task_edit(&mut self) {
4091 self.last_edit_kind = None;
4092 self.refresh();
4093 self.anchor = None;
4094 self.dirty = self.source != self.clean_source;
4095 self.status = None;
4096 self.clamp_caret();
4097 self.record_caret();
4098 }
4099
4100 // ── Tables ───────────────────────────────────────────────────────────────
4101 // A table is a grid, and twig edits it as one — add/remove/move a row or
4102 // column, set a column's alignment — re-spelling the whole table in a single
4103 // splice. Every gesture is anchored at the caret's cell. leaf just names the
4104 // gesture and re-reads the result; the whole table's numbering, borders, and
4105 // delimiter are twig's to keep straight.
4106
4107 /// Whether the caret is inside a table — what a frontend asks to enable or
4108 /// disable its table controls.
4109 ///
4110 /// An HTML `<table>` still answers `true`: the caret really is in a table,
4111 /// and the reason the grid controls stay dark there is
4112 /// [`Capabilities::table`], which is a fact about the document's format
4113 /// rather than about the caret. A frontend needs both.
4114 pub fn caret_in_table(&mut self) -> bool {
4115 let caret = self.caret.min(self.source.len());
4116 self.editor
4117 .ancestors_at(caret)
4118 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
4119 .unwrap_or(false)
4120 }
4121
4122 /// One grid op, guarded and settled — the shared body of the seven below.
4123 ///
4124 /// The guard is why this exists rather than seven copies of the same three
4125 /// lines, and it is the one guard leaf cannot delegate to twig. The table
4126 /// editor is the gesture family that consults no `Syntax` table (it spells a
4127 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
4128 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
4129 /// grid as a *pipe table* and reports success, swapping the element out for
4130 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
4131 /// downstream could tell that from a successful edit — the splice is real,
4132 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
4133 /// stopping at the door rather than detecting after the fact. See
4134 /// [`spells_pipe_tables`].
4135 fn table_op(
4136 &mut self,
4137 what: &str,
4138 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
4139 ) {
4140 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
4141 return;
4142 }
4143 self.record_caret();
4144 let at = self.caret;
4145 let r = op(&mut self.editor, at);
4146 self.apply_table(r, what);
4147 }
4148
4149 /// Insert an empty row below (`below`) or above the caret's row.
4150 pub fn table_insert_row(&mut self, below: bool) {
4151 self.table_op("table row", |e, at| e.table_insert_row(at, below));
4152 }
4153
4154 /// Delete the caret's row (not the header, not the last body row).
4155 pub fn table_delete_row(&mut self) {
4156 self.table_op("table row", |e, at| e.table_delete_row(at));
4157 }
4158
4159 /// Insert an empty column right (`right`) or left of the caret's column.
4160 pub fn table_insert_column(&mut self, right: bool) {
4161 self.table_op("table column", |e, at| e.table_insert_column(at, right));
4162 }
4163
4164 /// Delete the caret's column (unless it is the only one).
4165 pub fn table_delete_column(&mut self) {
4166 self.table_op("table column", |e, at| e.table_delete_column(at));
4167 }
4168
4169 /// Set the caret's column to `alignment`.
4170 pub fn table_set_alignment(&mut self, alignment: Alignment) {
4171 self.table_op("table alignment", |e, at| {
4172 e.table_set_alignment(at, alignment)
4173 });
4174 }
4175
4176 /// Move the caret's row one place down (`down`) or up, within the body rows.
4177 pub fn table_move_row(&mut self, down: bool) {
4178 self.table_op("table row", |e, at| e.table_move_row(at, down));
4179 }
4180
4181 /// Move the caret's column one place right (`right`) or left.
4182 pub fn table_move_column(&mut self, right: bool) {
4183 self.table_op("table column", |e, at| e.table_move_column(at, right));
4184 }
4185
4186 /// Settle the caret and document flags after a table op (or report its
4187 /// error). twig re-spells the whole table, so the caret rides its old byte
4188 /// offset and is clamped back into the rebuilt bytes — near enough to where
4189 /// it was, since the op preserves the cells' content and order around it.
4190 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
4191 match result {
4192 Ok(()) => {
4193 self.last_edit_kind = None;
4194 self.refresh();
4195 self.anchor = None;
4196 self.clamp_caret();
4197 self.dirty = self.source != self.clean_source;
4198 self.status = None;
4199 self.record_caret();
4200 }
4201 Err(e) => self.status = Some(format!("{what}: {e}")),
4202 }
4203 }
4204
4205 /// One `toggle_block_container` over the block-level target.
4206 ///
4207 /// leaf says *where*; twig decides everything else — which blocks the range
4208 /// covers, whether that means wrapping, unwrapping, nesting or converting,
4209 /// and how this document's format spells the prefix. The rule that a
4210 /// container only comes off when the range covers every block it holds is
4211 /// what the re-anchoring below is built around.
4212 fn toggle_container(&mut self, kind: BlockContainerKind) {
4213 // The read-only gate — this door reaches twig without the splice.
4214 if self.read_only {
4215 return;
4216 }
4217 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
4218 return;
4219 }
4220 let selected = self.selection();
4221 // A blank line holds no block, and twig opens an *empty* container on one
4222 // — since 3.2.0; it used to decline the range with `NotFound`, which is
4223 // why this used to lend it a scratch paragraph to wrap. Worth knowing
4224 // here because the line-for-line caret mapping below cannot describe it:
4225 // opening one under a paragraph writes the blank line the format needs
4226 // above the marker too, so the rewritten region has a line the old one
4227 // didn't, and "the same line, the same distance from its end" lands on
4228 // that new blank instead of in the container.
4229 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
4230 // Without a selection the target is the caret's own block, resolved the
4231 // way `set_block` resolves it — a caret at a line end sits at the doc
4232 // level and has to be nudged back onto the block it looks like it's in.
4233 // An empty range is enough: twig widens to the whole lines it touches.
4234 let (start, end) = match selected {
4235 Some(range) => range,
4236 None => {
4237 let off = self.block_offset_for_caret().unwrap_or(self.caret);
4238 (off, off)
4239 }
4240 };
4241 self.record_caret();
4242 match self.editor.toggle_block_container(start, end, kind) {
4243 Ok(change) => {
4244 // Read the caret's place out of the *pre-edit* source, before
4245 // `refresh` swaps that source out from under it.
4246 let place = (selected.is_none() && !opened_empty)
4247 .then(|| self.caret_line_tail(&change.old));
4248 self.last_edit_kind = None; // structural edit is its own undo step
4249 self.refresh();
4250 match place {
4251 // Both land the caret at the far end of what twig wrote, and
4252 // differ only in what they leave selected.
4253 //
4254 // From a selection: select what the container now holds, the
4255 // way `toggle` keeps its marked region selected — and for a
4256 // stronger reason than symmetry: a container comes *off* only
4257 // a range covering every block it holds, so a selection left
4258 // on its old bytes (now short by a prefix per line) would nest
4259 // on the second press instead of reversing the first.
4260 //
4261 // From a blank line: nothing to select, and the end of the
4262 // region is exactly past the bare `> ` / `- ` twig wrote —
4263 // the caret standing inside the container that was asked for.
4264 None => {
4265 self.anchor = (!opened_empty).then_some(change.new.start);
4266 self.caret = change.new.end;
4267 }
4268 Some(place) => {
4269 self.anchor = None;
4270 self.caret = self.line_tail_offset(&change.new, place);
4271 }
4272 }
4273 self.dirty = self.source != self.clean_source;
4274 self.status = None;
4275 self.clamp_caret();
4276 self.record_caret();
4277 }
4278 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4279 }
4280 }
4281
4282 /// The caret's place inside the region a container toggle is rewriting, in
4283 /// the only terms the rewrite preserves: which of the region's lines it sits
4284 /// on, and how many bytes of that line lie ahead of it.
4285 ///
4286 /// A container's markup goes in at column 0 and never touches what follows
4287 /// on the line, so that pair survives the edit exactly where a byte offset
4288 /// does not — a caret left on its old offset slides back by one prefix per
4289 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
4290 /// `> ` it just asked for.
4291 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
4292 let caret = self.caret.clamp(old.start, old.end);
4293 let line = self.source[old.start..caret].matches('\n').count();
4294 let end = self.source[caret..old.end]
4295 .find('\n')
4296 .map_or(old.end, |i| caret + i);
4297 (line, end - caret)
4298 }
4299
4300 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
4301 /// region: the offset `tail` bytes back from the end of the region's `line`.
4302 ///
4303 /// Both walks are clamped rather than trusted, because the one op that does
4304 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
4305 /// the items back apart with blank lines between them — and a caret landing
4306 /// on the nearest line of the right item beats one landing out of the region
4307 /// entirely.
4308 fn line_tail_offset(
4309 &self,
4310 new: &std::ops::Range<usize>,
4311 (line, tail): (usize, usize),
4312 ) -> usize {
4313 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4314 let mut start = 0;
4315 for _ in 0..line {
4316 match region[start..].find('\n') {
4317 Some(i) => start += i + 1,
4318 None => break,
4319 }
4320 }
4321 let end = region[start..]
4322 .find('\n')
4323 .map_or(region.len(), |i| start + i);
4324 new.start + end.saturating_sub(tail).max(start)
4325 }
4326
4327 /// Link the selection to `destination` — the toolbar's Link button. With no
4328 /// selection it acts at the caret, which re-points a link the caret is
4329 /// already standing in (twig replaces an existing link's destination and
4330 /// keeps its text) and otherwise spells a link that has no text of its own:
4331 /// an autolink (`<https://x.dev>`) where the destination is one, and
4332 /// `[destination](destination)` where it isn't.
4333 ///
4334 /// `destination` reaches twig raw. Escaping it is format knowledge and the
4335 /// two formats genuinely disagree — Markdown ends a destination at the first
4336 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4337 /// itself — so the side holding the document is the side that gets to spell
4338 /// it. A destination twig can't carry at all (one with a newline) comes back
4339 /// as an error rather than a quietly rewritten URL.
4340 pub fn insert_link(&mut self, destination: &str) {
4341 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
4342 return;
4343 }
4344 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4345 self.record_caret();
4346 match self.editor.insert_link(start, end, destination) {
4347 Ok(change) => {
4348 self.last_edit_kind = None;
4349 self.refresh();
4350 match self.link_text_span(change.new.start) {
4351 // A link with text of its own: select it, so typing replaces
4352 // a `[dest](dest)`'s stand-in label and a second press
4353 // re-points what the first one linked.
4354 Some(text) => {
4355 self.anchor = (text.start != text.end).then_some(text.start);
4356 self.caret = text.end;
4357 }
4358 // An autolink is finished the moment it's written — its text
4359 // *is* the URL. Leaving it selected would aim the next press
4360 // at the one shape twig still wraps instead of re-points.
4361 None => {
4362 self.anchor = None;
4363 self.caret = change.new.end;
4364 }
4365 }
4366 self.dirty = self.source != self.clean_source;
4367 self.status = None;
4368 self.clamp_caret();
4369 self.record_caret();
4370 }
4371 Err(e) => self.status = Some(format!("link: {e}")),
4372 }
4373 }
4374
4375 /// Insert a block-level image at the caret: ``. Any
4376 /// selection becomes the alt text (so "select a caption, insert image" labels
4377 /// it); with no selection, `alt` is used — empty for none. The caret lands
4378 /// just past the inserted image.
4379 ///
4380 /// Both halves go through twig (`insert_literal` for the alt text,
4381 /// `insert_image` for the image), so neither is spelled here. That used to be a
4382 /// `format!`, and it was wrong the first time an app inserted a real filename:
4383 /// Markdown ends a destination at the first space, so `` is
4384 /// not an image at all — and the fix is per-format, since moving into the
4385 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4386 pub fn insert_image(&mut self, destination: &str, alt: &str) {
4387 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
4388 return;
4389 }
4390 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4391 self.record_caret();
4392 // With no selection and an explicit `alt`, the alt text has to exist in the
4393 // document before it can be the image's — and it is raw caller input, so
4394 // it goes in through `insert_literal`, which escapes it for the format
4395 // rather than letting a `]` in someone's caption close the image early.
4396 let (start, end) = if start == end && !alt.is_empty() {
4397 match self.editor.insert_literal(start, alt) {
4398 Ok(change) => (change.new.start, change.new.end),
4399 Err(e) => {
4400 self.status = Some(format!("image: {e}"));
4401 return;
4402 }
4403 }
4404 } else {
4405 (start, end)
4406 };
4407 match self.editor.insert_image(start, end, destination) {
4408 Ok(change) => {
4409 self.last_edit_kind = None;
4410 self.refresh();
4411 // Just past the image, nothing selected — where a caret belongs
4412 // after inserting one.
4413 self.anchor = None;
4414 self.caret = change.new.end;
4415 self.dirty = self.source != self.clean_source;
4416 self.status = None;
4417 self.clamp_caret();
4418 self.record_caret();
4419 }
4420 Err(e) => self.status = Some(format!("image: {e}")),
4421 }
4422 }
4423
4424 /// Insert a block-level image, video, or audio at the caret. The image case
4425 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
4426 /// HTML elements, which is the only spelling Markdown and Djot have for them:
4427 ///
4428 /// ```text
4429 /// <video src="clip.mp4" controls>alt</video>
4430 /// <audio src="take.mp3" controls>alt</audio>
4431 /// ```
4432 ///
4433 /// HTML rather than a `::video{…}` directive deliberately. A directive means
4434 /// something only to an app that knows the vocabulary, so the document would
4435 /// read as literal punctuation everywhere else; `<video>` is what every other
4436 /// renderer already understands, and what leaf's own reader picks back up
4437 /// through `html_elements` promotion (see [`parse_extensions`]).
4438 ///
4439 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
4440 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
4441 /// `html_elements`. Before that only the multi-line form parsed as a block at
4442 /// all, and this wrote three lines to work around it.
4443 ///
4444 /// `controls` is always written: a player with no transport is a still frame
4445 /// the reader can't do anything with. Any selection becomes the element's
4446 /// fallback text, exactly as it becomes an image's alt.
4447 ///
4448 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
4449 /// applies, and bites harder here: a `"` in `destination` closes the
4450 /// attribute. A frontend taking these from a file picker is fine; one taking
4451 /// them from free text should keep them tame.
4452 ///
4453 /// [`MediaInfo`]: crate::MediaInfo
4454 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
4455 if kind == MediaKind::Image {
4456 return self.insert_image(destination, alt);
4457 }
4458 // Gated on the *image* gesture, not on one of its own — there isn't one,
4459 // since the bytes below are spelled here rather than by twig, and an HTML
4460 // document would in fact parse them. The button is one control with three
4461 // kinds behind it, and two of them working in a format where the third
4462 // cannot is a worse surface than three that agree — especially as
4463 // `insert_image` is the kind anyone reaches for first.
4464 if self.refuse_unsupported("media", Gesture::InsertImage) {
4465 return;
4466 }
4467 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4468 let alt_text = self
4469 .selected_text()
4470 .map(str::to_string)
4471 .unwrap_or_else(|| alt.to_string());
4472 let tag = match kind {
4473 MediaKind::Audio => "audio",
4474 _ => "video",
4475 };
4476 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
4477 self.edit(start, end, &markup);
4478 }
4479
4480 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
4481 /// button. Spelling and placement are both twig's; leaf used to write `---`
4482 /// itself, which was the Markdown spelling in a djot document too.
4483 ///
4484 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
4485 /// mid-paragraph and lands it after the caret's whole block. To get a rule
4486 /// *at* the caret — the paragraph parted in two around it, which is what a
4487 /// rule button is understood to do — the paragraph is first divided with
4488 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
4489 /// the offset `split_block` returns puts the rule after the *second* half
4490 /// instead, which is a rule in the right document and the wrong place.
4491 ///
4492 /// Only a plain paragraph is split. Everywhere else the rule simply lands
4493 /// after the block, which is both twig's own answer and the better one:
4494 /// splitting a fenced code block would leave two fences with a rule between
4495 /// them, and splitting a list item would mint an item nobody asked for on the
4496 /// way to a rule that lands after the list regardless. A table and a setext
4497 /// heading refuse the split outright, so they take the same path by
4498 /// themselves.
4499 pub fn insert_thematic_break(&mut self) {
4500 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
4501 {
4502 return;
4503 }
4504 self.caret = self.skip_trailing_close_delims(self.caret);
4505 // A selection is replaced by the rule, so collapse it first and let the
4506 // split-and-rule below run from the caret it leaves behind.
4507 if let Some((s, e)) = self.selection() {
4508 self.splice(s, e, "", EditKind::Other);
4509 }
4510 self.anchor = None;
4511 self.record_caret();
4512 let at = self.caret;
4513 if self.caret_in_bare_paragraph() {
4514 // A failure here is not fatal: the rule still lands after the block,
4515 // which is exactly what this call was trying to improve on.
4516 let _ = self.editor.split_block(at);
4517 }
4518 match self.editor.insert_thematic_break(at) {
4519 Ok(change) => {
4520 self.last_edit_kind = None;
4521 self.refresh();
4522 self.anchor = None;
4523 self.caret = change.new.end;
4524 self.dirty = self.source != self.clean_source;
4525 self.status = None;
4526 self.clamp_caret();
4527 self.record_caret();
4528 }
4529 Err(e) => self.status = Some(format!("thematic break: {e}")),
4530 }
4531 }
4532
4533 /// Whether the caret sits in a paragraph and nothing else — no list item, no
4534 /// quote, no fence, no table. The one shape where parting the block around
4535 /// the caret is unambiguously what a rule button means; see
4536 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4537 /// container is left to take the rule after itself.
4538 fn caret_in_bare_paragraph(&mut self) -> bool {
4539 let caret = self.caret.min(self.source.len());
4540 let Ok(chain) = self.editor.ancestors_at(caret) else {
4541 return false;
4542 };
4543 let mut in_para = false;
4544 for m in chain {
4545 match m.kind {
4546 Kind::Para => in_para = true,
4547 Kind::ListItem
4548 | Kind::TaskListItem
4549 | Kind::BlockQuote
4550 | Kind::CodeBlock
4551 | Kind::Table => return false,
4552 _ => {}
4553 }
4554 }
4555 in_para
4556 }
4557
4558 /// The destination of the link under the caret — what a Link prompt shows so
4559 /// ⌘K on an existing link edits its URL instead of asking for it again.
4560 /// `None` when the caret stands in no link.
4561 ///
4562 /// An autolink carries no separate destination: its text *is* the URL, so
4563 /// that's what comes back for one.
4564 pub fn link_destination_at_caret(&mut self) -> Option<String> {
4565 self.link_destination_at(self.caret)
4566 }
4567
4568 /// The destination of the link at `off`.
4569 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4570 /// the caret isn't.
4571 ///
4572 /// The offset form exists for the same reason
4573 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4574 /// the document somewhere else — a footnote's text in a popover, say — has
4575 /// rows and runs but no caret in them, and still needs to know which of those
4576 /// runs a reader can follow.
4577 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4578 self.nodes()
4579 .into_iter()
4580 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4581 .filter(|n| n.span.start <= off && off < n.span.end)
4582 .max_by_key(|n| n.span.start)
4583 .and_then(|n| n.destination.or(n.text))
4584 }
4585
4586 /// Where the locator `id` lands in this document — the `#v2` half of a
4587 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4588 /// answers to it.
4589 ///
4590 /// The other end of a link, and the reason this exists: without it a
4591 /// destination has only file granularity, so following a citation into a
4592 /// chapter drops the reader at the top of it to hunt for the verse. Which is
4593 /// also why it is a *document* query rather than a caret one — the document
4594 /// being asked is usually not the one the reader is in.
4595 ///
4596 /// Three readings, tried in order, because the same `#some-heading` is
4597 /// written three ways across the formats leaf opens:
4598 ///
4599 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4600 /// the auto-ids djot mints for its headings. The only exact answer, so it
4601 /// goes first — a document that says `{#v1}` has settled the question.
4602 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4603 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
4604 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
4605 /// authored anywhere land on a djot heading.
4606 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4607 /// none and `{#custom}` is literal text in a Markdown heading — so for
4608 /// the format most vaults are written in, the heading's own words are the
4609 /// only thing a fragment can name. This is the rule every Markdown
4610 /// renderer already follows, which is what makes `#a-heading` mean in
4611 /// diaryx what it means on the web.
4612 ///
4613 /// Ties go to the earliest match, then to the widest: a duplicated id is the
4614 /// document's mistake and the first one is the answer every anchor
4615 /// implementation gives, while preferring the wider span picks the section
4616 /// over the heading that opens it — more for a peek to show, same place to
4617 /// land.
4618 pub fn locate(&mut self, id: &str) -> Option<Landing> {
4619 let id = id.trim();
4620 if id.is_empty() {
4621 return None;
4622 }
4623 let nodes = self.nodes();
4624
4625 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4626 // is picking, among nodes that start together, the one that ends last.
4627 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4628 matches
4629 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4630 .map(|n| Landing {
4631 start: n.span.start,
4632 end: n.span.end,
4633 })
4634 };
4635
4636 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4637 return Some(landing);
4638 }
4639 let want = slug(id);
4640 if want.is_empty() {
4641 return None;
4642 }
4643 if let Some(landing) = pick(
4644 &mut nodes
4645 .iter()
4646 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4647 ) {
4648 return Some(landing);
4649 }
4650
4651 // A heading by its words. Its span is one line, so the end comes from
4652 // where the *section* it opens gives out — the next heading that is not
4653 // under it, or the end of the document. A Markdown heading has no
4654 // section node to ask (twig only builds those for djot), and a peek that
4655 // showed the heading alone would answer "what does that say" with the
4656 // title of the thing it says.
4657 let heading = nodes
4658 .iter()
4659 .filter(|n| n.kind == Kind::Heading)
4660 .filter(|n| {
4661 n.content_span
4662 .clone()
4663 .and_then(|s| self.source.get(s))
4664 .is_some_and(|text| slug(text) == want)
4665 })
4666 .min_by_key(|n| n.span.start)?;
4667 let level = heading.level.unwrap_or(u32::MAX);
4668 let end = nodes
4669 .iter()
4670 .filter(|n| n.kind == Kind::Heading)
4671 .filter(|n| n.span.start > heading.span.start)
4672 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4673 .map(|n| n.span.start)
4674 .min()
4675 .unwrap_or(self.source.len());
4676 Some(Landing {
4677 start: heading.span.start,
4678 end,
4679 })
4680 }
4681
4682 /// Write a footnote at the caret — the toolbar's Footnote button, and the
4683 /// one gesture in the footnote story that *authors* rather than follows.
4684 ///
4685 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4686 /// the `[^1]:` definition at the end of the document. Half a footnote is not
4687 /// a footnote — a bare reference with nothing defining it renders as literal
4688 /// brackets — so a single button that wrote only the reference would leave
4689 /// the author to hand-spell the other half in a document that had just
4690 /// stopped showing them what the first half meant. One edit also means one
4691 /// undo takes both back.
4692 ///
4693 /// The definition's body is left empty and **the caret lands in it**, which
4694 /// is the whole point of pressing the button: nobody wants a reference to a
4695 /// note they have not written yet. Getting back to where they were writing
4696 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4697 /// the same return leg a reader following a reference already uses, so the
4698 /// author is left standing on the near end of a round trip that works.
4699 ///
4700 /// A selection collapses to its *end* rather than being replaced: a
4701 /// reference annotates the words before it, so "select the claim, add a
4702 /// footnote" should mark that claim, not consume it.
4703 pub fn insert_footnote(&mut self) {
4704 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4705 return;
4706 }
4707 let at = self.selection().map_or(self.caret, |(_, end)| end);
4708 self.anchor = None;
4709 self.caret = at;
4710 self.record_caret();
4711 let label = self.next_footnote_label();
4712 match self.editor.insert_footnote(at, &label) {
4713 Ok(change) => {
4714 self.last_edit_kind = None;
4715 self.refresh();
4716 self.anchor = None;
4717 // `change.new` runs from the reference to the end of the
4718 // document, so its start is the `[^1]` just written and
4719 // `footnote_at` resolves it to the note the same way a reader's
4720 // tap does — and to the note's *body*, which is already a caret
4721 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4722 // and has none), so this needs no snap on top. The fallback is
4723 // the reference's own offset: a format that spelled the pair some
4724 // way leaf can't read back should still leave the caret on the
4725 // edit rather than at the far end of a document it just grew.
4726 self.caret = self
4727 .footnote_at(change.new.start)
4728 .and_then(|note| note.offset)
4729 .unwrap_or(change.new.start);
4730 self.dirty = self.source != self.clean_source;
4731 self.status = None;
4732 self.clamp_caret();
4733 self.record_caret();
4734 }
4735 Err(e) => self.status = Some(format!("footnote: {e}")),
4736 }
4737 }
4738
4739 /// The label to give a footnote the author has not named: the lowest counting
4740 /// number no footnote in the document is already wearing.
4741 ///
4742 /// twig takes the label rather than minting one, because it holds no opinion
4743 /// about what a document's footnotes should be called — and it is right not
4744 /// to. Numbering them is what every author of a numbered note expects, and
4745 /// re-using a taken number would silently point the new reference at somebody
4746 /// else's note (twig reuses an existing definition rather than appending a
4747 /// second one, which is the right rule for citing a note twice on purpose and
4748 /// exactly the wrong accident to have by default).
4749 ///
4750 /// *References* are counted alongside definitions, not just definitions: a
4751 /// document carrying a dangling `[^2]` has a 2 that means something to
4752 /// whoever wrote it, and minting a definition for it here would answer a
4753 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4754 /// count entirely — they take no number, so they block none.
4755 fn next_footnote_label(&mut self) -> String {
4756 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4757 .into_iter()
4758 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4759 .filter_map(|label| label.parse().ok())
4760 .collect();
4761 taken.extend(
4762 self.nodes()
4763 .into_iter()
4764 .filter(|n| n.kind == Kind::FootnoteReference)
4765 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4766 .filter_map(|label| label.parse::<u32>().ok()),
4767 );
4768 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4769 }
4770
4771 /// The footnote reference under the caret, resolved to the note it names.
4772 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4773 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4774 self.footnote_at(self.caret)
4775 }
4776
4777 /// The footnote reference at `off`, resolved to the note it names — what a
4778 /// frontend shows when a reader activates a `[^1]`.
4779 ///
4780 /// A reference is not a link node, so
4781 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4782 /// (and should not) answer for one: a link names a destination to leave for,
4783 /// a reference names a note that is already in this document. Following one
4784 /// is a move within the page, which is why this hands back an `offset`
4785 /// rather than something to open.
4786 ///
4787 /// Offset-based rather than caret-only because the gesture that wants this
4788 /// most is the one that must not move the caret: a pointer hovering a `[1]`
4789 /// asks what note it names without disturbing where the reader was typing.
4790 /// The caret is just the offset a click already placed —
4791 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4792 ///
4793 /// `None` when `off` stands in no reference. A reference whose note the
4794 /// document never defines is *not* `None` — it answers with the label it
4795 /// looked for and no text, which is what lets a frontend say so instead of
4796 /// silently doing nothing.
4797 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4798 // Innermost-wins by latest start, the rule its link sibling uses.
4799 let span = self
4800 .nodes()
4801 .into_iter()
4802 .filter(|n| n.kind == Kind::FootnoteReference)
4803 .filter(|n| n.span.start <= off && off < n.span.end)
4804 .max_by_key(|n| n.span.start)?
4805 .span;
4806 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4807
4808 // The note itself. Definitions are roots beside `doc` rather than
4809 // children of it, so they're asked for directly — see
4810 // `wysiwyg::footnote_definitions`.
4811 let note = wysiwyg::footnote_definitions(&mut self.editor)
4812 .into_iter()
4813 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4814 let Some(note) = note else {
4815 return Some(FootnoteRef {
4816 label,
4817 text: None,
4818 offset: None,
4819 end: None,
4820 });
4821 };
4822 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4823 Some(FootnoteRef {
4824 label,
4825 text: body
4826 .clone()
4827 .and_then(|b| self.source.get(b))
4828 .map(str::to_string),
4829 // The body's start, not the definition's — see `FootnoteRef::offset`.
4830 offset: body.clone().map(|b| b.start),
4831 end: body.map(|b| b.end),
4832 })
4833 }
4834
4835 /// The footnote *definition* the caret stands in, and where the reference
4836 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4837 /// at the caret's offset.
4838 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4839 self.footnote_definition_at(self.caret)
4840 }
4841
4842 /// The footnote definition spanning `off`, and where the reference that
4843 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4844 ///
4845 /// The mirror image, deliberately: the same gesture that takes a reader from
4846 /// `[1]` down to the note takes them from the note back up to `[1]`, so
4847 /// following a footnote is a round trip rather than a fall. It needs no
4848 /// memory of how the reader arrived — the document says where the reference
4849 /// is — which is what makes it work for a reader who scrolled to the notes
4850 /// themselves, and what keeps it right after an edit moves either end.
4851 ///
4852 /// `None` when `off` stands in no definition. A definition nothing cites is
4853 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4854 /// its label and no offset, so a frontend can say "nothing refers to this"
4855 /// rather than offer a jump that goes nowhere.
4856 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4857 // Definitions are roots beside `doc`, so `nodes()` — which walks the
4858 // document body — never reports one. They're asked for directly, the way
4859 // `footnote_at` asks for the note it resolves to.
4860 //
4861 // Closed at the end, unlike the half-open test its neighbours use. A
4862 // definition's span stops at its last content byte — the newline ending
4863 // the line is outside it — so `span.end` is the caret stop at the end of
4864 // the note's own row, not the first byte of anything after. Excluding it
4865 // meant the one caret an author is guaranteed to have, the one left
4866 // sitting at the end of the note they just typed, was in no definition at
4867 // all: writing a note and then asking to go back to its reference
4868 // answered nothing. Two definitions in a row still can't both match —
4869 // there is a blank line between them — and `max_by_key` decides anyway.
4870 let note = wysiwyg::footnote_definitions(&mut self.editor)
4871 .into_iter()
4872 .filter(|m| m.span.start <= off && off <= m.span.end)
4873 .max_by_key(|m| m.span.start)?;
4874 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4875
4876 // The earliest reference carrying this label. `min` rather than a `find`,
4877 // because `nodes()` reports a flattened walk whose order is twig's
4878 // business, not document order. Bound first: the walk needs `&mut self`
4879 // and reading the labels back out needs `&self.source`.
4880 let nodes = self.nodes();
4881 let offset = nodes
4882 .into_iter()
4883 .filter(|n| n.kind == Kind::FootnoteReference)
4884 .filter(|n| {
4885 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4886 })
4887 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4888 .map(|n| n.span.start + 2)
4889 .min();
4890 Some(FootnoteDef { label, offset })
4891 }
4892
4893 /// The destination of the image under the caret — what an image prompt shows
4894 /// so editing an existing image starts from its current URL instead of blank,
4895 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4896 /// `None` when the caret stands in no image. A caret resting just after a
4897 /// block image (its trailing stop) is still "in" it — the half-open span test
4898 /// excludes that offset, which is the intended precision: past the image is
4899 /// past it.
4900 pub fn image_destination_at_caret(&mut self) -> Option<String> {
4901 let off = self.caret;
4902 self.nodes()
4903 .into_iter()
4904 .filter(|n| n.kind == Kind::Image)
4905 .filter(|n| n.span.start <= off && off < n.span.end)
4906 .max_by_key(|n| n.span.start)
4907 .and_then(|n| n.destination)
4908 }
4909
4910 /// The language of the fenced code block the caret stands in — what a
4911 /// language prompt shows so editing it starts from the current value rather
4912 /// than blank. `None` when the caret is in no code block, or in one whose
4913 /// fence carries no language (or an indented block, which has no fence).
4914 pub fn code_language_at_caret(&mut self) -> Option<String> {
4915 let start = self.code_block_start_at_caret()?;
4916 wysiwyg::code_language(&self.source, start)
4917 }
4918
4919 /// Whether the caret stands in a fenced code block — the one a language
4920 /// prompt could edit. A frontend gates its "set language" affordance on this
4921 /// (an indented block, which can't carry a language, reports `false`).
4922 pub fn caret_in_fenced_code(&mut self) -> bool {
4923 self.code_block_start_at_caret()
4924 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4925 }
4926
4927 /// Set (or clear, with `""`) the language of the fenced code block the caret
4928 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4929 /// block, and a reported error for a language the format's fence cannot
4930 /// carry.
4931 ///
4932 /// twig rewrites the info string, so the fence's own width — measured
4933 /// against a body neither side touches — is kept, and a language holding a
4934 /// space, a line end or the fence character is refused rather than written
4935 /// out to reparse as something else. Leaf used to splice over the info span
4936 /// itself and `trim()` the input, which handled the one bad case it had
4937 /// thought of.
4938 pub fn set_code_language(&mut self, lang: &str) {
4939 // The read-only gate — this door reaches twig without the splice.
4940 if self.read_only {
4941 return;
4942 }
4943 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4944 return;
4945 }
4946 if self.code_block_start_at_caret().is_none() {
4947 return;
4948 }
4949 let lang = lang.trim();
4950 // `None` clears the info string; `Some("")` asks for an empty one. Both
4951 // write a bare fence, and the prompt's empty value means "clear".
4952 let want = (!lang.is_empty()).then_some(lang);
4953 self.record_caret();
4954 match self.editor.set_code_language(self.caret, want) {
4955 Ok(_) => {
4956 self.last_edit_kind = None;
4957 self.refresh();
4958 self.anchor = None;
4959 self.dirty = self.source != self.clean_source;
4960 self.status = None;
4961 self.clamp_caret();
4962 self.record_caret();
4963 }
4964 Err(e) => self.status = Some(format!("code language: {e}")),
4965 }
4966 }
4967
4968 /// The `span.start` of the code block covering the caret — the anchor
4969 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4970 /// in none.
4971 fn code_block_start_at_caret(&mut self) -> Option<usize> {
4972 let off = self.caret;
4973 self.nodes()
4974 .into_iter()
4975 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4976 .max_by_key(|n| n.span.start)
4977 .map(|n| n.span.start)
4978 }
4979
4980 /// The source range of the text inside the link covering `off` — what sits
4981 /// between its `[` and `]`. `None` when twig reports no link there.
4982 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4983 self.nodes()
4984 .into_iter()
4985 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4986 // the other's `span.start`; the link that starts latest at or before
4987 // `off` is the one `off` is actually in.
4988 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4989 .max_by_key(|n| n.span.start)
4990 .and_then(|n| n.content_span)
4991 }
4992
4993 // ── undo / redo ───────────────────────────────────────────────────────────
4994 // twig owns the history of *bytes* (it owns the buffer) and now carries the
4995 // caret through it too: `record_caret` stashes each state's caret in twig's
4996 // opaque per-step blob, and undo/redo hand it back with the source they
4997 // restore. So leaf keeps no history of its own — no parallel stacks to march
4998 // in lockstep and silently drift out of it.
4999
5000 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
5001 /// where they were when that step began.
5002 pub fn undo(&mut self) {
5003 if self.read_only {
5004 return;
5005 }
5006 let (undone, redoable) = (self.undo_steps, self.redo_steps);
5007 match self.editor.undo() {
5008 Ok(Some(change)) => {
5009 self.after_history(change);
5010 // `refresh` counted the restore as an edit; it was a step back.
5011 self.undo_steps = undone.saturating_sub(1);
5012 self.redo_steps = redoable + 1;
5013 }
5014 Ok(None) => {
5015 self.undo_steps = 0;
5016 self.status = Some("nothing to undo".into());
5017 }
5018 Err(e) => self.status = Some(format!("undo: {e}")),
5019 }
5020 }
5021
5022 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
5023 /// selection back where that step originally left them.
5024 pub fn redo(&mut self) {
5025 if self.read_only {
5026 return;
5027 }
5028 let (undone, redoable) = (self.undo_steps, self.redo_steps);
5029 match self.editor.redo() {
5030 Ok(Some(change)) => {
5031 self.after_history(change);
5032 // `refresh` counted the restore as an edit; it was a step forward.
5033 self.undo_steps = undone + 1;
5034 self.redo_steps = redoable.saturating_sub(1);
5035 }
5036 Ok(None) => {
5037 self.redo_steps = 0;
5038 self.status = Some("nothing to redo".into());
5039 }
5040 Err(e) => self.status = Some(format!("redo: {e}")),
5041 }
5042 }
5043
5044 /// Refresh the cached source and put the caret back where the step being
5045 /// undone/redone had it, clearing any active run.
5046 ///
5047 /// The caret comes from twig's blob for the restored state (what
5048 /// `record_caret` stored). `change` is only the fallback for a state with no
5049 /// blob — a caret at the end of the restored text, which is where this always
5050 /// landed before the blobs were kept. It is the edit site, not where the user
5051 /// was standing, so it's a floor and not the behaviour: undoing should hand
5052 /// back the document *and* the place you were working, which for an edit made
5053 /// anywhere but under the caret are two different places.
5054 fn after_history(&mut self, change: Change) {
5055 self.refresh();
5056 match self
5057 .editor
5058 .caret_blob()
5059 .ok()
5060 .and_then(|b| CaretState::from_blob(&b))
5061 {
5062 Some(state) => {
5063 self.caret = state.caret.min(self.source.len());
5064 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
5065 }
5066 None => {
5067 self.caret = change.new.end.min(self.source.len());
5068 self.anchor = None;
5069 }
5070 }
5071 self.goal_col = None;
5072 self.last_edit_kind = None;
5073 self.dirty = self.source != self.clean_source;
5074 self.status = None;
5075 self.clamp_caret();
5076 }
5077
5078 // ── the file ──────────────────────────────────────────────────────────────
5079
5080 #[cfg(feature = "fs")]
5081 pub fn save(&mut self) {
5082 if self.is_untitled() {
5083 // No path to write and no name to invent: ⌘S on an untitled document
5084 // is a Save As, and only a frontend has a picker to ask with. Say so
5085 // rather than failing at the filesystem with an empty path.
5086 self.status = Some("untitled — save as…".into());
5087 return;
5088 }
5089 let path = self.path.clone();
5090 if self.write(&path) {
5091 self.mark_saved();
5092 }
5093 }
5094
5095 /// Save As: write the document to `path` and *move* it there — `self.path`
5096 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
5097 /// what Save As means; a copy would leave the user editing a document whose
5098 /// name is no longer where their keystrokes go.
5099 ///
5100 /// The move only happens if the bytes actually landed. A failed write leaves
5101 /// the path, `dirty`, and the disk watermark exactly as they were, with the
5102 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
5103 /// must never come away believing it was saved.
5104 ///
5105 /// An existing `path` is overwritten, and the caller is the one that knows
5106 /// whether to ask first: a Save As picker has already run that prompt, and a
5107 /// second confirmation from down here would be the same question twice.
5108 ///
5109 /// `format` does **not** follow the new extension. The buffer is parsed as
5110 /// the format it was opened with, and re-reading it as another one is a
5111 /// conversion — a different, lossy operation that would throw away the undo
5112 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
5113 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
5114 /// until it's reopened.
5115 #[cfg(feature = "fs")]
5116 pub fn save_as(&mut self, path: PathBuf) {
5117 if !self.write(&path) {
5118 return;
5119 }
5120 self.path = path;
5121 self.mark_saved();
5122 }
5123
5124 /// Put `source` on disk at `path`, reporting whether it got there. The one
5125 /// place leaf writes a document, so a save and a Save As can't disagree
5126 /// about what a failure looks like.
5127 #[cfg(feature = "fs")]
5128 fn write(&mut self, path: &Path) -> bool {
5129 match std::fs::write(path, self.source.as_bytes()) {
5130 Ok(()) => true,
5131 Err(e) => {
5132 self.status = Some(format!("save failed: {e}"));
5133 false
5134 }
5135 }
5136 }
5137
5138 /// Re-base the document's saved watermark to the current bytes: clears
5139 /// `dirty`, records `source` as the new clean state (so undoing back to here
5140 /// clears the flag again), and re-stamps the on-disk hash.
5141 ///
5142 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
5143 /// the hook a **filesystem-free host** calls itself once it has persisted
5144 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
5145 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
5146 /// are already where that host wants them, and this just tells the model they
5147 /// are safe.
5148 pub fn mark_saved(&mut self) {
5149 self.clean_source = self.source.clone();
5150 self.dirty = false;
5151 // The bytes on disk are now ours, so this is the new watermark: without
5152 // re-stamping it, every save would report its own work as an external
5153 // change forever after.
5154 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
5155 self.status = Some(format!("saved {}", self.file_name()));
5156 }
5157
5158 /// What the file looks like now against the bytes leaf last read or wrote.
5159 ///
5160 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
5161 /// so this is a filesystem round-trip, not a per-frame question — ask it
5162 /// when a window regains focus, on a timer, or before a save.
5163 ///
5164 /// This *only* reports the file. Whether the document also has unsaved edits
5165 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
5166 /// [`DiskState::Changed`] means a save overwrites someone's work and a
5167 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
5168 /// it has no way to ask — so it hands a frontend both halves and lets it put
5169 /// the question to the person who can answer it.
5170 #[cfg(feature = "fs")]
5171 pub fn disk_state(&self) -> DiskState {
5172 let Some(want) = self.disk_hash else {
5173 return DiskState::Untitled;
5174 };
5175 match std::fs::read(&self.path) {
5176 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
5177 Ok(_) => DiskState::Changed,
5178 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
5179 Err(_) => DiskState::Unreadable,
5180 }
5181 }
5182
5183 /// Re-read the file and replace the document with what's there — the other
5184 /// answer to a [`DiskState::Changed`].
5185 ///
5186 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
5187 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
5188 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
5189 /// document shouldn't have to argue with a guard.
5190 ///
5191 /// **The undo history survives, and the reload is one step in it.** The
5192 /// whole buffer is spliced with the file's bytes through the same door every
5193 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
5194 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
5195 /// swapped the document out from under a reader gives them back what they
5196 /// were looking at, marked dirty, and ^Z again carries on into whatever they
5197 /// had done before it. This used to build a fresh parse and drop the stack,
5198 /// on the reasoning that twig's history belongs to the buffer and these are
5199 /// different bytes; that is true of *rebasing* a step onto them and not of
5200 /// recording the swap itself as one, which is all this is. A splice twig
5201 /// won't take falls back to the fresh parse, and only that path still costs
5202 /// the history.
5203 ///
5204 /// The caret keeps its byte offset, clamped to the new length; the selection
5205 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
5206 /// file changed, so it can't know where the caret "still" is. Clamping keeps
5207 /// it where the user left it in the common case (a change further down the
5208 /// file, or none in the text they're sitting in), and never puts it
5209 /// somewhere invalid. A selection has two such offsets and no such excuse —
5210 /// silently reinterpreting one over changed bytes would arm the *next*
5211 /// keystroke to delete something the user never selected.
5212 ///
5213 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
5214 /// document alone with a status.
5215 #[cfg(feature = "fs")]
5216 pub fn reload(&mut self) {
5217 if self.is_untitled() {
5218 self.status = Some("no file to reload".into());
5219 return;
5220 }
5221 let bytes = match std::fs::read(&self.path) {
5222 Ok(b) => b,
5223 Err(e) => {
5224 self.status = Some(format!("reload failed: {e}"));
5225 return;
5226 }
5227 };
5228 let Ok(source) = String::from_utf8(bytes) else {
5229 self.status = Some("reload failed: file is not UTF-8".into());
5230 return;
5231 };
5232 // Already these bytes — someone saved a file back unchanged, or leaf's
5233 // own write is being read back. Re-baseline against it and stop: a
5234 // splice of the text onto itself would put an undo step on the stack for
5235 // something nobody did.
5236 if source == self.source {
5237 self.disk_hash = Some(hash_bytes(source.as_bytes()));
5238 self.clean_source = source;
5239 self.dirty = false;
5240 self.status = Some(format!("reloaded {}", self.file_name()));
5241 return;
5242 }
5243 let caret = self.caret;
5244 // The pre-reload caret, so undoing the swap puts it back where the
5245 // reader was standing — the same bracketing `splice_exact` does.
5246 self.record_caret();
5247 if self
5248 .editor
5249 .edit_range(0, self.source.len(), &source)
5250 .is_ok()
5251 {
5252 self.refresh();
5253 } else {
5254 // twig wouldn't take the splice. Start over from the bytes, which is
5255 // what this always did, and is the one path that still costs the
5256 // history — `format` is the format this document *is*, not what the
5257 // (unchanged) name now says, see `save_as`.
5258 match new_editor(source.as_bytes(), self.format) {
5259 Ok(editor) => {
5260 self.editor = editor;
5261 self.source = source.clone();
5262 // Not going through `refresh`, so the revision has to move
5263 // here or every frontend keeps painting the old file from
5264 // cache.
5265 self.revision += 1;
5266 }
5267 Err(e) => {
5268 self.status = Some(format!("reload failed: {e}"));
5269 return;
5270 }
5271 }
5272 }
5273 self.disk_hash = Some(hash_bytes(source.as_bytes()));
5274 self.clean_source = self.source.clone();
5275 self.caret = caret.min(self.source.len());
5276 self.anchor = None;
5277 self.goal_col = None;
5278 self.last_edit_kind = None;
5279 self.dirty = false;
5280 self.status = Some(format!("reloaded {}", self.file_name()));
5281 self.clamp_caret();
5282 // And the post-reload caret, so a redo restores it.
5283 self.record_caret();
5284 }
5285
5286 /// Re-read the source from twig after it has changed the document. The one
5287 /// funnel every edit, undo, and redo comes through — so it's where the
5288 /// revision moves, and anything cached against the text dies here.
5289 fn refresh(&mut self) {
5290 if let Ok(s) = self.editor.source_str() {
5291 self.source = s;
5292 }
5293 self.revision += 1;
5294 // An edit is a step onto the history and the end of anything undone;
5295 // `undo`/`redo` come through here too and correct this after.
5296 self.undo_steps += 1;
5297 self.redo_steps = 0;
5298 self.clamp_caret();
5299 }
5300
5301 /// Whether [`undo`](Self::undo) has a step to take back — for a native
5302 /// Edit menu to enable its item by. See the note on `undo_steps` for what
5303 /// "has" means here.
5304 pub fn can_undo(&self) -> bool {
5305 !self.read_only && self.undo_steps > 0
5306 }
5307
5308 /// Whether [`redo`](Self::redo) has an undone step to restore.
5309 pub fn can_redo(&self) -> bool {
5310 !self.read_only && self.redo_steps > 0
5311 }
5312
5313 // ── caret movement ─────────────────────────────────────────────────────────
5314 // `extend` grows the selection (Shift+motion): it pins the anchor on the
5315 // first extended step and moves only the caret; an un-extended motion drops
5316 // the selection.
5317
5318 /// Place the caret at byte `offset` (clamped to a char boundary), extending
5319 /// the selection when `extend` is set. The public form of `move_to`, for a
5320 /// frontend that hit-tests pixels straight to a source offset.
5321 pub fn place_caret(&mut self, offset: usize, extend: bool) {
5322 self.goal_col = None;
5323 let before = self.caret;
5324 // A pixel hit-test can land between the visible caret stops — in the
5325 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
5326 // Snap to the nearest real stop so the caret can't come to rest where it
5327 // would draw in one place and type in another. The `(row, col)` click
5328 // path (`click`) already snaps this way through `offset_of_pos`; the
5329 // source view reaches every byte, so it snaps to nothing.
5330 let target = match self.view {
5331 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
5332 // The source view reaches every byte, so there is no stop to snap
5333 // to — but "every byte" still means every *character* boundary. A
5334 // caret resting inside a multi-byte character draws nowhere real
5335 // and panics the next time anything slices there.
5336 View::Source => self.char_boundary_at_or_before(offset),
5337 };
5338 self.move_to(target, extend);
5339 self.clamp_caret();
5340 self.debug_assert_on_a_stop(before);
5341 }
5342
5343 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
5344 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
5345 /// grab the metadata) while the source view still selects the literal whole.
5346 pub fn select_all(&mut self) {
5347 self.anchor = Some(self.caret_floor());
5348 self.caret = self.source.len();
5349 self.goal_col = None;
5350 self.last_edit_kind = None;
5351 self.status = None;
5352 }
5353
5354 /// Select the word (or whitespace / punctuation run) at `offset` — the
5355 /// double-click gesture. Anchors on the run's start with the caret at its
5356 /// end so a following Shift-motion extends from the far edge.
5357 pub fn select_word_at(&mut self, offset: usize) {
5358 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
5359 self.anchor = Some(s);
5360 self.caret = e;
5361 self.goal_col = None;
5362 self.last_edit_kind = None;
5363 self.status = None;
5364 self.clamp_caret();
5365 }
5366
5367 /// Select the whole enclosing text block (paragraph, heading, list item's
5368 /// text…) at `offset` — the triple-click gesture. Reads the range straight
5369 /// from the AST (twig's `content_span`), so it selects the entire *logical*
5370 /// paragraph even when that paragraph soft-wraps across several visual rows —
5371 /// where a visual-row-based select breaks down, because one source offset at
5372 /// a wrap boundary belongs to two rows at once.
5373 pub fn select_block_at(&mut self, offset: usize) {
5374 let off = offset.min(self.source.len());
5375 let range = self
5376 .editor
5377 .ancestors_at(off)
5378 .ok()
5379 .and_then(|chain| {
5380 // Ancestors run root → deepest; the deepest node that is neither
5381 // an inline span nor a multi-block container is the text block
5382 // the caret sits in (a paragraph, a heading, a code block…).
5383 chain
5384 .into_iter()
5385 .rev()
5386 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5387 .map(|m| m.content_span.unwrap_or(m.span))
5388 })
5389 .unwrap_or_else(|| source_line_range(&self.source, off));
5390 self.anchor = Some(range.start.min(self.source.len()));
5391 self.caret = range.end.min(self.source.len());
5392 self.goal_col = None;
5393 self.last_edit_kind = None;
5394 self.status = None;
5395 self.clamp_caret();
5396 }
5397
5398 /// Select the exact source range `[start, end)` — anchor at `start`, caret
5399 /// at `end` — without snapping either end to a visible caret stop.
5400 ///
5401 /// The one caret verb that takes a range it was *handed* rather than one it
5402 /// worked out, for a host that already knows the bytes it means: a search
5403 /// hit, an annotation's footprint, a quote re-anchored through
5404 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
5405 /// wrong tool for that, and not by a little — it snaps to the nearest
5406 /// *visible* stop, and where a range butts up against a hidden delimiter
5407 /// the nearest stop is the one before it, so selecting the "needle" of
5408 /// `**needle**` comes back with "needl" and an edit against it strands the
5409 /// "e".
5410 ///
5411 /// What `place_caret` does that is bookkeeping rather than snapping still
5412 /// happens here, because a host handing in a range is not asking to opt out
5413 /// of the invariants:
5414 ///
5415 /// - both ends are clamped into the document and up to
5416 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
5417 /// frontmatter is hidden, and a caret parked in it draws nowhere and
5418 /// types into the metadata;
5419 /// - both land on character boundaries, so nothing slices a `é` in half;
5420 /// - the sticky vertical goal column is dropped, and any armed inline mark
5421 /// disarmed, since a range from outside inherits neither.
5422 ///
5423 /// An empty range is a caret rather than a selection —
5424 /// [`selection`](Self::selection) reports `None` for it, as it does for any
5425 /// anchor that has met the caret.
5426 pub fn select_range(&mut self, start: usize, end: usize) {
5427 let floor = self.caret_floor();
5428 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
5429 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
5430 self.anchor = Some(anchor);
5431 self.caret = caret;
5432 self.goal_col = None;
5433 self.status = None;
5434 self.last_edit_kind = None;
5435 self.clear_pending();
5436 }
5437
5438 /// `offset` itself if it is a character boundary, else the boundary before
5439 /// it. An offset that isn't one draws nowhere real and panics the next time
5440 /// anything slices there.
5441 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
5442 let mut o = offset.min(self.source.len());
5443 while o > 0 && !self.source.is_char_boundary(o) {
5444 o -= 1;
5445 }
5446 o
5447 }
5448
5449 /// The lowest source offset the caret may occupy in the active view. In
5450 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
5451 /// the first rendered offset; the source view reaches everything, so it's 0.
5452 fn caret_floor(&self) -> usize {
5453 match self.view {
5454 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
5455 View::Source => 0,
5456 }
5457 }
5458
5459 /// Land in a table cell with its whole content selected — the anchor at the
5460 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
5461 /// like tabbing into a form field: the text comes up selected, so typing
5462 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
5463 /// end`) collapses to a plain caret home (an empty selection is no selection).
5464 fn select_cell(&mut self, start: usize, end: usize) {
5465 self.select_range(start, end);
5466 }
5467
5468 fn move_to(&mut self, offset: usize, extend: bool) {
5469 if extend {
5470 if self.anchor.is_none() {
5471 self.anchor = Some(self.caret);
5472 }
5473 } else {
5474 self.anchor = None;
5475 }
5476 self.caret = offset.min(self.source.len()).max(self.caret_floor());
5477 self.status = None;
5478 // A caret move ends the current typing/deletion run, so the next edit
5479 // starts a fresh undo group rather than coalescing across the gap.
5480 self.last_edit_kind = None;
5481 // Moving away disarms any sticky mark — "start bold" applies only where
5482 // it was asked for, not wherever the caret next lands.
5483 self.clear_pending();
5484 }
5485
5486 // In the source view, motion walks source bytes / source lines. In the
5487 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
5488 // what steps the caret cleanly over hidden delimiters.
5489
5490 pub fn move_left(&mut self, extend: bool) {
5491 self.goal_col = None;
5492 if !extend && let Some((s, _e)) = self.selection() {
5493 self.move_to(s, false);
5494 return;
5495 }
5496 let target = match self.view {
5497 View::Source => {
5498 if self.caret > 0 {
5499 prev_boundary(&self.source, self.caret)
5500 } else {
5501 0
5502 }
5503 }
5504 // Walks caret *stops*, not columns: decoration (a table border, a
5505 // cell's padding) is stepped over in one press, and a hidden
5506 // delimiter never holds the caret up — though the end of a mark's
5507 // content is a stop of its own (`VisualMap::mark_ends`), so
5508 // leaving `**bold**` from past its `**` is a press onto the end of
5509 // the bold and another onto the `d`.
5510 View::Wysiwyg => self
5511 .vmap
5512 .caret_stop_before(self.caret)
5513 .unwrap_or(self.caret),
5514 };
5515 let before = self.caret;
5516 self.move_to(target, extend);
5517 self.debug_assert_on_a_stop(before);
5518 }
5519
5520 pub fn move_right(&mut self, extend: bool) {
5521 self.goal_col = None;
5522 if !extend && let Some((_s, e)) = self.selection() {
5523 self.move_to(e, false);
5524 return;
5525 }
5526 let target = match self.view {
5527 View::Source => {
5528 if self.caret < self.source.len() {
5529 next_boundary(&self.source, self.caret)
5530 } else {
5531 self.caret
5532 }
5533 }
5534 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
5535 };
5536 let before = self.caret;
5537 self.move_to(target, extend);
5538 self.debug_assert_on_a_stop(before);
5539 }
5540
5541 /// Move to the start of the previous word (⌥← / Ctrl+←).
5542 pub fn move_word_left(&mut self, extend: bool) {
5543 self.goal_col = None;
5544 let before = self.caret;
5545 let target = self.word_left_from(self.caret);
5546 self.move_to(target, extend);
5547 self.debug_assert_on_a_stop(before);
5548 }
5549
5550 /// Move to the end of the next word (⌥→ / Ctrl+→).
5551 pub fn move_word_right(&mut self, extend: bool) {
5552 self.goal_col = None;
5553 let before = self.caret;
5554 let target = self.word_right_from(self.caret);
5555 self.move_to(target, extend);
5556 self.debug_assert_on_a_stop(before);
5557 }
5558
5559 // Word boundaries are found in the space the *view* is in. The source view
5560 // walks the source, because there the source is what's rendered. WYSIWYG
5561 // walks the rendered text instead: `**` is invisible to the user, so it has
5562 // to be invisible to word motion too — a caret parked inside one draws in
5563 // the column after `bold` and types two bytes earlier, and a word-delete
5564 // that stops there shreds the markup into `a ** c`.
5565
5566 /// The word boundary to the left of `off` in the active view's space.
5567 fn word_left_from(&self, off: usize) -> usize {
5568 match self.view {
5569 View::Source => prev_word(&self.source, off),
5570 View::Wysiwyg => self.glyph_word_left(off),
5571 }
5572 }
5573
5574 /// The word boundary to the right of `off` in the active view's space.
5575 fn word_right_from(&self, off: usize) -> usize {
5576 match self.view {
5577 View::Source => next_word(&self.source, off),
5578 View::Wysiwyg => self.glyph_word_right(off),
5579 }
5580 }
5581
5582 /// The character class of the glyph drawn at stop `off`.
5583 ///
5584 /// Read from the source, because a stop points at the source byte its glyph
5585 /// came from — the source *is* where the rendered character is written. What
5586 /// makes the walk glyph space rather than source space is that it only ever
5587 /// visits stops, and the hidden bytes between them have none.
5588 fn class_at(&self, off: usize) -> Class {
5589 self.source
5590 .get(off..)
5591 .and_then(|s| s.chars().next())
5592 .map_or(Class::Space, classify)
5593 }
5594
5595 /// [`next_word`] in glyph space: skip any leading separators, then consume
5596 /// the following word run, with the stop table standing in for the source's
5597 /// characters.
5598 fn glyph_word_right(&self, from: usize) -> usize {
5599 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
5600 return from;
5601 };
5602 let mut in_word = false;
5603 loop {
5604 match self.class_at(off) {
5605 Class::Word => in_word = true,
5606 _ if in_word => return off,
5607 _ => {}
5608 }
5609 match self.vmap.stop_after(off) {
5610 Some(next) => off = next,
5611 None => return off,
5612 }
5613 }
5614 }
5615
5616 /// [`prev_word`] in glyph space: skip separators walking left, then consume
5617 /// the preceding word run.
5618 fn glyph_word_left(&self, from: usize) -> usize {
5619 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
5620 return from;
5621 };
5622 let mut in_word = false;
5623 while let Some(prev) = self.vmap.stop_before(off) {
5624 match self.class_at(prev) {
5625 Class::Word => in_word = true,
5626 _ if in_word => return off,
5627 _ => {}
5628 }
5629 off = prev;
5630 }
5631 off
5632 }
5633
5634 /// After a motion that walks the visual map, the caret must be *on* the map.
5635 /// A stop is the only offset where the caret draws and edits in the same
5636 /// place, and it's the invariant both a caret parked inside an emoji and one
5637 /// parked inside a `**` were quietly breaking.
5638 ///
5639 /// Only when the caret actually moved: a walk with nowhere to go leaves it
5640 /// where it was, which is wherever the floor or a frontend put it rather
5641 /// than somewhere this motion chose.
5642 fn debug_assert_on_a_stop(&self, before: usize) {
5643 debug_assert!(
5644 self.view != View::Wysiwyg
5645 || self.vmap.num_rows() == 0
5646 || self.caret == before
5647 || self.vmap.is_stop(self.caret),
5648 "motion left the caret at {}, which is not a caret stop: it would draw in \
5649 one place and type in another",
5650 self.caret
5651 );
5652 }
5653
5654 // Up and Down run off the ends of the document rather than stopping dead at
5655 // them: Up from the first row lands at the document's start, Down from the
5656 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5657 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5658 // reaching the end of the text is what a reader means by it.
5659 //
5660 // The views used to disagree here by accident rather than by decision: the
5661 // source view fell into the edge behaviour through `row_col_to_offset`
5662 // clamping an out-of-range row to the end of the string, while WYSIWYG had
5663 // no row below to walk to and did nothing at all. They share the rule now,
5664 // each in its own space — the source view reaches every byte, WYSIWYG only
5665 // the offsets it draws.
5666
5667 pub fn move_up(&mut self, extend: bool) {
5668 let (row, col) = self.caret_pos();
5669 let goal = self.goal_col.unwrap_or(col);
5670 let target = match self.view {
5671 View::Source => match row.checked_sub(1) {
5672 Some(r) => row_col_to_offset(&self.source, r, goal),
5673 None => self.reachable_start(),
5674 },
5675 // A table's border rules are drawn but hold no caret, so Up steps
5676 // over them to the row that does.
5677 View::Wysiwyg => match self.vmap.navigable_above(row) {
5678 Some(r) => self.row_target(r, goal),
5679 None => self.reachable_start(),
5680 },
5681 };
5682 self.step_vertical(target, goal, extend);
5683 }
5684
5685 pub fn move_down(&mut self, extend: bool) {
5686 let (row, col) = self.caret_pos();
5687 let goal = self.goal_col.unwrap_or(col);
5688 let target = match self.view {
5689 View::Source => match self.source_row_below(row) {
5690 Some(r) => row_col_to_offset(&self.source, r, goal),
5691 None => self.reachable_end(),
5692 },
5693 View::Wysiwyg => match self.vmap.navigable_below(row) {
5694 Some(r) => self.row_target(r, goal),
5695 None => self.reachable_end(),
5696 },
5697 };
5698 self.step_vertical(target, goal, extend);
5699 }
5700
5701 /// Land a vertical motion at `target`, latching the `goal` column it aimed
5702 /// with so the rest of the run keeps aiming there.
5703 ///
5704 /// A motion with nowhere to go changes *nothing*, the goal column included:
5705 /// the latch used to run before the early return at the top of the document,
5706 /// so an Up that did nothing still armed a column, and the next Down aimed
5707 /// at one the caret had never been in.
5708 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5709 let before = self.caret;
5710 if target == before {
5711 return;
5712 }
5713 self.goal_col = Some(goal);
5714 self.move_to(target, extend);
5715 self.debug_assert_on_a_stop(before);
5716 }
5717
5718 /// The source line below `row`, or `None` when `row` is the last one. Lines
5719 /// are counted by newline, so a trailing one leaves a real, empty last line
5720 /// for the caret to sit on — the document ends below it, not on it.
5721 fn source_row_below(&self, row: usize) -> Option<usize> {
5722 let last = self.source.bytes().filter(|&b| b == b'\n').count();
5723 (row < last).then_some(row + 1)
5724 }
5725
5726 /// Where a vertical motion aiming at the `goal` column lands on visual row
5727 /// `r`: the column clamped to the row, mapped to its offset, then held
5728 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5729 /// column belongs to the row below, and a gutter's column 0 points at the
5730 /// block rather than at this row.
5731 fn row_target(&self, r: usize, goal: usize) -> usize {
5732 let (start, end) = self.row_bounds(r);
5733 self.vmap
5734 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5735 .clamp(start, end)
5736 }
5737
5738 /// The first and last offsets the caret can reach in the active view.
5739 ///
5740 /// Not the same span in both: the source view shows every byte, so it can
5741 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5742 /// sits below the first stop, and a document's trailing newline is drawn
5743 /// nowhere and so sits past the last.
5744 fn reachable_start(&self) -> usize {
5745 match self.view {
5746 View::Source => 0,
5747 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5748 }
5749 }
5750
5751 fn reachable_end(&self) -> usize {
5752 match self.view {
5753 View::Source => self.source.len(),
5754 View::Wysiwyg => self
5755 .vmap
5756 .stop_at_or_before(self.source.len())
5757 .unwrap_or(self.caret),
5758 }
5759 }
5760
5761 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5762 /// including the space a soft wrap ate off its end, which is drawn on this
5763 /// row however much the offset past it belongs to the next one.
5764 fn row_span(&self, r: usize) -> (usize, usize) {
5765 let start = self
5766 .vmap
5767 .row_start(r)
5768 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5769 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5770 (start.min(end), end)
5771 }
5772
5773 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5774 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5775 /// this row's last position is the one before it — the offset before the
5776 /// space the wrap ate, where the caret draws just past the row's last word
5777 /// and types there too.
5778 ///
5779 /// Aiming at the shared offset instead is what stalled End: it is the row's
5780 /// last *column*, so End pressed on the row reached it and then read back as
5781 /// the row below's start, where a second press ran on to that row's end and
5782 /// the next to the one after — End walking down the paragraph a row a press.
5783 fn row_bounds(&self, r: usize) -> (usize, usize) {
5784 let (start, end) = self.row_span(r);
5785 let wraps = self
5786 .vmap
5787 .navigable_below(r)
5788 .and_then(|b| self.vmap.row_start(b))
5789 .is_some_and(|off| off == end);
5790 match wraps {
5791 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5792 false => (start, end),
5793 }
5794 }
5795
5796 /// The `[start, end]` of the line Home and End aim at: the visual row in
5797 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5798 ///
5799 /// A soft-wrapped row is a line here, because it is one to the eye and the
5800 /// eye is what these keys are aimed by — a reader pressing End means the end
5801 /// of the line they can see. (`select_block_at` wants the opposite and reads
5802 /// the AST for it: a triple-click grabs the whole paragraph, however many
5803 /// rows it folds into.)
5804 fn line_bounds(&self) -> (usize, usize) {
5805 let (row, _) = self.caret_pos();
5806 match self.view {
5807 View::Source => {
5808 let start = line_start(&self.source, row);
5809 (start, line_end_from(&self.source, start))
5810 }
5811 View::Wysiwyg => self.row_bounds(row),
5812 }
5813 }
5814
5815 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5816 /// *drawn* — what a kill takes.
5817 ///
5818 /// The two part only at a soft wrap, over the space the wrap ate: the caret
5819 /// can't stand after it (that offset opens the row below, and End stopping
5820 /// there would walk), but it is on this row, and a kill that spared it would
5821 /// leave a double space behind where the row's text had been. Deleting it
5822 /// joins nothing — a wrap is drawn, not written.
5823 fn line_span(&self) -> (usize, usize) {
5824 let (row, _) = self.caret_pos();
5825 match self.view {
5826 View::Source => self.line_bounds(),
5827 View::Wysiwyg => self.row_span(row),
5828 }
5829 }
5830
5831 /// The first offset in `[start, end]` holding something other than
5832 /// whitespace, or `end` when the line holds nothing else — where Home aims.
5833 ///
5834 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5835 /// so a hidden delimiter is never taken for the line's first character (nor
5836 /// landed on), and the source view steps the source it is showing.
5837 fn first_non_space(&self, start: usize, end: usize) -> usize {
5838 let mut off = start;
5839 while off < end {
5840 if self.class_at(off) != Class::Space {
5841 return off;
5842 }
5843 off = match self.view {
5844 View::Source => next_boundary(&self.source, off),
5845 View::Wysiwyg => match self.vmap.stop_after(off) {
5846 Some(next) => next,
5847 None => return end,
5848 },
5849 };
5850 }
5851 end
5852 }
5853
5854 /// Home: to the first character on the line, or to column 0 when the caret
5855 /// is already on it — the two-press toggle every editor spells this way.
5856 /// The indentation is somewhere the caret has to be able to reach and almost
5857 /// never where a reader is headed, so it costs the second press.
5858 pub fn move_home(&mut self, extend: bool) {
5859 self.goal_col = None;
5860 let (start, end) = self.line_bounds();
5861 let text = self.first_non_space(start, end);
5862 let target = if self.caret == text { start } else { text };
5863 let before = self.caret;
5864 self.move_to(target, extend);
5865 self.debug_assert_on_a_stop(before);
5866 }
5867
5868 /// End: to the end of the line.
5869 pub fn move_end(&mut self, extend: bool) {
5870 self.goal_col = None;
5871 let (_, end) = self.line_bounds();
5872 let before = self.caret;
5873 self.move_to(end, extend);
5874 self.debug_assert_on_a_stop(before);
5875 }
5876
5877 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5878 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5879 /// when the caret isn't in a table, or is already in the last/first cell — the
5880 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5881 /// meaning everywhere else.
5882 pub fn cell_hop(&mut self, forward: bool) -> bool {
5883 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5884 return false;
5885 };
5886 // Flatten to document (row-major) order and step one cell either way.
5887 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5888 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5889 let next = if forward {
5890 i.checked_add(1)
5891 } else {
5892 i.checked_sub(1)
5893 };
5894 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5895 return false; // at the table's edge; leave Tab to the frontend
5896 };
5897 self.select_cell(start, end);
5898 true
5899 }
5900
5901 /// Move the caret to the cell directly above (`down == false`) or below in
5902 /// the same column, landing with the cell's whole content selected (see
5903 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5904 /// when the caret isn't in a table), so the frontend can fall through — the
5905 /// vertical counterpart of [`Self::cell_hop`].
5906 ///
5907 /// A ragged row that is short a column clamps to its last cell, so Down never
5908 /// falls out of the table over a gap the row above happened to have.
5909 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5910 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5911 return false;
5912 };
5913 let target = match down {
5914 true => r + 1,
5915 false if r == 0 => return false,
5916 false => r - 1,
5917 };
5918 let Some(row) = grid.get(target) else {
5919 return false;
5920 };
5921 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5922 return false;
5923 };
5924 self.select_cell(start, end);
5925 true
5926 }
5927
5928 /// The table containing `off` as a row-major grid of `(start, end)` cell
5929 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5930 /// isn't in a table. Read straight off the visual map's laid-out grid, so
5931 /// every cell (an empty one included, whose derived home twig gives no
5932 /// `content_span` for) is present and in the order Tab walks them.
5933 // Grid, row, column — three returns that only ever travel together, and a
5934 // named type for the pair of them would be read at one call site.
5935 #[allow(clippy::type_complexity)]
5936 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5937 for t in &self.vmap.tables {
5938 let mut pos = None;
5939 let grid: Vec<Vec<(usize, usize)>> = t
5940 .grid
5941 .iter()
5942 .enumerate()
5943 .map(|(r, row)| {
5944 row.cells
5945 .iter()
5946 .enumerate()
5947 .map(|(c, cell)| {
5948 if pos.is_none() && off >= cell.start && off <= cell.end {
5949 pos = Some((r, c));
5950 }
5951 (cell.start, cell.end)
5952 })
5953 .collect()
5954 })
5955 .collect();
5956 if let Some((r, c)) = pos {
5957 return Some((grid, r, c));
5958 }
5959 }
5960 None
5961 }
5962
5963 // ── table key policy ──────────────────────────────────────────────────────
5964 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5965 // as one policy every frontend shares, rather than each re-deriving it. Each
5966 // reports whether it acted *as a table key*; a `false` hands the key back to
5967 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5968 // everywhere else.
5969
5970 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5971 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5972 /// back and simply stays put at the very first cell. `false` when the caret
5973 /// isn't in a table.
5974 pub fn cell_tab(&mut self, forward: bool) -> bool {
5975 if !self.caret_in_table() {
5976 return false;
5977 }
5978 if self.cell_hop(forward) {
5979 return true;
5980 }
5981 // Off the last cell: grow the table by a row and step into its first
5982 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5983 if forward {
5984 self.append_row_and_enter(0);
5985 }
5986 true
5987 }
5988
5989 /// Return inside a table: drop to the cell below in the same column,
5990 /// appending a new row when the caret is already in the last one. `false`
5991 /// when the caret isn't in a table, so the frontend inserts a newline.
5992 pub fn cell_return(&mut self) -> bool {
5993 if !self.caret_in_table() {
5994 return false;
5995 }
5996 if self.cell_move_vertical(true) {
5997 return true;
5998 }
5999 // Already on the last row: grow one below and drop into the same column.
6000 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
6001 self.append_row_and_enter(col);
6002 true
6003 }
6004
6005 /// Append a row below the caret's (last) row and land in `col` of it. The
6006 /// caret is in the last row, so twig's "insert below" makes the fresh row the
6007 /// table's new last — but twig re-spells the whole table, moving every byte,
6008 /// so the destination is read back from the rebuilt grid by the table's
6009 /// position (stable across a row insert), not from the pre-edit caret.
6010 fn append_row_and_enter(&mut self, col: usize) {
6011 let table = self.caret_table_index();
6012 self.table_insert_row(true);
6013 self.rebuild_map();
6014 let Some((start, end)) = table
6015 .and_then(|ti| self.vmap.tables.get(ti))
6016 .and_then(|t| t.grid.last())
6017 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
6018 .map(|cell| (cell.start, cell.end))
6019 else {
6020 return;
6021 };
6022 self.select_cell(start, end);
6023 }
6024
6025 /// The index, among the document's tables, of the one the caret sits in —
6026 /// `None` when it's in none. Used to re-find a table after an edit re-spells
6027 /// it (a row insert leaves the table order unchanged).
6028 fn caret_table_index(&self) -> Option<usize> {
6029 let off = self.caret;
6030 self.vmap.tables.iter().position(|t| {
6031 t.grid
6032 .iter()
6033 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
6034 })
6035 }
6036
6037 /// Shift+Return inside a table: insert a hard line break *within* the current
6038 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
6039 /// table, so the frontend inserts an ordinary line break.
6040 ///
6041 /// A table row is a single source line, so the newline-spelled hard break
6042 /// can't live in a cell. twig spells the in-cell break the format's way
6043 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
6044 /// break round-trips as structure the renderer reads back as a line — not the
6045 /// opaque raw HTML the old raw-splice left behind.
6046 ///
6047 /// Djot has no idiomatic in-cell break, so twig refuses it
6048 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
6049 /// would render as the literal text `<br>`. The gesture is still *consumed*
6050 /// there — returning `false` would let the frontend insert a real newline,
6051 /// which splits the one-line row — it just leaves the cell unchanged and says
6052 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
6053 ///
6054 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
6055 /// have to be read together: djot is not the only `false`, and naming it in
6056 /// the message was already a guess that HTML — which spells the break as its
6057 /// own `<br>` — would have made wrong.
6058 pub fn cell_line_break(&mut self) -> bool {
6059 if self.read_only || !self.caret_in_table() {
6060 return false;
6061 }
6062 self.record_caret();
6063 match self.editor.insert_line_break(self.caret) {
6064 Ok(change) => {
6065 self.last_edit_kind = None;
6066 self.refresh();
6067 self.caret = change.new.end;
6068 self.anchor = None;
6069 self.goal_col = None;
6070 self.clamp_caret();
6071 self.dirty = self.source != self.clean_source;
6072 self.status = None;
6073 self.record_caret();
6074 }
6075 Err(twig::Error::UnsupportedFormat) => {
6076 self.status = Some(format!(
6077 "in-cell line breaks aren't supported in {}",
6078 self.format_name()
6079 ));
6080 }
6081 Err(_) => {}
6082 }
6083 true
6084 }
6085
6086 /// Rebuild the visual map at the width the last build used. A structural edit
6087 /// bumps the revision and swaps the source in, but leaves the *map* stale;
6088 /// when a single gesture edits and then moves over the result (Tab appending
6089 /// a row, then stepping into it), the move needs the map to already show the
6090 /// edit rather than waiting for the frontend's next frame.
6091 fn rebuild_map(&mut self) {
6092 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
6093 self.build_map(wrap);
6094 }
6095
6096 /// Move the caret to the very start of the document (⌘↑ on macOS,
6097 /// Ctrl+Home on Windows/Linux).
6098 pub fn move_doc_start(&mut self, extend: bool) {
6099 self.goal_col = None;
6100 self.move_to(0, extend);
6101 }
6102
6103 /// Move the caret to the very end of the document (⌘↓ on macOS,
6104 /// Ctrl+End on Windows/Linux).
6105 pub fn move_doc_end(&mut self, extend: bool) {
6106 self.goal_col = None;
6107 let end = self.source.len();
6108 self.move_to(end, extend);
6109 }
6110
6111 /// Point the caret at the body cell `(row, col)` the mouse landed on —
6112 /// `col` being a cell of the terminal grid, which is what a display column
6113 /// is. A click on the far cell of a wide character lands at that
6114 /// character's start; the mapping's own doc-comments carry the rule.
6115 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
6116 self.goal_col = None;
6117 let target = match self.view {
6118 View::Source => row_col_to_offset(&self.source, row, col),
6119 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
6120 };
6121 let before = self.caret;
6122 self.move_to(target, extend);
6123 self.debug_assert_on_a_stop(before);
6124 }
6125
6126 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
6127 /// screen if it has moved since the last frame, and never scroll past the
6128 /// last of `rows`.
6129 ///
6130 /// Only if it has *moved* — that's the whole point. Revealing the caret on
6131 /// every frame ties the viewport to it, and a scroll wheel that fights the
6132 /// caret for the viewport loses: the view snaps back the instant it tries to
6133 /// pass the caret's row, so the document can't be scrolled beyond what's
6134 /// already on screen. A caret move is the frontend's cue to follow; a scroll
6135 /// with the caret sitting still is the reader's cue to leave it alone.
6136 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
6137 if self.drawn_caret != Some(self.caret) {
6138 if caret_row < self.scroll {
6139 self.scroll = caret_row;
6140 } else if height > 0 && caret_row >= self.scroll + height {
6141 self.scroll = caret_row + 1 - height;
6142 }
6143 self.drawn_caret = Some(self.caret);
6144 }
6145 self.scroll = self.scroll.min(rows.saturating_sub(1));
6146 }
6147
6148 /// The caret's screen position `(row, col)` in the active view's grid, with
6149 /// `col` a display column: the cell to draw the caret in, which on a line of
6150 /// `你好` or emoji is not the count of characters before it.
6151 pub fn caret_pos(&self) -> (usize, usize) {
6152 match self.view {
6153 View::Source => offset_to_row_col(&self.source, self.caret),
6154 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
6155 }
6156 }
6157
6158 fn clamp_caret(&mut self) {
6159 if self.caret > self.source.len() {
6160 self.caret = self.source.len();
6161 }
6162 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
6163 // any selection anchor) to the first rendered offset.
6164 let floor = self.caret_floor();
6165 if self.caret < floor {
6166 self.caret = floor;
6167 }
6168 if let Some(a) = self.anchor
6169 && a < floor
6170 {
6171 self.anchor = Some(floor);
6172 }
6173 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
6174 self.caret -= 1;
6175 }
6176 }
6177}
6178
6179// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
6180
6181// Left/right motion and backspace/delete step by *grapheme cluster*, not
6182// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
6183// marks moves and deletes as the single character a user sees. Grapheme
6184// boundaries are a superset of char boundaries, so the caret stays valid for twig.
6185
6186/// How an insert of `text` groups for undo: a single typed character folds into
6187/// the run of typing around it, while a newline or a multi-character insert is a
6188/// step of its own.
6189fn typed_edit_kind(text: &str) -> EditKind {
6190 if text.chars().take(2).count() == 1 && text != "\n" {
6191 EditKind::Insert
6192 } else {
6193 EditKind::Other
6194 }
6195}
6196
6197fn prev_boundary(s: &str, i: usize) -> usize {
6198 let mut cursor = GraphemeCursor::new(i, s.len(), true);
6199 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
6200}
6201
6202fn next_boundary(s: &str, i: usize) -> usize {
6203 let mut cursor = GraphemeCursor::new(i, s.len(), true);
6204 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
6205}
6206
6207// ── word boundaries ──────────────────────────────────────────────────────────
6208// The shared primitive behind word-wise motion, word deletion, and
6209// double-click-to-select-a-word. A "word" is a maximal run of one character
6210// class; whitespace and punctuation are their own classes, so motion skips
6211// cleanly between them the way native text fields do.
6212
6213#[derive(PartialEq, Eq, Clone, Copy)]
6214enum Class {
6215 Word,
6216 Space,
6217 Other,
6218}
6219
6220/// The source range of an inline node's own visible text — the part of it a
6221/// WYSIWYG caret can reach, as against the delimiters that only spell it.
6222/// `None` for a node with no interior to empty (a `str`, a break).
6223///
6224/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
6225/// one delimiter in from the span — the same place the renderer maps it to. A
6226/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
6227/// against the source rather than trusted: a range guessed wrong here is text
6228/// deleted wrong.
6229fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
6230 if let Some(span) = n.content_span.clone() {
6231 return Some(span);
6232 }
6233 match n.kind.as_str() {
6234 "verbatim" | "inline_math" => {
6235 let text = n.text.as_ref()?;
6236 let start = n.span.start + 1;
6237 let range = start..start + text.len();
6238 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
6239 }
6240 _ => None,
6241 }
6242}
6243
6244/// The `id` a node declares, or `None` for one that declares none — the
6245/// attribute djot writes for a `{#v1}` and mints for a heading.
6246///
6247/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
6248/// names nothing, so it reads as absent rather than as the empty string.
6249fn declared_id(n: &FlatNode) -> Option<&str> {
6250 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
6251}
6252
6253/// A heading's words reduced to the form a link fragment spells them in:
6254/// lowercase, runs of anything else collapsed to a single `-`, with none left
6255/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
6256///
6257/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
6258/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
6259/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
6260/// any other language is still a heading someone will link to. Underscores
6261/// survive for the same reason they do on the web: they are word characters
6262/// wherever identifiers are written.
6263fn slug(text: &str) -> String {
6264 let mut out = String::new();
6265 let mut pending = false;
6266 for c in text.chars() {
6267 if c.is_alphanumeric() || c == '_' {
6268 if pending && !out.is_empty() {
6269 out.push('-');
6270 }
6271 pending = false;
6272 out.extend(c.to_lowercase());
6273 } else {
6274 pending = true;
6275 }
6276 }
6277 out
6278}
6279
6280fn is_block_container(kind: &Kind) -> bool {
6281 matches!(
6282 kind,
6283 Kind::Doc
6284 | Kind::Section
6285 | Kind::BlockQuote
6286 | Kind::BulletList
6287 | Kind::OrderedList
6288 | Kind::TaskList
6289 | Kind::ListItem
6290 | Kind::TaskListItem
6291 // Every `container` — a directive in any of its three forms, or a
6292 // promoted HTML element. A *text* directive is really inline, so
6293 // claiming it here is a small overreach, and the deliberate one this
6294 // function's kind-only peer `is_inline_kind` documents: the pair is
6295 // consulted together, and answering "block container" for something
6296 // inline is what keeps an ancestor walk from stopping short of the
6297 // paragraph that actually holds it.
6298 | Kind::Container
6299 )
6300}
6301
6302/// The `[start, end)` byte range of the source line containing `off` (newline
6303/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
6304/// line between paragraphs).
6305fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
6306 let off = off.min(s.len());
6307 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
6308 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
6309 start..end
6310}
6311
6312/// How many leading bytes an outdent takes off `line`: a whole indent level
6313/// where the line has one, and whatever it has where it has less.
6314///
6315/// A leading tab counts as a level on its own. It's indentation some other
6316/// editor wrote, and one tab is one level everywhere it came from — measuring it
6317/// in spaces it doesn't contain would leave it untouchable.
6318fn outdent_width(line: &str, unit: usize) -> usize {
6319 if line.starts_with('\t') {
6320 return 1;
6321 }
6322 line.bytes().take(unit).take_while(|b| *b == b' ').count()
6323}
6324
6325/// A list marker found at the head of a line, together with everything before it
6326/// that a sibling line has to repeat.
6327///
6328/// The three offsets differ only inside a block quote, where `> - b` opens with
6329/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
6330/// `line_start == marker_start`, and `text` is the plain `" - "`.
6331#[derive(Clone, Debug)]
6332struct ListMarker {
6333 /// The line's first byte.
6334 line_start: usize,
6335 /// Where the marker proper begins, past any quote prefix. The offset to hand
6336 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
6337 marker_start: usize,
6338 /// `line_start` through the marker's trailing space — quote prefix, indent
6339 /// and bullet together, which is what the next item's line opens with.
6340 text: String,
6341}
6342
6343impl ListMarker {
6344 /// Where the item's content starts — one past the marker's trailing space.
6345 fn content_start(&self) -> usize {
6346 self.line_start + self.text.len()
6347 }
6348}
6349
6350fn classify(c: char) -> Class {
6351 if c == '_' || c.is_alphanumeric() {
6352 Class::Word
6353 } else if c.is_whitespace() {
6354 Class::Space
6355 } else {
6356 Class::Other
6357 }
6358}
6359
6360/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
6361/// skip any leading separators, then consume the following word run.
6362fn next_word(s: &str, i: usize) -> usize {
6363 let mut off = i;
6364 let mut in_word = false;
6365 for c in s[i..].chars() {
6366 if classify(c) == Class::Word {
6367 in_word = true;
6368 } else if in_word {
6369 break;
6370 }
6371 off += c.len_utf8();
6372 }
6373 off
6374}
6375
6376/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
6377/// skip separators walking left, then consume the preceding word run.
6378fn prev_word(s: &str, i: usize) -> usize {
6379 let mut off = i;
6380 let mut in_word = false;
6381 for c in s[..i].chars().rev() {
6382 if classify(c) == Class::Word {
6383 in_word = true;
6384 } else if in_word {
6385 break;
6386 }
6387 off -= c.len_utf8();
6388 }
6389 off
6390}
6391
6392/// The `[start, end)` run of same-class characters surrounding `off` — the
6393/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
6394/// the run ending there is used.
6395fn word_range_at(s: &str, off: usize) -> (usize, usize) {
6396 if s.is_empty() {
6397 return (0, 0);
6398 }
6399 let off = off.min(s.len());
6400 let reference = if off < s.len() {
6401 s[off..].chars().next()
6402 } else {
6403 s[..off].chars().next_back()
6404 };
6405 let Some(rc) = reference else {
6406 return (off, off);
6407 };
6408 let class = classify(rc);
6409
6410 let mut start = off;
6411 for c in s[..start].chars().rev() {
6412 if classify(c) == class {
6413 start -= c.len_utf8();
6414 } else {
6415 break;
6416 }
6417 }
6418 let mut end = off;
6419 for c in s[end..].chars() {
6420 if classify(c) == class {
6421 end += c.len_utf8();
6422 } else {
6423 break;
6424 }
6425 }
6426 (start, end)
6427}
6428
6429/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
6430/// the line's start — terminal cells, not characters, so the column names the
6431/// cell the caret is drawn in even on a line of `你好` or emoji.
6432fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
6433 let off = off.min(s.len());
6434 let mut row = 0;
6435 let mut line_start = 0;
6436 for (i, &b) in s.as_bytes().iter().enumerate() {
6437 if i >= off {
6438 break;
6439 }
6440 if b == b'\n' {
6441 row += 1;
6442 line_start = i + 1;
6443 }
6444 }
6445 (row, wysiwyg::text_width(&s[line_start..off]))
6446}
6447
6448/// The byte offset at display column `col` of `row` (clamped to that line's
6449/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
6450///
6451/// A column landing *inside* a character — the second cell of `你`, or any cell
6452/// but the first of an emoji — resolves to that character's start, which is the
6453/// column the caret would have been drawn at to begin with. So both cells of a
6454/// wide character mean the character, and every offset survives the round trip
6455/// out to a column and back. The walk steps by grapheme cluster for the same
6456/// reason the caret does: a cluster is the character, and the cells belong to it
6457/// rather than to the codepoints spelling it.
6458fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
6459 let start = line_start(s, row);
6460 let end = line_end_from(s, start);
6461 let mut off = start;
6462 let mut at = 0; // the display column `off` sits at
6463 while off < end {
6464 let next = next_boundary(s, off).min(end);
6465 let cells = wysiwyg::text_width(&s[off..next]);
6466 if at + cells > col {
6467 break; // `col` is one of this cluster's own cells
6468 }
6469 at += cells;
6470 off = next;
6471 }
6472 off
6473}
6474
6475fn line_start(s: &str, row: usize) -> usize {
6476 if row == 0 {
6477 return 0;
6478 }
6479 let mut r = 0;
6480 for (i, &b) in s.as_bytes().iter().enumerate() {
6481 if b == b'\n' {
6482 r += 1;
6483 if r == row {
6484 return i + 1;
6485 }
6486 }
6487 }
6488 s.len()
6489}
6490
6491fn line_end_from(s: &str, start: usize) -> usize {
6492 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
6493}
6494
6495/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
6496/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
6497/// twig applies writing the mark out, so the toolbar can light the same button
6498/// that made the node.
6499///
6500/// `None` for every other kind, including the inline nodes that aren't marks at
6501/// all (`str`, `link`, `image`, the math and break kinds): they're things a
6502/// caret stands in, not formatting a button toggles.
6503fn inline_kind(kind: &Kind) -> Option<InlineKind> {
6504 Some(match kind {
6505 Kind::Strong => InlineKind::Strong,
6506 Kind::Emph => InlineKind::Emph,
6507 Kind::Verbatim => InlineKind::Verbatim,
6508 Kind::Mark => InlineKind::Mark,
6509 Kind::Superscript => InlineKind::Superscript,
6510 Kind::Subscript => InlineKind::Subscript,
6511 Kind::Insert => InlineKind::Insert,
6512 Kind::Delete => InlineKind::Delete,
6513 _ => return None,
6514 })
6515}
6516
6517/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
6518/// a highlight's opening `==`.
6519///
6520/// Two enums for one closed vocabulary, and the duplication is the boundary
6521/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
6522/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
6523/// the editor writes. Spelled as a match rather than routed through the two
6524/// crates' name strings so that a colour added on either side is a compile
6525/// error here, where the pairing is decided, rather than a runtime `None` that
6526/// would read as "clear the colour".
6527fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
6528 match color {
6529 MarkColor::Red => twig::MarkColor::Red,
6530 MarkColor::Orange => twig::MarkColor::Orange,
6531 MarkColor::Yellow => twig::MarkColor::Yellow,
6532 MarkColor::Green => twig::MarkColor::Green,
6533 MarkColor::Blue => twig::MarkColor::Blue,
6534 MarkColor::Purple => twig::MarkColor::Purple,
6535 MarkColor::Brown => twig::MarkColor::Brown,
6536 }
6537}
6538
6539/// Where an offset lands after a splice it didn't make — twig's own rule, from
6540/// [`Change`]: shift anything at or past the replaced range's end by the length
6541/// the replacement gained or lost, and leave anything before it alone.
6542///
6543/// An offset *inside* the replaced range has no text of its own to ride any
6544/// more, and lands at the end of what replaced it: for
6545/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
6546/// the prefix is cleared, which then sits where the highlighted text begins.
6547fn reanchor(off: usize, change: &Change) -> usize {
6548 if off < change.old.start {
6549 return off;
6550 }
6551 if off < change.old.end {
6552 return change.new.end;
6553 }
6554 (off + change.new.end).saturating_sub(change.old.end)
6555}
6556
6557/// A watermark for a file's contents (see `Doc::disk_hash`).
6558///
6559/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
6560/// watermark is compared only against one taken by the same process moments
6561/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
6562/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
6563fn hash_bytes(bytes: &[u8]) -> u64 {
6564 use std::hash::{Hash, Hasher};
6565 let mut h = std::collections::hash_map::DefaultHasher::new();
6566 bytes.hash(&mut h);
6567 h.finish()
6568}
6569
6570#[cfg(feature = "fs")]
6571fn detect_format(path: &Path) -> Result<Format> {
6572 let ext = path
6573 .extension()
6574 .and_then(|e| e.to_str())
6575 .unwrap_or("")
6576 .to_ascii_lowercase();
6577 Ok(match ext.as_str() {
6578 "dj" | "djot" => Format::Djot,
6579 "md" | "markdown" => Format::Markdown,
6580 "xml" => Format::Xml,
6581 "html" | "htm" => Format::Html,
6582 other => return Err(anyhow!("unknown document extension: .{other}")),
6583 })
6584}
6585
6586#[cfg(test)]
6587mod tests {
6588 use super::*;
6589
6590 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
6591 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
6592 /// without one is a view no user is ever in.
6593 fn doc_in(view: View, name: &str, body: &str) -> Doc {
6594 // The fixture name doubles as the temp file's, so two tests picking the
6595 // same one raced under the parallel runner and read each other's body —
6596 // a green suite proving the wrong thing. The counter makes that
6597 // unreachable rather than asking every future caller to notice.
6598 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6599 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6600 let mut p = std::env::temp_dir();
6601 p.push(format!("leaf_test_{name}_{seq}.md"));
6602 std::fs::write(&p, body).unwrap();
6603 let mut d = Doc::open(p).unwrap();
6604 d.view = view;
6605 if view == View::Wysiwyg {
6606 d.build_visual(80);
6607 }
6608 d
6609 }
6610
6611 // Source-view document for the source-behaviour tests. `Doc::open` now
6612 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
6613 // `wysiwyg_doc` builds the rich-text variant on top of this.
6614 fn doc_with(name: &str, body: &str) -> Doc {
6615 doc_in(View::Source, name, body)
6616 }
6617
6618 /// Every visual row's drawn text — what the reader actually sees, which is
6619 /// the only thing the reveal preference is supposed to change.
6620 fn drawn_rows(d: &Doc) -> Vec<String> {
6621 d.vmap
6622 .rows
6623 .iter()
6624 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6625 .collect()
6626 }
6627
6628 /// Put the caret at the first byte of `needle` and rebuild, so the row under
6629 /// it becomes the revealed line.
6630 fn caret_at(d: &mut Doc, needle: &str) {
6631 d.caret = d.source.find(needle).expect("needle in source");
6632 d.build_visual(80);
6633 }
6634
6635 #[test]
6636 fn blockquote_after_a_list_is_not_bulleted() {
6637 // twig nests a following top-level block quote under the `bullet_list`
6638 // (a direct child, not a `list_item`). The map must render it de-nested —
6639 // `│ quote`, never `• │ quote` — with a blank separator, like any block
6640 // that follows a list. Regression for the "combined list + blockquote" bug.
6641 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
6642 d.build_visual(80);
6643 let rows: Vec<String> = d
6644 .vmap
6645 .rows
6646 .iter()
6647 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6648 .collect();
6649 assert!(
6650 rows.iter().any(|r| r == "│ quote"),
6651 "block quote should render on its own gutter, got rows: {rows:?}"
6652 );
6653 assert!(
6654 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
6655 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
6656 );
6657 }
6658
6659 // ── the map is built at most once per (revision, wrap) ───────────────────
6660 //
6661 // A frontend repaints for reasons that have nothing to do with the text — a
6662 // blinking caret, a scroll — and rebuilding the map is O(document). These
6663 // pin *that the cache fires*, which a passing suite can't tell you: a cache
6664 // that never hits is invisible to every other test in this file.
6665 //
6666 // The probe is to wreck the built map and ask for it again. A rebuild
6667 // repairs it; a cache hit hands the wreckage straight back. Nothing else
6668 // can distinguish the two from outside.
6669
6670 #[test]
6671 fn a_rebuild_with_nothing_changed_reuses_the_map() {
6672 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6673 d.build_visual(80);
6674 assert!(!d.vmap.rows.is_empty());
6675 d.vmap.rows.clear(); // wreck it
6676 d.build_visual(80);
6677 assert!(
6678 d.vmap.rows.is_empty(),
6679 "the map was rebuilt though nothing changed — the cache never fired"
6680 );
6681 }
6682
6683 #[test]
6684 fn an_edit_rebuilds_the_map() {
6685 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6686 d.build_visual(80);
6687 let before = d.revision();
6688 d.vmap.rows.clear();
6689 d.insert("x");
6690 d.build_visual(80);
6691 assert!(d.revision() > before, "an edit must move the revision");
6692 assert!(
6693 !d.vmap.rows.is_empty(),
6694 "an edited document must not paint from a stale map"
6695 );
6696 }
6697
6698 #[test]
6699 fn a_width_change_rebuilds_the_map() {
6700 // The map is a function of the wrap width too, so a resize is a miss
6701 // even though the text is untouched.
6702 let mut d = doc_in(
6703 View::Wysiwyg,
6704 "cache_width",
6705 "one two three four five six\n",
6706 );
6707 d.build_visual(80);
6708 d.vmap.rows.clear();
6709 d.build_visual(12);
6710 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6711 // And the unwrapped map is its own key, not the same as any width.
6712 d.vmap.rows.clear();
6713 d.build_visual_unwrapped();
6714 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6715 }
6716
6717 #[test]
6718 fn a_motion_does_not_rebuild_the_map() {
6719 // The whole point: moving the caret changes nothing the map is built
6720 // from. If a motion bumped the revision, every arrow key would cost a
6721 // full rebuild and the cache would be worthless.
6722 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6723 d.build_visual(80);
6724 let rev = d.revision();
6725 d.move_right(false);
6726 d.move_right(true);
6727 d.move_down(false);
6728 assert_eq!(d.revision(), rev, "a motion must not move the revision");
6729 d.vmap.rows.clear();
6730 d.build_visual(80);
6731 assert!(
6732 d.vmap.rows.is_empty(),
6733 "a motion should not rebuild the map"
6734 );
6735 }
6736
6737 #[test]
6738 fn saving_does_not_rebuild_the_map() {
6739 // Saving changes `dirty`, not the text.
6740 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6741 d.insert("x");
6742 d.build_visual(80);
6743 let rev = d.revision();
6744 d.save();
6745 assert_eq!(d.revision(), rev, "a save must not move the revision");
6746 assert!(!d.dirty, "the save should have cleaned the document");
6747 }
6748
6749 #[test]
6750 fn a_reload_rebuilds_the_map() {
6751 // Reload replaces the text without going through `refresh`, so it has to
6752 // move the revision itself — else the editor paints the old file.
6753 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6754 d.build_visual(80);
6755 let rev = d.revision();
6756 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6757 d.reload();
6758 assert!(d.revision() > rev, "a reload must move the revision");
6759 d.build_visual(80);
6760 let text: String = d
6761 .vmap
6762 .rows
6763 .iter()
6764 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6765 .collect();
6766 assert!(
6767 text.contains("wholly new"),
6768 "the reloaded text should be on screen, got {text:?}"
6769 );
6770 }
6771
6772 // ── golden-case harness ──────────────────────────────────────────────────
6773 // The pattern the whole parity suite can reuse: write a fixture with the
6774 // caret marked by `|`, run one action, and compare the rendered result —
6775 // also caret-marked — against the expected string. One readable line per
6776 // behavior, and it exercises the exact `Doc` ops both frontends call.
6777
6778 /// Split a `|`-marked fixture into `(source, caret_offset)`.
6779 fn parse_caret(marked: &str) -> (String, usize) {
6780 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6781 (marked.replacen('|', "", 1), caret)
6782 }
6783
6784 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6785 /// selection) so a result reads like the fixtures.
6786 fn render_caret(d: &Doc) -> String {
6787 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6788 // so the caret always renders inside its own selection.
6789 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6790 if let Some((s, e)) = d.selection() {
6791 marks.push((s, 0, '['));
6792 marks.push((e, 2, ']'));
6793 }
6794 // Insert right-to-left: descending offset, then descending rank.
6795 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6796 let mut out = d.source.clone();
6797 for (at, _, ch) in marks {
6798 out.insert(at, ch);
6799 }
6800 out
6801 }
6802
6803 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6804 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6805 golden_in(View::Source, name, marked, action)
6806 }
6807
6808 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6809 /// the same fixture has to read the same way in both.
6810 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6811 let (src, caret) = parse_caret(marked);
6812 let mut d = doc_in(view, name, &src);
6813 d.caret = caret;
6814 action(&mut d);
6815 render_caret(&d)
6816 }
6817
6818 #[test]
6819 fn word_motion_walks_word_by_word() {
6820 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6821 assert_eq!(
6822 g("hello wor|ld", |d| d.move_word_left(false)),
6823 "hello |world"
6824 );
6825 assert_eq!(
6826 g("hello| world", |d| d.move_word_left(false)),
6827 "|hello world"
6828 );
6829 assert_eq!(
6830 g("hel|lo world", |d| d.move_word_right(false)),
6831 "hello| world"
6832 );
6833 assert_eq!(
6834 g("hello| world", |d| d.move_word_right(false)),
6835 "hello world|"
6836 );
6837 // Punctuation is its own class, so motion stops at the boundary.
6838 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6839 }
6840
6841 #[test]
6842 fn word_motion_extends_the_selection_when_asked() {
6843 assert_eq!(
6844 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6845 "hello [world|]"
6846 );
6847 }
6848
6849 #[test]
6850 fn delete_word_removes_a_whole_word() {
6851 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6852 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6853 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6854 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6855 }
6856
6857 // ── Home / End ───────────────────────────────────────────────────────────
6858
6859 #[test]
6860 fn home_toggles_between_the_line_s_text_and_its_margin() {
6861 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6862 // indent to the markup it spells everywhere it means one, so the fixture
6863 // with whitespace left to walk is a code block, which is verbatim.
6864 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6865 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
6866 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
6867 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
6868 // A line with no indentation has one place to go, so the toggle is a
6869 // no-op rather than a trip to nowhere.
6870 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6871 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6872
6873 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
6874 let indent = d.source.find(" indented").unwrap();
6875 d.caret = indent + 6; // inside "indented"
6876 d.move_home(false);
6877 assert_eq!(
6878 d.caret,
6879 indent + 4,
6880 "wysiwyg: Home aims at the code line's text"
6881 );
6882 d.move_home(false);
6883 assert_eq!(
6884 d.caret, indent,
6885 "wysiwyg: the second press takes the indent"
6886 );
6887 d.move_home(false);
6888 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6889 }
6890
6891 #[test]
6892 fn end_takes_the_line_the_view_is_showing() {
6893 // The line differs by view for the same document, and that is the point:
6894 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6895 // as a space on one row and the source view as two lines.
6896 let mut d = doc_with("end_src", "one two\nthree\n");
6897 d.caret = 1;
6898 d.move_end(false);
6899 assert_eq!(d.caret, 7, "source: the end of the source line");
6900
6901 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6902 d.caret = 1;
6903 d.move_end(false);
6904 assert_eq!(
6905 d.caret, 13,
6906 "wysiwyg: the end of the row, soft break and all"
6907 );
6908 }
6909
6910 #[test]
6911 fn home_and_end_extend_the_selection_when_asked() {
6912 for (view, tag) in VIEWS {
6913 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6914 d.caret = 6;
6915 d.move_end(true);
6916 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6917 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6918 d.caret = 6;
6919 d.move_home(true);
6920 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6921 }
6922 }
6923
6924 // ── kill to the line's start / end ───────────────────────────────────────
6925
6926 #[test]
6927 fn kill_to_the_line_start_and_end_in_both_views() {
6928 for (view, tag) in VIEWS {
6929 // The gap that reads as a paragraph break in each view: the source
6930 // view's lines are the renderer's rows only where the source says so.
6931 let gap = if view == View::Source { "\n" } else { "\n\n" };
6932 let mut d = doc_in(
6933 view,
6934 &format!("kill_end_{tag}"),
6935 &format!("one two{gap}three\n"),
6936 );
6937 d.caret = 3;
6938 d.delete_to_line_end();
6939 assert_eq!(
6940 d.source,
6941 format!("one{gap}three\n"),
6942 "{tag}: ^K to the line's end"
6943 );
6944 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6945
6946 let mut d = doc_in(
6947 view,
6948 &format!("kill_start_{tag}"),
6949 &format!("one two{gap}three\n"),
6950 );
6951 d.caret = 7; // the end of the first line
6952 d.delete_to_line_start();
6953 assert_eq!(
6954 d.source,
6955 format!("{gap}three\n"),
6956 "{tag}: ⌘⌫ to the line's start"
6957 );
6958 assert_eq!(d.caret, 0, "{tag}");
6959 }
6960 }
6961
6962 #[test]
6963 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6964 // The decision: at the boundary both kills do nothing, rather than
6965 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6966 // at a soft wrap as often as at a newline, where there is nothing
6967 // written to delete — and a source newline is only half of the blank
6968 // line between two paragraphs, so taking it leaves a soft break rather
6969 // than the join it looks like. Backspace and Delete are the keys for it.
6970 for (view, tag) in VIEWS {
6971 let gap = if view == View::Source { "\n" } else { "\n\n" };
6972 let src = format!("one{gap}three\n");
6973 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6974 d.caret = 3; // the end of "one"
6975 d.delete_to_line_end();
6976 assert_eq!(
6977 d.source, src,
6978 "{tag}: ^K at the line's end joined it to the next"
6979 );
6980
6981 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6982 d.caret = 3 + gap.len(); // the start of "three"
6983 d.delete_to_line_start();
6984 assert_eq!(
6985 d.source, src,
6986 "{tag}: ⌘⌫ at the line's start joined it to the last"
6987 );
6988 }
6989 }
6990
6991 #[test]
6992 fn a_kill_takes_the_selection_when_there_is_one() {
6993 // What every other delete here does with one, so these two as well.
6994 for (view, tag) in VIEWS {
6995 for (name, kill) in [
6996 (
6997 "end",
6998 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6999 ),
7000 ("start", |d: &mut Doc| d.delete_to_line_start()),
7001 ] {
7002 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
7003 d.anchor = Some(4);
7004 d.caret = 7; // "two"
7005 kill(&mut d);
7006 assert_eq!(
7007 d.source, "one three\n",
7008 "{tag}: {name} ignored the selection"
7009 );
7010 assert_eq!(d.selection(), None, "{tag}: {name}");
7011 }
7012 }
7013 }
7014
7015 #[test]
7016 fn a_kill_takes_the_markup_it_empties_with_it() {
7017 // The same hazard a word-delete has: a WYSIWYG range covers what the
7018 // user can see, which for `**bold**` is the word and never the
7019 // delimiters, so a kill that stopped at the text would leave `a ****` —
7020 // markup wrapped around nothing.
7021 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
7022 d.caret = d.source.find("bold").unwrap();
7023 d.delete_to_line_end();
7024 assert_eq!(d.source, "a \n");
7025 }
7026
7027 #[test]
7028 fn a_kill_is_undone_in_one_step() {
7029 for (view, tag) in VIEWS {
7030 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
7031 d.caret = 3;
7032 d.delete_to_line_end();
7033 assert_eq!(d.source, "one\n", "{tag}");
7034 d.undo();
7035 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
7036 }
7037 }
7038
7039 #[test]
7040 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
7041 // Regression: triple-click used move_home/move_end over visual rows, so
7042 // it only worked on a paragraph's first row (a wrap-boundary offset maps
7043 // to the earlier row). select_block_at reads the AST, so every offset in
7044 // the paragraph selects the whole thing.
7045 let body = "one two three four five six seven eight\n";
7046 let mut d = doc_with("sel_block", body);
7047 d.view = View::Wysiwyg;
7048 d.build_visual(12); // force the paragraph to wrap into several rows
7049 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
7050 let para = (0, "one two three four five six seven eight".len());
7051 for off in [0usize, 8, 19, 28, 38] {
7052 d.caret = 0;
7053 d.anchor = None;
7054 d.select_block_at(off);
7055 assert_eq!(
7056 d.selection(),
7057 Some(para),
7058 "offset {off} should select the paragraph"
7059 );
7060 }
7061 }
7062
7063 #[test]
7064 fn select_block_uses_content_span_for_a_heading() {
7065 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
7066 d.select_block_at(4); // inside "Title"
7067 // content_span excludes the "# " marker.
7068 assert_eq!(d.selected_text(), Some("Title"));
7069 d.select_block_at(10); // inside "body"
7070 assert_eq!(d.selected_text(), Some("body"));
7071 }
7072
7073 #[test]
7074 fn select_all_spans_the_document() {
7075 let mut d = doc_with("sel_all", "abc\n\ndef\n");
7076 d.select_all();
7077 assert_eq!(d.selection(), Some((0, d.source.len())));
7078 }
7079
7080 #[test]
7081 fn select_word_at_picks_the_surrounding_word() {
7082 let mut d = doc_with("sel_word", "hello world\n");
7083 d.select_word_at(8); // inside "world"
7084 assert_eq!(d.selection(), Some((6, 11)));
7085 // Double-clicking at end-of-word still grabs the word to its left.
7086 d.select_word_at(5); // the space between the words
7087 assert_eq!(d.selection(), Some((5, 6)));
7088 }
7089
7090 #[test]
7091 fn word_helpers_respect_utf8_boundaries() {
7092 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
7093 assert_eq!(
7094 golden("utf8", "|café ok", |d| d.move_word_right(false)),
7095 "café| ok"
7096 );
7097 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
7098 }
7099
7100 #[test]
7101 fn typing_inserts_at_the_caret_and_advances_it() {
7102 let mut d = doc_with("type", "hello\n");
7103 d.insert("Hi ");
7104 assert_eq!(d.source, "Hi hello\n");
7105 assert_eq!(d.caret, 3);
7106 assert!(d.dirty);
7107 }
7108
7109 #[test]
7110 fn backspace_deletes_the_char_before_the_caret() {
7111 let mut d = doc_with("bs", "hello\n");
7112 d.caret = 3; // after "hel"
7113 d.backspace();
7114 assert_eq!(d.source, "helo\n");
7115 assert_eq!(d.caret, 2);
7116 }
7117
7118 #[test]
7119 fn typing_replaces_the_selection() {
7120 let mut d = doc_with("replace", "a word b\n");
7121 d.anchor = Some(2);
7122 d.caret = 6; // "word" selected
7123 d.insert("X");
7124 assert_eq!(d.source, "a X b\n");
7125 assert_eq!(d.caret, 3);
7126 assert_eq!(d.anchor, None);
7127 }
7128
7129 #[test]
7130 fn toggle_bold_wraps_then_unwraps_the_selection() {
7131 let mut d = doc_with("bold", "a word b\n");
7132 d.anchor = Some(2);
7133 d.caret = 6;
7134 d.toggle(InlineKind::Strong);
7135 assert_eq!(d.source, "a **word** b\n");
7136 // The toggled region stays selected, so a second toggle reverses it.
7137 d.toggle(InlineKind::Strong);
7138 assert_eq!(d.source, "a word b\n");
7139 d.toggle(InlineKind::Strong);
7140 assert_eq!(d.source, "a **word** b\n");
7141 }
7142
7143 #[test]
7144 fn toggle_code_wraps_then_unwraps_the_selection() {
7145 let mut d = doc_with("code_rt", "a word b\n");
7146 d.anchor = Some(2);
7147 d.caret = 6;
7148 d.toggle(InlineKind::Verbatim);
7149 assert_eq!(d.source, "a `word` b\n");
7150 d.toggle(InlineKind::Verbatim);
7151 assert_eq!(d.source, "a word b\n");
7152 }
7153
7154 #[test]
7155 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
7156 // ⌘b at a bare caret, then type: the text comes out bold with no
7157 // selection ever made — the word-processor "start bold here" gesture.
7158 let mut d = doc_with("sticky_wrap", "xy\n");
7159 d.caret = 1; // between x and y
7160 d.toggle(InlineKind::Strong);
7161 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
7162 d.insert("A");
7163 assert_eq!(d.source, "x**A**y\n");
7164 }
7165
7166 #[test]
7167 fn sticky_bold_lights_the_toolbar_before_any_typing() {
7168 // The button must light the instant ⌘b is pressed, or the mode is
7169 // invisible until the first character lands.
7170 let mut d = doc_with("sticky_light", "xy\n");
7171 d.caret = 1;
7172 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7173 d.toggle(InlineKind::Strong);
7174 assert!(d.active_inline_marks().contains(InlineKind::Strong));
7175 }
7176
7177 #[test]
7178 fn sticky_bold_toggled_off_types_normally_again() {
7179 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
7180 // in the flow of typing, the exact sequence the user described.
7181 let mut d = doc_with("sticky_off", "\n");
7182 d.caret = 0;
7183 d.toggle(InlineKind::Strong);
7184 d.insert("a");
7185 d.insert("b"); // continues inside the run, no re-arming
7186 assert_eq!(d.source, "**ab**\n");
7187 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
7188 d.insert("c");
7189 assert_eq!(d.source, "**ab**c\n");
7190 }
7191
7192 #[test]
7193 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
7194 // Once a mark is realised the caret sits inside the run, so plain typing
7195 // extends it rather than starting a second, adjacent bold span.
7196 let mut d = doc_with("sticky_cont", "\n");
7197 d.caret = 0;
7198 d.toggle(InlineKind::Emph);
7199 d.insert("h");
7200 d.insert("i");
7201 assert_eq!(d.source, "*hi*\n");
7202 }
7203
7204 #[test]
7205 fn moving_the_caret_disarms_a_sticky_mark() {
7206 // Arming a mark and then moving away must not style text elsewhere.
7207 let mut d = doc_with("sticky_disarm", "xy\n");
7208 d.caret = 0;
7209 d.toggle(InlineKind::Strong);
7210 d.move_right(false); // caret 0 → 1, disarms
7211 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7212 d.insert("A");
7213 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
7214 }
7215
7216 #[test]
7217 fn stacked_sticky_marks_apply_together() {
7218 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
7219 let mut d = doc_with("sticky_stack", "\n");
7220 d.caret = 0;
7221 d.toggle(InlineKind::Strong);
7222 d.toggle(InlineKind::Emph);
7223 d.insert("x");
7224 // Land the caret on the styled character and confirm both marks are live.
7225 d.anchor = Some(d.source.find('x').unwrap());
7226 d.caret = d.anchor.unwrap() + 1;
7227 let marks = d.active_inline_marks();
7228 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
7229 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
7230 }
7231
7232 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
7233
7234 #[test]
7235 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
7236 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
7237 // The space inside the run made `**bold **`, which is *not* bold — four
7238 // literal asterisks — so the rich view drew them, correctly and
7239 // uselessly, until the next character happened to close the run again.
7240 let mut d = wysiwyg_doc("edge_typing", "a \n");
7241 d.caret = 2;
7242 d.toggle(InlineKind::Strong);
7243 for c in "bold".chars() {
7244 d.insert(&c.to_string());
7245 }
7246 assert_eq!(d.source, "a **bold**\n");
7247 d.insert(" ");
7248 assert_eq!(
7249 d.source, "a **bold** \n",
7250 "the space belongs outside the run"
7251 );
7252 assert!(
7253 d.active_inline_marks().contains(InlineKind::Strong),
7254 "bold is still what's being typed, so the button stays lit"
7255 );
7256 // What the writer is looking at while all this happens: their words.
7257 d.build_visual(80);
7258 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
7259 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
7260 for c in "hey".chars() {
7261 d.insert(&c.to_string());
7262 }
7263 assert_eq!(
7264 d.source, "a **bold hey**\n",
7265 "one bold phrase, not two runs"
7266 );
7267 }
7268
7269 #[test]
7270 fn typing_past_a_space_can_still_leave_the_bold_behind() {
7271 // The other half: the marks stay armed across the space, so ⌘b turns
7272 // them off again there and the next word is plain — the run isn't
7273 // rejoined by a caret that was told not to.
7274 let mut d = wysiwyg_doc("edge_shed", "\n");
7275 d.caret = 0;
7276 d.toggle(InlineKind::Strong);
7277 for c in "bold ".chars() {
7278 d.insert(&c.to_string());
7279 }
7280 assert_eq!(d.source, "**bold** \n");
7281 d.toggle(InlineKind::Strong);
7282 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7283 d.insert("x");
7284 assert_eq!(d.source, "**bold** x\n");
7285 }
7286
7287 #[test]
7288 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
7289 // ⌘b and then a space before any word: the space is not marked (nothing
7290 // is), and the word after it is.
7291 let mut d = wysiwyg_doc("edge_space_first", "a\n");
7292 d.caret = 1;
7293 d.toggle(InlineKind::Strong);
7294 d.insert(" ");
7295 assert_eq!(d.source, "a \n");
7296 assert!(d.active_inline_marks().contains(InlineKind::Strong));
7297 d.insert("b");
7298 assert_eq!(d.source, "a **b**\n");
7299 }
7300
7301 #[test]
7302 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
7303 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
7304 d.caret = 8; // the caret's home at the end of the run's text
7305 d.insert(" ");
7306 assert_eq!(
7307 d.source, "x **bold** \n",
7308 "the space lands past the delimiters"
7309 );
7310 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
7311
7312 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
7313 d.caret = 4; // in front of the "b"
7314 d.insert(" ");
7315 assert_eq!(d.source, "x **bold** y\n");
7316 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
7317 }
7318
7319 #[test]
7320 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
7321 // Backspace over the last letter of a bold phrase.
7322 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
7323 d.caret = 10; // past the "h"
7324 d.backspace();
7325 assert_eq!(d.source, "a **bold** \n");
7326 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
7327 assert!(d.active_inline_marks().contains(InlineKind::Strong));
7328 d.insert("x");
7329 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
7330 }
7331
7332 #[test]
7333 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
7334 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
7335 // mark, which is only text. The marks live on in the caret instead.
7336 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
7337 d.caret = 5;
7338 d.backspace();
7339 assert_eq!(d.source, "a c\n");
7340 assert!(d.active_inline_marks().contains(InlineKind::Strong));
7341 d.insert("x");
7342 assert_eq!(d.source, "a **x** c\n");
7343 }
7344
7345 #[test]
7346 fn typing_over_a_whole_bold_word_keeps_it_bold() {
7347 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
7348 d.anchor = Some(4);
7349 d.caret = 8; // the word, not its delimiters
7350 d.insert("x");
7351 assert_eq!(d.source, "a **x** c\n");
7352 }
7353
7354 #[test]
7355 fn a_code_span_keeps_the_space_it_is_given() {
7356 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
7357 // is still verbatim, so nothing is re-spelt. The repair asks the parser
7358 // rather than a table of kinds, and this is the answer it gets.
7359 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
7360 d.caret = 7;
7361 d.insert(" ");
7362 assert_eq!(d.source, "a `code ` c\n");
7363 }
7364
7365 #[test]
7366 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
7367 // A run's closing delimiter has a caret home on each side of it, one
7368 // column apart on screen — and a plain ← off the space after a bold word
7369 // lands on the outer one. The character drawn behind the caret there is
7370 // still the last letter of the phrase, so that is what Backspace takes;
7371 // the byte behind it is a `*` nobody can see.
7372 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
7373 d.caret = 9;
7374 d.move_left(false);
7375 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
7376 d.backspace();
7377 assert_eq!(
7378 d.source, "**bol** x\n",
7379 "a letter of the phrase, not its `*`"
7380 );
7381 assert_eq!(d.caret, 5);
7382
7383 // And the mirror in front of the opening delimiter, where Delete's
7384 // character is the first letter of the run.
7385 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
7386 d.caret = 1;
7387 d.delete_forward();
7388 assert_eq!(d.source, "x**old**\n");
7389 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
7390 }
7391
7392 #[test]
7393 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
7394 // The byte beside the caret at either edge of a bold word is a `*` the
7395 // rich view draws nothing for. Taking it is not the character delete the
7396 // key was pressed for — it unspells the run and puts a literal asterisk
7397 // on screen (`a *bold** c`). The visible character is the one that goes.
7398 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
7399 d.caret = 4; // in front of the "b"
7400 d.backspace();
7401 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
7402
7403 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
7404 d.caret = 8; // past the "d"
7405 d.delete_forward();
7406 assert_eq!(d.source, "a **bold**c\n");
7407 assert_eq!(d.caret, 8, "and the caret stays inside the run");
7408 d.insert("x");
7409 assert_eq!(d.source, "a **boldx**c\n");
7410
7411 // A code span's backticks are hidden the same way, so they are covered
7412 // by the same rule and not by a list of kinds.
7413 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
7414 d.caret = 3;
7415 d.backspace();
7416 assert_eq!(d.source, "a`code` c\n");
7417 }
7418
7419 #[test]
7420 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
7421 // The asterisks are on the screen there and the caret can stand between
7422 // them, so a delete takes exactly the byte it is aimed at.
7423 let mut d = doc_with("edge_open_src", "a **bold** c\n");
7424 d.caret = 4;
7425 d.backspace();
7426 assert_eq!(d.source, "a *bold** c\n");
7427
7428 let mut d = doc_with("edge_close_src", "a **bold** c\n");
7429 d.caret = 8;
7430 d.delete_forward();
7431 assert_eq!(d.source, "a **bold* c\n");
7432 }
7433
7434 #[test]
7435 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
7436 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
7437 // The space had stepped outside the run (the mark-edge rule), taking the
7438 // caret with it, so the delete put it back down on the far side of the
7439 // closing `**` — one place on screen, and the wrong side of it. Typing
7440 // came out plain and the toolbar went dark, with nothing to see.
7441 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
7442 d.caret = 0;
7443 d.toggle(InlineKind::Strong);
7444 for c in "bold".chars() {
7445 d.insert(&c.to_string());
7446 }
7447 d.insert(" ");
7448 assert_eq!(d.source, "**bold** \n");
7449 d.backspace();
7450 assert_eq!(
7451 d.source, "**bold**\n",
7452 "the space goes, the delimiters stay"
7453 );
7454 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
7455 assert!(
7456 d.active_inline_marks().contains(InlineKind::Strong),
7457 "so the button is still lit"
7458 );
7459 d.insert("x");
7460 assert_eq!(
7461 d.source, "**boldx**\n",
7462 "and the next character is still bold"
7463 );
7464 }
7465
7466 #[test]
7467 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
7468 // What the stranded caret did next: the byte behind it was the closing
7469 // `*`, so a second press took that instead of a letter — `**bold*`, the
7470 // styling gone and an asterisk on the screen where the word had been.
7471 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
7472 d.caret = 0;
7473 d.toggle(InlineKind::Strong);
7474 for c in "bold ".chars() {
7475 d.insert(&c.to_string());
7476 }
7477 assert_eq!(d.source, "**bold** \n");
7478 d.backspace();
7479 d.backspace();
7480 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
7481 assert_eq!(d.caret, 5);
7482 }
7483
7484 #[test]
7485 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
7486 // `***both***` closes two runs with one stack of asterisks: the caret has
7487 // to walk in through all of them, or it lands between the emph and the
7488 // strong and types half-marked.
7489 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
7490 d.caret = 11;
7491 d.backspace();
7492 assert_eq!(d.source, "***both***\n");
7493 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
7494 d.insert("x");
7495 assert_eq!(d.source, "***bothx***\n");
7496 }
7497
7498 #[test]
7499 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
7500 // The settle only moves a caret a run actually closed over. Ordinary
7501 // deletes — inside a run, or in plain prose — are untouched.
7502 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
7503 d.caret = 8;
7504 d.backspace();
7505 assert_eq!(d.source, "a **bol** c\n");
7506 assert_eq!(d.caret, 7);
7507
7508 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
7509 d.caret = 5;
7510 d.backspace();
7511 assert_eq!(d.source, "plai\n");
7512 assert_eq!(d.caret, 4);
7513 }
7514
7515 #[test]
7516 fn the_source_view_leaves_a_delete_where_it_landed() {
7517 // The delimiters are on the screen there, so the offset past them is a
7518 // place the caret can be seen to be — nothing to settle.
7519 let mut d = doc_with("edge_bksp_src", "**bold** \n");
7520 d.caret = 9;
7521 d.backspace();
7522 assert_eq!(d.source, "**bold**\n");
7523 assert_eq!(d.caret, 8);
7524 }
7525
7526 #[test]
7527 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
7528 // `***both***` closes two runs with one stack of asterisks; a space that
7529 // clears only the inner one lands against the outer's and breaks that
7530 // instead.
7531 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
7532 d.caret = 9;
7533 d.insert(" ");
7534 assert_eq!(d.source, "a ***both*** \n");
7535 assert_eq!(d.caret, 13);
7536 d.insert("x");
7537 assert_eq!(d.source, "a ***both x***\n");
7538 }
7539
7540 #[test]
7541 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
7542 // The delimiter shuffle is not an edit the writer made, so it is not a
7543 // step they have to undo past.
7544 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
7545 d.caret = 8;
7546 d.insert(" ");
7547 assert_eq!(d.source, "a **bold** \n");
7548 d.undo();
7549 assert_eq!(d.source, "a **bold**\n");
7550 }
7551
7552 #[test]
7553 fn the_source_view_types_the_space_where_it_was_asked_to() {
7554 // The rule is a rich-view courtesy. In the source view the delimiters are
7555 // on the screen and the user is editing the bytes they can see.
7556 let mut d = doc_with("edge_src", "a **bold** c\n");
7557 d.caret = 8;
7558 d.insert(" ");
7559 assert_eq!(d.source, "a **bold ** c\n");
7560 }
7561
7562 #[test]
7563 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
7564 // Double-clicking a word takes the space after it; bolding that must not
7565 // spell `**word **`, which is not bold at all.
7566 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
7567 d.anchor = Some(2);
7568 d.caret = 7; // "word "
7569 d.toggle(InlineKind::Strong);
7570 assert_eq!(d.source, "a **word** b\n");
7571 d.toggle(InlineKind::Strong);
7572 assert_eq!(d.source, "a word b\n");
7573 d.toggle(InlineKind::Strong);
7574 assert_eq!(
7575 d.source, "a **word** b\n",
7576 "reapplying the mark must not wrap stale delimiter offsets"
7577 );
7578 // And a selection of nothing but whitespace has no word to mark.
7579 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
7580 d.anchor = Some(6);
7581 d.caret = 7;
7582 d.toggle(InlineKind::Strong);
7583 assert_eq!(d.source, "a word b\n");
7584 assert!(d.status.is_some());
7585 }
7586
7587 #[test]
7588 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
7589 let mut d = doc_with("head_set", "hello\n");
7590 d.caret = 2; // caret inside the paragraph, no selection
7591 d.set_block(BlockKind::Heading(1));
7592 assert_eq!(d.source, "# hello\n");
7593 }
7594
7595 #[test]
7596 fn set_block_heading_works_in_wysiwyg_view() {
7597 // The app defaults to WYSIWYG; the caret is a source offset either way.
7598 let mut d = wysiwyg_doc("head_wys", "hello\n");
7599 d.caret = 2;
7600 d.set_block(BlockKind::Heading(1));
7601 assert_eq!(d.source, "# hello\n");
7602 }
7603
7604 #[test]
7605 fn toggle_heading_applies_switches_and_reverts() {
7606 let mut d = doc_with("head_toggle", "hello\n");
7607 d.caret = 2;
7608 d.toggle_heading(1);
7609 assert_eq!(d.source, "# hello\n"); // paragraph → H1
7610 d.toggle_heading(2);
7611 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
7612 d.toggle_heading(2);
7613 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
7614 }
7615
7616 #[test]
7617 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
7618 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
7619 // the blank line but the caret rendered on the *next* line, because the
7620 // separator was a non-navigable decoration row. In Preserve flow that
7621 // blank line is a real caret home — the caret must resolve onto it, and
7622 // typing there makes the soft break that continues the paragraph.
7623 let src = "line one:\nsecond line\n";
7624 let mut d = wysiwyg_doc("pre_enter_lineend", src);
7625 d.set_line_flow(LineFlow::Preserve);
7626 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
7627 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
7628 d.newline();
7629 d.build_visual_unwrapped();
7630 assert_eq!(d.source, "line one:\n\nsecond line\n");
7631 assert_eq!(
7632 d.caret, 10,
7633 "caret sits on the new blank line, not the next line"
7634 );
7635 // The blank line is row 1, and the caret resolves onto it — not row 2.
7636 assert_eq!(
7637 d.vmap.pos_of_offset(10),
7638 (1, 0),
7639 "caret renders on the blank row"
7640 );
7641 assert!(
7642 !d.vmap.rows[1].decoration,
7643 "the blank line is navigable in Preserve"
7644 );
7645 // Typing there makes a soft break: one paragraph, three lines.
7646 d.insert("new clause,");
7647 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
7648 }
7649
7650 #[test]
7651 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
7652 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
7653 // that keeps it one paragraph — where Fold would open a second paragraph.
7654 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
7655 d.set_line_flow(LineFlow::Preserve);
7656 d.caret = 3;
7657 d.newline();
7658 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
7659
7660 // End-of-paragraph: Enter then typing continues the same paragraph on a
7661 // new line (a soft break), not a fresh paragraph.
7662 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
7663 d.set_line_flow(LineFlow::Preserve);
7664 d.caret = 3;
7665 d.newline();
7666 d.insert("def");
7667 assert_eq!(
7668 d.source, "abc\ndef\n",
7669 "end-of-line Enter + typing is a soft break"
7670 );
7671 }
7672
7673 #[test]
7674 fn preserve_double_enter_still_makes_a_paragraph() {
7675 // Two Enters in a row promote to a real paragraph break: the second lands
7676 // on the blank line the first opened and takes the empty-line branch.
7677 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
7678 d.set_line_flow(LineFlow::Preserve);
7679 d.caret = 3;
7680 d.newline();
7681 d.newline();
7682 d.insert("def");
7683 assert_eq!(
7684 d.source, "abc\n\ndef\n",
7685 "double Enter is a paragraph break"
7686 );
7687 }
7688
7689 #[test]
7690 fn preserve_backspace_joins_across_a_soft_break() {
7691 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7692 // soft break it deletes the single newline and joins the two lines.
7693 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7694 d.set_line_flow(LineFlow::Preserve);
7695 d.build_visual(80);
7696 d.caret = 4; // start of "def", just past the soft break
7697 d.backspace();
7698 assert_eq!(
7699 d.source, "abcdef\n",
7700 "Backspace joins across the soft break"
7701 );
7702 assert_eq!(d.caret, 3, "caret lands where the lines meet");
7703 }
7704
7705 #[test]
7706 fn fold_enter_still_starts_a_new_paragraph() {
7707 // The default flow is unchanged: a lone `\n` would render as an invisible
7708 // space, so Enter keeps opening the paragraph break that actually shows.
7709 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7710 d.caret = 3;
7711 d.newline();
7712 assert_eq!(
7713 d.source, "abc\n\ndef\n",
7714 "Fold mid-line Enter is a paragraph break"
7715 );
7716 }
7717
7718 #[test]
7719 fn wysiwyg_one_enter_starts_a_new_paragraph() {
7720 // Regression: one Enter left the caret between the two newlines, so typing
7721 // made a soft break (one paragraph) and you needed a second Enter.
7722 let mut d = wysiwyg_doc("wys_enter", "abc\n");
7723 d.caret = 3;
7724 d.newline();
7725 d.insert("def");
7726 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7727 }
7728
7729 #[test]
7730 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7731 // Regression: Enter at the caret's natural End-of-line resting place
7732 // after a bold run with nothing following it (on screen: right after
7733 // "bold", before the hidden closing "**") spliced the paragraph break
7734 // at that very byte offset — which sits *before* the closing "**" in
7735 // the source, since the delimiter is hidden and emits no glyph of its
7736 // own for `push_row`'s "end of row" fallback to count. That severed the
7737 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7738 // "**" alone on the new line instead of leaving "**bold**" intact with
7739 // a fresh empty paragraph after it.
7740 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7741 d.move_end(false); // the WYSIWYG End key, from caret 0
7742 assert_eq!(
7743 d.caret, 6,
7744 "caret rests right after \"bold\", before the hidden \"**\""
7745 );
7746 d.newline();
7747 assert!(
7748 d.source.starts_with("**bold**"),
7749 "the closing ** must stay attached to \"bold\": got {:?}",
7750 d.source
7751 );
7752 assert_eq!(
7753 d.source, "**bold**\n\n\n",
7754 "a fresh empty paragraph follows the still-intact bold run"
7755 );
7756 }
7757
7758 #[test]
7759 fn source_view_enter_is_a_single_newline() {
7760 let mut d = doc_with("src_enter", "abc\n");
7761 d.caret = 3;
7762 d.newline();
7763 assert_eq!(d.source, "abc\n\n");
7764 }
7765
7766 #[test]
7767 fn heading_applies_at_the_end_of_a_paragraph() {
7768 // The caret at a line end sits at the doc level; set_block must still find
7769 // the block on that line.
7770 let mut d = doc_with("head_end", "abc\n");
7771 d.caret = 3; // end of "abc"
7772 d.toggle_heading(1);
7773 assert_eq!(d.source, "# abc\n");
7774 }
7775
7776 #[test]
7777 fn heading_on_an_empty_new_paragraph_creates_one() {
7778 let mut d = wysiwyg_doc("head_empty", "abc\n");
7779 d.caret = 3;
7780 d.newline(); // caret now on a fresh, empty paragraph
7781 d.toggle_heading(1);
7782 d.insert("Title");
7783 assert!(d.source.contains("# Title"), "got {:?}", d.source);
7784 }
7785
7786 #[test]
7787 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7788 // The reported bug, end to end: click a blank line with another one under
7789 // it, press H1, type. The text landed in the heading and the caret's
7790 // offset was right (the source view drew it there), but the rich view
7791 // drew it two rows lower, on the trailing blank line — the empty `# `
7792 // heading had left every row below it short by the marker's two bytes,
7793 // and the blank line ended up claiming the heading's own end offset.
7794 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7795 d.build_visual_unwrapped();
7796 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7797 d.toggle_heading(1);
7798 for c in "title".chars() {
7799 d.insert(&c.to_string());
7800 d.build_visual_unwrapped(); // as a frontend does, one frame per key
7801 }
7802 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7803 assert_eq!(
7804 d.caret_pos(),
7805 (4, 5),
7806 "the caret draws at the end of the heading"
7807 );
7808 }
7809
7810 #[test]
7811 fn clicking_an_empty_heading_types_after_its_marker() {
7812 // The same anchor from the other side: the empty heading's row is its own
7813 // caret home, so a click on it must land past the hidden `# `. Landing in
7814 // front of the hashes made the first keystroke un-heading the line.
7815 let mut d = wysiwyg_doc("head_click", "# \n");
7816 d.build_visual_unwrapped();
7817 d.caret = d.vmap.offset_of_pos(0, 0);
7818 d.insert("x");
7819 assert_eq!(d.source, "# x\n");
7820 }
7821
7822 #[test]
7823 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7824 let mut d = wysiwyg_doc("head_enter", "# Title\n");
7825 d.caret = 7; // end of the heading
7826 d.newline();
7827 d.insert("body");
7828 assert_eq!(d.source, "# Title\n\nbody\n");
7829 }
7830
7831 #[test]
7832 fn wysiwyg_enter_continues_a_bullet_list() {
7833 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7834 d.caret = 6; // end of "item"
7835 d.newline();
7836 d.insert("two");
7837 assert_eq!(d.source, "- item\n- two\n");
7838 }
7839
7840 #[test]
7841 fn wysiwyg_enter_increments_an_ordered_list() {
7842 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7843 d.caret = 6; // end of "one"
7844 d.newline();
7845 d.insert("two");
7846 assert_eq!(d.source, "1. one\n2. two\n");
7847 }
7848
7849 #[test]
7850 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7851 // Regression for the "extra newline" left between a list and the paragraph
7852 // below it. Enter, Enter leaves the list on a fresh empty paragraph
7853 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7854 // Backspace should then take the caret cleanly back to the end of the list
7855 // item, `- item\n\nnext`, not delete a single newline and strand it on the
7856 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7857 // no caret can land on. The map is rebuilt between keystrokes exactly as a
7858 // frontend does, since Backspace reads the stop table to place the delete.
7859 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7860 d.caret = 6; // end of "item"
7861 d.newline();
7862 d.build_visual(80);
7863 d.newline(); // leave the list onto a fresh empty paragraph
7864 d.build_visual(80);
7865 assert_eq!(
7866 d.source, "- item\n\n\n\nnext\n",
7867 "double-Enter opens the empty paragraph"
7868 );
7869 d.backspace();
7870 assert_eq!(
7871 d.source, "- item\n\nnext\n",
7872 "one Backspace collapses the whole gap"
7873 );
7874 assert_eq!(
7875 d.caret, 6,
7876 "and lands the caret back at the end of the list item"
7877 );
7878 }
7879
7880 #[test]
7881 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7882 // The stop-wise delete must not over-reach when there is no block boundary
7883 // to cross: two blank lines in a row are one caret stop apart, so pressing
7884 // Enter on an empty line and then Backspace removes exactly the one newline
7885 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7886 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7887 d.caret = 5; // the empty paragraph the first Enter already opened
7888 d.build_visual(80);
7889 d.newline();
7890 d.build_visual(80);
7891 assert_eq!(
7892 d.source, "abc\n\n\n\n",
7893 "Enter on the blank line adds one newline"
7894 );
7895 d.backspace();
7896 assert_eq!(
7897 d.source, "abc\n\n\n",
7898 "Backspace takes back exactly that one newline"
7899 );
7900 }
7901
7902 #[test]
7903 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7904 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7905 d.caret = 6; // end of the empty "- " item
7906 d.newline();
7907 d.insert("p");
7908 assert_eq!(d.source, "- a\n\np\n");
7909 }
7910
7911 #[test]
7912 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7913 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7914 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7915 // not take the list-exit path (which would splice the `- ` away as if
7916 // leaving an empty item); the AST guard sends it to a normal break and
7917 // leaves the underline intact.
7918 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7919 assert!(
7920 d.nodes().iter().any(|n| n.kind == Kind::Heading),
7921 "precondition: twig parses this as a heading, not a list",
7922 );
7923 d.caret = 7; // on the `- ` underline line
7924 d.newline();
7925 assert!(
7926 d.source.contains("- "),
7927 "the setext underline survives, not spliced away as a list item: {:?}",
7928 d.source,
7929 );
7930 }
7931
7932 #[test]
7933 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7934 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7935 d.caret = 7; // end of "abc" inside the fence
7936 d.newline();
7937 d.insert("def");
7938 assert_eq!(d.source, "```\nabc\ndef\n```\n");
7939 }
7940
7941 #[test]
7942 fn wysiwyg_enter_continues_a_block_quote() {
7943 // Enter opens a new *paragraph* inside the quote, not a second line of
7944 // the same one. `> quote\n> more` is a soft break, which under
7945 // `LineFlow::Fold` renders as a space — the keystroke would look like it
7946 // did nothing. The quoted blank line is what makes the break visible, and
7947 // it's the same thing Enter does in running prose.
7948 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7949 d.caret = 7; // end of "quote"
7950 d.newline();
7951 d.insert("more");
7952 assert_eq!(d.source, "> quote\n>\n> more\n");
7953 // Still one quote, now holding two paragraphs — not a quote and a stray
7954 // line that fell out of it.
7955 let quotes = d
7956 .nodes()
7957 .iter()
7958 .filter(|n| n.kind == Kind::BlockQuote)
7959 .count();
7960 assert_eq!(quotes, 1);
7961 }
7962
7963 #[test]
7964 fn set_block_makes_a_heading_at_the_caret() {
7965 let mut d = doc_with("head", "Title\n\nbody\n");
7966 d.caret = 0;
7967 d.set_block(BlockKind::Heading(2));
7968 assert_eq!(d.source, "## Title\n\nbody\n");
7969 d.set_block(BlockKind::Paragraph);
7970 assert_eq!(d.source, "Title\n\nbody\n");
7971 }
7972
7973 // ── block containers (quote / list) ──────────────────────────────────────
7974
7975 #[test]
7976 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7977 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7978 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7979 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7980 // A caret at a line end sits at the doc level; the block is still found.
7981 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7982 }
7983
7984 #[test]
7985 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7986 // Every source line of the paragraph gets its own `> `, so a caret left
7987 // on its old byte offset falls one prefix per line above it too far
7988 // back — inside the markup it just asked for rather than in its word.
7989 assert_eq!(
7990 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7991 "> aaa\n> b|bb\n> ccc\n"
7992 );
7993 }
7994
7995 #[test]
7996 fn toggle_blockquote_works_in_wysiwyg_view() {
7997 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7998 assert_eq!(
7999 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
8000 "> hel|lo\n"
8001 );
8002 assert_eq!(
8003 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
8004 "hel|lo\n"
8005 );
8006 }
8007
8008 #[test]
8009 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
8010 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
8011 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8012 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8013 // The *other* kind converts in place instead of nesting, which is what
8014 // makes the two buttons one three-state control.
8015 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8016 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8017 // Its own kind, over the only item the list holds, takes it off.
8018 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
8019 }
8020
8021 #[test]
8022 fn toggle_list_works_in_wysiwyg_view() {
8023 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
8024 assert_eq!(
8025 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
8026 "1. hel|lo\n"
8027 );
8028 assert_eq!(
8029 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
8030 "- hel|lo\n"
8031 );
8032 assert_eq!(
8033 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
8034 "hel|lo\n"
8035 );
8036 }
8037
8038 #[test]
8039 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
8040 // The selection has to grow with the markup: twig takes a container off
8041 // only a range covering every block it holds, so the second press can
8042 // reverse the first only if the result is what's selected.
8043 let mut d = doc_with("list_sel", "abc\n\ndef\n");
8044 d.select_all();
8045 d.toggle_list(true);
8046 assert_eq!(d.source, "1. abc\n\n2. def\n");
8047 assert_eq!(d.selection(), Some((0, d.source.len())));
8048 d.toggle_list(true);
8049 assert_eq!(d.source, "abc\n\ndef\n");
8050 }
8051
8052 #[test]
8053 fn toggle_blockquote_nests_a_partly_covered_quote() {
8054 // twig's rule: covering only some of a container's blocks nests, because
8055 // taking the quote off would drag its uncovered siblings out with it.
8056 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
8057 d.caret = 2; // in the first quoted paragraph only
8058 d.toggle_blockquote();
8059 assert_eq!(d.source, "> > a\n>\n> b\n");
8060 }
8061
8062 #[test]
8063 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
8064 // A blank line used to be no block for twig to wrap —
8065 // `toggle_block_container` answered `NotFound` — so Quote and the list
8066 // buttons did nothing on the very line the H1 button works on, and leaf
8067 // lent twig a scratch paragraph to wrap and took it back out again.
8068 // twig 3.2.0 opens an empty container there itself, so what is left here
8069 // is where the caret lands: inside the marker that was just written.
8070 let mut d = doc_with("quote_blank", "\nabc\n");
8071 d.caret = 0;
8072 d.toggle_blockquote();
8073 assert_eq!(d.source, "> \nabc\n");
8074 assert_eq!(
8075 d.caret, 2,
8076 "the caret belongs inside the quote it just opened"
8077 );
8078 assert!(d.status.is_none(), "{:?}", d.status);
8079 assert!(d.dirty);
8080
8081 // And the paragraph below is still its own block: an empty container one
8082 // soft break from `abc` would take that paragraph into the quote with it.
8083 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
8084 d.caret = 0;
8085 d.toggle_blockquote();
8086 d.build_visual(80);
8087 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
8088
8089 // The same from the other side: a blank line directly under a paragraph
8090 // earns the blank line an empty block needs, rather than being read as a
8091 // soft break inside that paragraph.
8092 let mut d = doc_with("list_blank_below", "abc\n");
8093 d.caret = 4;
8094 d.toggle_list(false);
8095 assert_eq!(d.source, "abc\n\n- ");
8096 assert_eq!(d.caret, 7);
8097 }
8098
8099 #[test]
8100 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
8101 // The gesture the rendering fix is for. `newline` inside a quote already
8102 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
8103 // spelling — but the two marker lines it adds belonged to no node until
8104 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
8105 // just made drew as plain prose under the quote.
8106 let mut d = wysiwyg_doc("quote_enter", "> a\n");
8107 d.caret = 3; // past `a`, at the end of the quoted line
8108 d.newline();
8109 assert_eq!(d.source, "> a\n>\n> \n");
8110 d.build_visual(80);
8111 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
8112 // And the caret is on the new line, not stranded on the old one.
8113 assert_eq!(d.caret, 8);
8114 }
8115
8116 #[test]
8117 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
8118 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
8119 // now it is twig's single edit. Either way one ⌘z has to put the blank
8120 // line back rather than undoing into a half-built document.
8121 for open in [
8122 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
8123 &|d: &mut Doc| d.toggle_list(false),
8124 &|d: &mut Doc| d.toggle_list(true),
8125 ] {
8126 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
8127 d.caret = 3;
8128 open(&mut d);
8129 assert_ne!(d.source, "a\n\n\n\nb\n");
8130 d.undo();
8131 assert_eq!(d.source, "a\n\n\n\nb\n");
8132 }
8133 }
8134
8135 #[test]
8136 fn a_container_toggle_is_one_undo_step() {
8137 let mut d = doc_with("quote_undo", "hello\n");
8138 d.caret = 3;
8139 d.insert("X"); // a typing run the structural edit must not fold into
8140 d.toggle_blockquote();
8141 assert_eq!(d.source, "> helXlo\n");
8142 d.undo();
8143 assert_eq!(d.source, "helXlo\n");
8144 }
8145
8146 // ── links ────────────────────────────────────────────────────────────────
8147
8148 #[test]
8149 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
8150 let mut d = doc_with("link_sel", "word here\n");
8151 d.anchor = Some(0);
8152 d.caret = 4;
8153 d.insert_link("http://x.dev");
8154 assert_eq!(d.source, "[word](http://x.dev) here\n");
8155 // The text, not the destination — so a second press re-points the link
8156 // the first one made rather than nesting one inside it.
8157 assert_eq!(d.selected_text(), Some("word"));
8158 d.insert_link("http://y.dev");
8159 assert_eq!(d.source, "[word](http://y.dev) here\n");
8160 assert_eq!(d.selected_text(), Some("word"));
8161 }
8162
8163 #[test]
8164 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
8165 let mut d = doc_with("img_caret", "before after\n");
8166 d.caret = 7; // between "before " and "after"
8167 d.insert_image("cat.png", "a cat");
8168 assert_eq!(d.source, "before after\n");
8169 // The caret sits just past the inserted image, nothing selected.
8170 assert_eq!(d.selection(), None);
8171 assert_eq!(d.caret, 7 + "".len());
8172 }
8173
8174 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
8175 /// destination at the first space, so the `format!` this used to be wrote
8176 /// something that was not an image at all — and the reader saw the markup as
8177 /// text. twig owns the spelling now, and moves it into the angle form.
8178 #[test]
8179 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
8180 let mut d = doc_with("img_space", "x\n");
8181 d.caret = 0;
8182 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
8183 assert_eq!(
8184 d.source,
8185 "x\n"
8186 );
8187 // And it reads back as an image pointing at the unescaped path — the angle
8188 // brackets are spelling, not part of the destination.
8189 d.caret = 2;
8190 assert_eq!(
8191 d.image_destination_at_caret(),
8192 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
8193 );
8194 }
8195
8196 /// A `)` in a caption or a filename must not close the image early.
8197 #[test]
8198 fn insert_image_escapes_a_paren_in_either_half() {
8199 let mut d = doc_with("img_paren", "x\n");
8200 d.caret = 0;
8201 d.insert_image("a)b.png", "");
8202 assert_eq!(d.source, "b.png)x\n");
8203 d.caret = 2;
8204 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
8205 }
8206
8207 #[test]
8208 fn insert_image_uses_the_selection_as_alt_text() {
8209 let mut d = doc_with("img_sel", "caption here\n");
8210 d.anchor = Some(0);
8211 d.caret = 7; // "caption"
8212 d.insert_image("p.png", "ignored fallback");
8213 assert_eq!(d.source, " here\n");
8214 }
8215
8216 #[test]
8217 fn insert_image_with_no_alt_leaves_empty_brackets() {
8218 let mut d = doc_with("img_noalt", "\n");
8219 d.caret = 0;
8220 d.insert_image("logo.svg", "");
8221 assert_eq!(d.source, "\n");
8222 }
8223
8224 #[test]
8225 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
8226 // The round trip is the point: it's no use writing markup the reader
8227 // can't pick up again. This is the pair that only holds from twig 2.5.1
8228 // on — before it, the one-line form went in fine and came back as a
8229 // paragraph of raw tags, publishing no media at all.
8230 let mut d = doc_with("vid_rt", "\n");
8231 d.caret = 0;
8232 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
8233 assert_eq!(
8234 d.source,
8235 "<video src=\"clip.mp4\" controls>a clip</video>\n"
8236 );
8237
8238 d.build_visual(80);
8239 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
8240 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
8241 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
8242 assert_eq!(d.vmap.media[0].alt, "a clip");
8243 }
8244
8245 #[test]
8246 fn insert_media_spells_audio_with_its_own_tag() {
8247 let mut d = doc_with("aud_rt", "\n");
8248 d.caret = 0;
8249 d.insert_media(MediaKind::Audio, "take.mp3", "");
8250 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
8251 d.build_visual(80);
8252 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
8253 }
8254
8255 #[test]
8256 fn insert_media_uses_the_selection_as_fallback_text() {
8257 // The same courtesy `insert_image` does with alt: select a caption,
8258 // insert, and the caption labels the thing rather than being replaced.
8259 let mut d = doc_with("vid_sel", "the talk here\n");
8260 d.anchor = Some(0);
8261 d.caret = 8; // "the talk"
8262 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
8263 assert_eq!(
8264 d.source,
8265 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
8266 );
8267 }
8268
8269 #[test]
8270 fn insert_media_with_an_image_kind_is_just_insert_image() {
8271 let mut d = doc_with("img_via_media", "\n");
8272 d.caret = 0;
8273 d.insert_media(MediaKind::Image, "logo.svg", "x");
8274 assert_eq!(d.source, "\n");
8275 }
8276
8277 // ── thematic breaks ─────────────────────────────────────────────────────
8278
8279 /// The node the source parses as at `caret` — what confirms an inserted
8280 /// `---` actually reads back as a rule, not stray text or a setext heading.
8281 ///
8282 /// The *narrowest* node covering the offset. Every ancestor covers it too,
8283 /// and since twig 2.8 that includes the `doc` root, which now carries a real
8284 /// span (it reported none before, so taking the first match used to land on
8285 /// the block by luck and now always answers `"doc"`).
8286 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
8287 d.nodes()
8288 .into_iter()
8289 .filter(|n| n.span.start <= caret && caret < n.span.end)
8290 .min_by_key(|n| n.span.end - n.span.start)
8291 .map(|n| n.kind)
8292 }
8293
8294 #[test]
8295 fn a_task_box_toggles_at_the_caret_and_reads_back() {
8296 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
8297 d.caret = 8; // inside "todo"
8298 assert_eq!(d.task_checked_at_caret(), Some(false));
8299 d.toggle_task_checked();
8300 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
8301 assert_eq!(d.task_checked_at_caret(), Some(true));
8302 d.toggle_task_checked();
8303 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
8304 }
8305
8306 #[test]
8307 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
8308 // The whole reason `toggle_task_at` exists apart from the caret form:
8309 // ticking a box elsewhere must not move the cursor out of what's being
8310 // typed.
8311 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
8312 d.caret = 8; // inside "first"
8313 let second = d.source.find("second").unwrap();
8314 d.toggle_task_at(second);
8315 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
8316 assert_eq!(d.caret, 8, "the caret stayed in the first item");
8317 }
8318
8319 #[test]
8320 fn a_plain_item_gains_and_loses_a_box() {
8321 let mut d = doc_with("task_mint", "- plain\n");
8322 d.caret = 4;
8323 assert_eq!(d.task_checked_at_caret(), None);
8324 d.toggle_task_item();
8325 assert_eq!(d.source, "- [ ] plain\n");
8326 assert_eq!(
8327 d.task_checked_at_caret(),
8328 Some(false),
8329 "a new box arrives unticked"
8330 );
8331 d.toggle_task_item();
8332 assert_eq!(d.source, "- plain\n");
8333 }
8334
8335 #[test]
8336 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
8337 // `set checked` must not silently convert a bullet into a task — that is
8338 // `toggle_task_item`'s job, and twig refuses it here.
8339 let mut d = doc_with("task_none", "- plain\n");
8340 d.caret = 4;
8341 d.toggle_task_checked();
8342 assert_eq!(d.source, "- plain\n", "nothing written");
8343 assert!(
8344 d.status.is_some(),
8345 "the refusal should reach the status line"
8346 );
8347 }
8348
8349 #[test]
8350 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
8351 let mut d = doc_with("task_quote", "> - [ ] nested\n");
8352 d.caret = d.source.find("nested").unwrap();
8353 assert_eq!(d.task_checked_at_caret(), Some(false));
8354 d.toggle_task_checked();
8355 assert_eq!(d.source, "> - [x] nested\n");
8356 }
8357
8358 #[test]
8359 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
8360 // A rule is a block, so twig's `insert_thematic_break` alone lands it
8361 // after the whole paragraph. `split_block` parts the paragraph first and
8362 // the rule is aimed at the *first* half, which is what a rule button is
8363 // understood to do — and what leaf spelled by hand until twig grew both
8364 // halves of the gesture.
8365 let mut d = doc_with("hr_mid", "before after\n");
8366 d.caret = 7; // between "before " and "after"
8367 d.insert_thematic_break();
8368 assert_eq!(d.source, "before \n\n---\n\nafter\n");
8369 assert_eq!(d.selection(), None);
8370 assert_eq!(
8371 kind_at(&mut d, "before \n\n".len()),
8372 Some(Kind::ThematicBreak)
8373 );
8374 }
8375
8376 #[test]
8377 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
8378 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
8379 // and leaf wrote the first into both until twig started spelling it.
8380 let mut md = doc_with("hr_md", "para\n");
8381 md.caret = 2;
8382 md.insert_thematic_break();
8383 assert_eq!(md.source, "pa\n\n---\n\nra\n");
8384
8385 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
8386 dj.caret = 2;
8387 dj.insert_thematic_break();
8388 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
8389 }
8390
8391 #[test]
8392 fn clicking_below_a_final_thematic_break_can_type_after_it() {
8393 let mut d = wysiwyg_doc("hr_final_click", "---\n");
8394 d.build_visual(80);
8395 d.click(d.vmap.num_rows() + 2, 0, false);
8396 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
8397 d.insert("after");
8398 assert_eq!(d.source, "---\nafter");
8399 }
8400
8401 #[test]
8402 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
8403 // The same bytes are two documents. In Markdown ` - b` is a nested item
8404 // and the next one belongs beside it, at its indent. In Djot a list
8405 // marker can't interrupt a paragraph, so those bytes are literal text in
8406 // item `a` and there is only one item — writing ` - ` under it would add
8407 // no item at all, just more text, and the new sibling has to go to
8408 // column zero. Both spellings come out of the *enclosing item's* line.
8409 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
8410 md.caret = "- a\n - b".len();
8411 md.newline();
8412 assert_eq!(md.source, "- a\n - b\n - \n");
8413 assert_eq!(list_items(&mut md), 3);
8414
8415 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
8416 dj.view = View::Wysiwyg;
8417 dj.build_visual(80);
8418 dj.caret = "- a\n - b".len();
8419 dj.newline();
8420 assert_eq!(dj.source, "- a\n - b\n- \n");
8421 assert_eq!(list_items(&mut dj), 2);
8422
8423 // Where Djot's nesting is real — opened by a blank line — the indent is
8424 // reproduced there too, and the two formats agree again.
8425 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
8426 dj.view = View::Wysiwyg;
8427 dj.build_visual(80);
8428 dj.caret = "- a\n\n - b".len();
8429 dj.newline();
8430 assert_eq!(dj.source, "- a\n\n - b\n - \n");
8431 assert_eq!(list_items(&mut dj), 3);
8432 }
8433
8434 #[test]
8435 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
8436 // Tab replaces the line's whole prefix with the one twig spells, so the
8437 // quote markers, the parent's indent and an ordered marker's extra
8438 // column are all its answer rather than leaf's arithmetic.
8439 for (name, body, caret, want) in [
8440 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
8441 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
8442 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
8443 // A checkbox is markup the item's own text wraps past, but a nested
8444 // list may only open at the *list* marker's column — four in from
8445 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
8446 // parses as one item, not two.
8447 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
8448 (
8449 "quoted task",
8450 "> - [ ] a\n> - [ ] b\n",
8451 18,
8452 "> - [ ] a\n> - [ ] b\n",
8453 ),
8454 ] {
8455 let mut doc = wysiwyg_doc(name, body);
8456 doc.caret = caret;
8457 doc.indent();
8458 assert_eq!(doc.source, want, "{name}");
8459 // The nesting is real, not just indented text.
8460 assert_eq!(list_items(&mut doc), 2, "{name}");
8461 }
8462 }
8463
8464 #[test]
8465 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
8466 // The same bytes, the two formats disagreeing, and a gesture that used
8467 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
8468 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
8469 // so those bytes are literal text inside item `a` — there is nothing to
8470 // outdent, and treating them as a marker turned one item into two, a
8471 // structural edit from a keystroke that should delete one character.
8472 //
8473 // twig's `line_prefix` is what tells them apart: it reports the marker
8474 // on the Markdown line and nothing on the Djot one, which is a
8475 // continuation. No byte scan can reach that answer.
8476 let src = "- a\n - b\n";
8477 let at = "- a\n - ".len();
8478
8479 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
8480 md.view = View::Wysiwyg;
8481 md.build_visual(80);
8482 md.caret = at;
8483 md.backspace();
8484 assert_eq!(md.source, "- a\n- b\n");
8485 assert_eq!(list_items(&mut md), 2);
8486
8487 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
8488 dj.view = View::Wysiwyg;
8489 dj.build_visual(80);
8490 dj.caret = at;
8491 dj.backspace();
8492 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
8493 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
8494 }
8495
8496 #[test]
8497 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
8498 // Leaf used to spell the next item from the marker bytes it scanned, and
8499 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
8500 // and dropped out of the checklist. twig reproduces the whole
8501 // continuation, and a fresh item is always unticked however the one above
8502 // it stands.
8503 for (name, body, want) in [
8504 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
8505 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
8506 ] {
8507 let mut doc = wysiwyg_doc(name, body);
8508 doc.caret = body.trim_end_matches('\n').len();
8509 doc.newline();
8510 assert_eq!(doc.source, want, "{name}");
8511 // Both items are checklist items — the new one is a box, not the
8512 // plain bullet the old marker scan left behind — and it is unticked
8513 // whichever way the one above it faces.
8514 let boxes: Vec<Option<bool>> = doc
8515 .nodes()
8516 .iter()
8517 .filter(|n| n.kind == Kind::TaskListItem)
8518 .map(|n| n.checked)
8519 .collect();
8520 assert_eq!(boxes.len(), 2, "{name}");
8521 assert_eq!(boxes[1], Some(false), "{name}");
8522 }
8523 }
8524
8525 #[test]
8526 fn a_split_takes_the_space_the_caret_was_in_front_of() {
8527 // Splicing a break at the caret strands the space the words were parted
8528 // at on the head of the second block, where it reads as an indent nobody
8529 // typed. twig's split consumes it.
8530 for (name, body, caret, want) in [
8531 ("para", "one two\n", 3, "one\n\ntwo\n"),
8532 ("item", "- one two\n", 5, "- one\n- two\n"),
8533 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
8534 // A heading takes leaf's own path, which has to match.
8535 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
8536 ] {
8537 let mut doc = wysiwyg_doc(name, body);
8538 doc.caret = caret;
8539 doc.newline();
8540 assert_eq!(doc.source, want, "{name}");
8541 }
8542 }
8543
8544 #[test]
8545 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
8546 // The one place leaf keeps its own break: `split_block` repeats the `#`,
8547 // and Enter after a title is how the body under it is asked for.
8548 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
8549 doc.caret = "# Title".len();
8550 doc.newline();
8551 doc.insert("body");
8552 assert_eq!(doc.source, "# Title\n\nbody\n");
8553 assert_eq!(
8554 doc.nodes()
8555 .iter()
8556 .filter(|n| n.kind == Kind::Heading)
8557 .count(),
8558 1
8559 );
8560 }
8561
8562 #[test]
8563 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
8564 // A quoted item's marker doesn't open its line, so a scan that starts at
8565 // column zero finds a `>` where it wanted a bullet, calls the line "not a
8566 // list" and hands Enter to the plain-quote branch — which writes `> ` and
8567 // drops the list. The next item has to carry the whole prefix.
8568 for (name, body, want) in [
8569 ("flat", "> - a\n", "> - a\n> - \n"),
8570 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8571 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8572 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
8573 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
8574 ] {
8575 let mut doc = wysiwyg_doc(name, body);
8576 doc.caret = body.trim_end_matches('\n').len();
8577 doc.newline();
8578 assert_eq!(doc.source, want, "{name}");
8579 // The marker isn't just spelled right, it parses as an item.
8580 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
8581 }
8582 }
8583
8584 #[test]
8585 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
8586 // Double-Enter exits the list. Unquoted that means a blank line, but a
8587 // *bare* blank line would end the quote too and drop the caret out of it,
8588 // so the separator keeps its `>` and the caret's line keeps its `> `.
8589 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
8590 doc.caret = "> - a\n> - ".len();
8591 doc.newline();
8592 assert_eq!(doc.source, "> - a\n>\n> \n");
8593 assert_eq!(list_items(&mut doc), 1);
8594 // What "still in the quote" means for the next keystroke: the caret sits
8595 // behind the prefix, and what's typed there lands inside the quote as a
8596 // paragraph of its own — not as more of item `a`.
8597 doc.insert("x");
8598 assert_eq!(doc.source, "> - a\n>\n> x\n");
8599 assert!(
8600 doc.editor
8601 .ancestors_at(doc.caret - 1)
8602 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
8603 );
8604 }
8605
8606 #[test]
8607 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
8608 // The marker is hidden block markup, so Backspace over it is structural —
8609 // but only the marker is the list's. Splicing from the line start would
8610 // take the `>` with it and silently unquote the line.
8611 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
8612 doc.caret = "> - ".len();
8613 doc.backspace();
8614 assert_eq!(doc.source, "> a\n");
8615 assert_eq!(list_items(&mut doc), 0);
8616
8617 // A nested one outdents instead, moving the bullet within the quote
8618 // rather than moving the quote.
8619 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
8620 doc.caret = "> - a\n> - ".len();
8621 doc.backspace();
8622 assert_eq!(doc.source, "> - a\n> - b\n");
8623 assert_eq!(list_items(&mut doc), 2);
8624 }
8625
8626 #[test]
8627 fn only_a_bare_paragraph_is_parted_around_the_caret() {
8628 // The split is deliberately narrow. Parting a fenced block would leave
8629 // two fences with a rule between them, and parting a list item would
8630 // mint an item nobody asked for on the way to a rule that lands after
8631 // the list either way — so both keep the whole block intact and take the
8632 // rule after it. A caret in a quote is likewise left alone.
8633 for (name, body, caret, want) in [
8634 (
8635 "code",
8636 "```\nfn x() {}\n```\n",
8637 8,
8638 "```\nfn x() {}\n```\n\n---\n",
8639 ),
8640 ("list", "- one two\n", 6, "- one two\n\n---\n"),
8641 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
8642 ] {
8643 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
8644 d.caret = caret;
8645 d.insert_thematic_break();
8646 assert_eq!(d.source, want, "{name}: the block should stay whole");
8647 }
8648 }
8649
8650 #[test]
8651 fn insert_thematic_break_replaces_the_selection() {
8652 // Now that the rule lands *at* the caret again, replacing the selection
8653 // is coherent once more: the text goes, and the rule takes its place.
8654 // The space the deletion left leading the second half is consumed by the
8655 // split rather than opening the new paragraph with it.
8656 let mut d = doc_with("hr_sel", "one two three\n");
8657 d.anchor = Some(4);
8658 d.caret = 7; // "two"
8659 d.insert_thematic_break();
8660 assert_eq!(d.source, "one \n\n---\n\nthree\n");
8661 assert_eq!(d.selection(), None);
8662 }
8663
8664 #[test]
8665 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
8666 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
8667 // because writing `---` into one is code, not a rule — twig now walks out
8668 // to the block that owns the caret's line, so there is nothing to refuse.
8669 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
8670 code.caret = 5; // inside the fenced code
8671 code.insert_thematic_break();
8672 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
8673 assert_eq!(code.status, None, "no refusal to report any more");
8674
8675 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
8676 table.caret = 3; // in the header row
8677 table.insert_thematic_break();
8678 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
8679 }
8680
8681 #[test]
8682 fn insert_thematic_break_in_a_list_item_ends_the_list() {
8683 // The un-indented rule cannot continue the list, so it closes the list
8684 // and lands at the top level rather than nested inside it.
8685 let mut d = doc_with("hr_list", "- one\n- two\n");
8686 d.caret = "- one\n- tw".len(); // mid "two"
8687 d.insert_thematic_break();
8688 d.build_visual(80);
8689 let rule_at = d.source.find("---").unwrap();
8690 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8691 assert!(
8692 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8693 && n.span.start <= rule_at
8694 && rule_at < n.span.end),
8695 "the rule must not be nested inside the list"
8696 );
8697 }
8698
8699 #[test]
8700 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8701 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8702 // which is the document the gesture was actually asked for.
8703 let mut d = doc_with("hr_quote", "> hello\n");
8704 d.caret = 4; // inside the quoted text
8705 d.insert_thematic_break();
8706 assert_eq!(d.source, "> hello\n>\n> ---\n");
8707 d.build_visual(80);
8708 let rule_at = d.source.find("---").unwrap();
8709 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8710 assert!(
8711 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8712 && n.span.start <= rule_at
8713 && rule_at < n.span.end),
8714 "the rule belongs to the quote it was asked for"
8715 );
8716 }
8717
8718 // ── typing against a block picture ────────────────────────────────────────
8719
8720 /// A rendered-view document with the caret parked on one of the picture's two
8721 /// stops, and the map already built — the state a frontend is in between
8722 /// drawing a frame and the next keystroke.
8723 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8724 let mut d = doc_in(View::Wysiwyg, name, src);
8725 d.build_visual_unwrapped();
8726 let start = src.find("".len(),
8730 };
8731 d
8732 }
8733
8734 /// The block media the map publishes, after rebuilding it — "is this still a
8735 /// picture, or has it become a line of text with an image in it?"
8736 fn media_count(d: &mut Doc) -> usize {
8737 d.build_visual_unwrapped();
8738 d.vmap.media.len()
8739 }
8740
8741 #[test]
8742 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8743 // The accident this prevents: tap the blank page under a photo (which
8744 // lands on the picture's trailing stop), type, and `xy` is a
8745 // paragraph with an *inline* image — the photo stops being drawn.
8746 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
8747 d.insert("xy");
8748 assert_eq!(d.source, "hi\n\n\n\nxy\n");
8749 assert_eq!(media_count(&mut d), 1, "still a picture");
8750 }
8751
8752 #[test]
8753 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8754 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
8755 d.insert("xy");
8756 assert_eq!(d.source, "hi\n\nxy\n\n\n");
8757 assert_eq!(media_count(&mut d), 1);
8758 }
8759
8760 #[test]
8761 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8762 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
8763 d.insert("x");
8764 assert_eq!(d.source, "x\n\n\n");
8765 assert_eq!(media_count(&mut d), 1);
8766 }
8767
8768 #[test]
8769 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8770 // The opened paragraph is part of the keystroke, not an edit the writer
8771 // made — so it undoes with the character, not a step later.
8772 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
8773 d.insert("x");
8774 assert_eq!(d.source, "hi\n\n\n\nx\n");
8775 d.undo();
8776 assert_eq!(d.source, "hi\n\n\n");
8777 }
8778
8779 #[test]
8780 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
8781 // ⌘V dissolves the picture exactly as a keystroke does.
8782 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
8783 d.paste("pasted");
8784 assert_eq!(d.source, "hi\n\n\n\npasted\n");
8785 assert_eq!(media_count(&mut d), 1);
8786 }
8787
8788 #[test]
8789 fn typing_beside_an_inline_image_is_ordinary_editing() {
8790 // An inline image has no placeholder row and no stops of its own. Opening
8791 // a paragraph mid-sentence would be the bug, not the fix.
8792 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
8793 d.build_visual_unwrapped();
8794 d.caret = "see ".len();
8795 d.insert("!");
8796 assert_eq!(d.source, "see ! here\n");
8797 }
8798
8799 #[test]
8800 fn source_view_types_raw_markup_against_an_image_untouched() {
8801 // Source view is for writing the markup itself; a break inserted behind
8802 // the writer's back there would be the editor arguing with them.
8803 let mut d = doc_in(View::Source, "pic_src", "\n");
8804 d.caret = "".len();
8805 d.insert("x");
8806 assert_eq!(d.source, "x\n");
8807 }
8808
8809 #[test]
8810 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
8811 // A selection is replaced, not joined into, so there is nothing to
8812 // protect: the range takes the picture with it.
8813 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
8814 d.anchor = Some(d.caret);
8815 d.caret = d.source.find("".len();
8816 d.insert("x");
8817 assert_eq!(d.source, "hi\n\nx\n");
8818 }
8819
8820 #[test]
8821 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
8822 // What this actually cost: a real vault's photo, to one stray Backspace.
8823 // The caret past `` was deleting the closing paren — invisible
8824 // in the rendered view — and the photo became the text `\n", MediaStop::After);
8826 d.backspace();
8827 assert_eq!(d.source, "hi\n");
8828 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
8829 d.undo();
8830 assert_eq!(
8831 d.source, "hi\n\n\n",
8832 "and comes back in one piece"
8833 );
8834 }
8835
8836 #[test]
8837 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
8838 // Deleting the break here would join the picture to the paragraph above,
8839 // where it is an *inline* image and stops being drawn. Step over the
8840 // boundary; the next press deletes in the paragraph the caret reached.
8841 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
8842 d.backspace();
8843 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
8844 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8845 d.backspace();
8846 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
8847 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8848 }
8849
8850 #[test]
8851 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8852 // The mirror. A byte-step here eats the `!` and leaves a link.
8853 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
8854 d.delete_forward();
8855 assert_eq!(d.source, "hi\n\nbye\n");
8856 assert_eq!(media_count(&mut d), 0);
8857 }
8858
8859 #[test]
8860 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8861 let mut d = doc_at_picture(
8862 "pic_del_after",
8863 "hi\n\n\n\nbye\n",
8864 MediaStop::After,
8865 );
8866 d.delete_forward();
8867 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
8868 assert_eq!(
8869 d.caret,
8870 d.source.find("bye").unwrap(),
8871 "the caret stepped down to `bye`"
8872 );
8873 }
8874
8875 #[test]
8876 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8877 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
8878 d.backspace();
8879 assert_eq!(d.source, "\n");
8880 assert_eq!(media_count(&mut d), 0);
8881 }
8882
8883 #[test]
8884 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8885 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8886 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
8887 d.delete_word_back();
8888 assert_eq!(d.source, "hi there\n");
8889
8890 // And in front of one it runs *through* the paragraph break into the
8891 // prose above, which merges the picture inline — so it steps out first,
8892 // and the second press deletes the word it was aimed at.
8893 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
8894 d.delete_word_back();
8895 assert_eq!(d.source, "hi there\n\n\n");
8896 d.delete_word_back();
8897 assert_eq!(
8898 d.source, "hi \n\n\n",
8899 "the word above went, the picture stayed"
8900 );
8901 assert_eq!(media_count(&mut d), 1);
8902 }
8903
8904 #[test]
8905 fn source_view_deletes_raw_markup_against_an_image_untouched() {
8906 let mut d = doc_in(View::Source, "pic_src_del", "\n");
8907 d.caret = "".len();
8908 d.backspace();
8909 assert_eq!(d.source, ";
8910 }
8911
8912 #[test]
8913 fn image_destination_at_caret_reads_the_image_under_the_caret() {
8914 let mut d = doc_with("img_read", "\n");
8915 d.caret = 3; // inside the image markup
8916 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8917 // Past the image, the caret is in no image.
8918 d.caret = "".len();
8919 assert_eq!(d.image_destination_at_caret(), None);
8920 }
8921
8922 #[test]
8923 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8924 // The image is one placeholder row by default, and `set_media_rows` grows
8925 // it to the height the frontend measured: the label row plus blank
8926 // `decoration` fillers that hold the vertical space a raster is drawn into.
8927 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
8928 assert_eq!(d.vmap.media.len(), 1);
8929 let img_row = d.vmap.media[0].rows_span.start;
8930 assert_eq!(
8931 d.vmap.media[0].rows_span,
8932 img_row..img_row + 1,
8933 "default is one row"
8934 );
8935
8936 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8937 d.build_visual(80);
8938 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8939 let span = d.vmap.media[0].rows_span.clone();
8940 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8941 // The label row carries the mark and its glyphs; the three below are blank
8942 // decoration — drawn, but no caret and no text.
8943 assert!(
8944 d.vmap.rows[span.start].media.is_some(),
8945 "mark rides the first row"
8946 );
8947 for r in (span.start + 1)..span.end {
8948 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8949 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8950 assert!(
8951 d.vmap.rows[r].media.is_none(),
8952 "only the first row is marked"
8953 );
8954 }
8955 }
8956
8957 #[test]
8958 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8959 // The extra rows are pure spacers: the caret's only homes stay the stop in
8960 // front of the image and the one just past it, so walking the document top
8961 // to bottom visits the same offsets whether the image is 1 row or 5.
8962 let body = "ab\n\n\n\ncd\n";
8963 let stops_at = |rows: usize| -> Vec<usize> {
8964 let mut d = wysiwyg_doc("img_stops", body);
8965 if rows > 1 {
8966 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8967 d.build_visual(80);
8968 }
8969 d.caret = 0;
8970 let mut seen = vec![d.caret];
8971 loop {
8972 d.move_right(false);
8973 if *seen.last().unwrap() == d.caret {
8974 break;
8975 }
8976 seen.push(d.caret);
8977 }
8978 seen
8979 };
8980 assert_eq!(
8981 stops_at(1),
8982 stops_at(5),
8983 "reserving rows must not add stops"
8984 );
8985 }
8986
8987 #[test]
8988 fn insert_link_repoints_the_link_at_a_bare_caret() {
8989 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8990 d.caret = 3; // in the link's text, nothing selected
8991 d.insert_link("http://y.dev");
8992 assert_eq!(d.source, "[word](http://y.dev)\n");
8993 assert_eq!(d.selected_text(), Some("word"));
8994 }
8995
8996 #[test]
8997 fn insert_link_on_an_empty_range_autolinks_a_url() {
8998 // A link with no text of its own is an autolink, and twig spells it —
8999 // `<…>` is the canonical form and needs no text typed into it, so the
9000 // caret lands after it rather than selecting a finished link.
9001 let mut d = doc_with("link_empty", "\n");
9002 d.caret = 0;
9003 d.insert_link("http://x.dev");
9004 assert_eq!(d.source, "<http://x.dev>\n");
9005 assert_eq!(d.selection(), None);
9006 assert_eq!(d.caret, 14);
9007 }
9008
9009 #[test]
9010 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
9011 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
9012 // in Markdown, so a destination that can't autolink doubles as the text
9013 // instead — which is then selected, ready to be typed over.
9014 let mut d = doc_with("link_rel", "\n");
9015 d.caret = 0;
9016 d.insert_link("./notes.md");
9017 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
9018 assert_eq!(d.selection(), Some((1, 11)));
9019 d.insert("Notes");
9020 assert_eq!(d.source, "[Notes](./notes.md)\n");
9021 }
9022
9023 #[test]
9024 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
9025 // The autolink's text is its URL, so re-pointing replaces the whole
9026 // node — the caret must not splice a second link inside the first.
9027 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
9028 d.caret = 10;
9029 d.insert_link("https://y.dev");
9030 assert_eq!(d.source, "see <https://y.dev> ok\n");
9031 }
9032
9033 #[test]
9034 fn code_language_reads_and_edits_through_the_fence() {
9035 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
9036 d.caret = 10; // inside the code body
9037 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
9038 assert!(d.caret_in_fenced_code());
9039
9040 d.set_code_language("python");
9041 assert!(
9042 d.source.starts_with("```python\n"),
9043 "source: {:?}",
9044 d.source
9045 );
9046 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
9047
9048 // Clearing it leaves a bare fence and no label.
9049 d.set_code_language("");
9050 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
9051 assert_eq!(d.code_language_at_caret(), None);
9052
9053 // A caret outside any code block edits nothing.
9054 let mut p = doc_with("code_lang_none", "just prose\n");
9055 assert!(!p.caret_in_fenced_code());
9056 p.set_code_language("rust");
9057 assert_eq!(p.source, "just prose\n");
9058 }
9059
9060 #[test]
9061 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
9062 // Markdown's info string ends at whitespace, so `two words` would write
9063 // a fence that reads back with a different language than the one asked
9064 // for. twig refuses it; leaf reports that and leaves the source alone.
9065 // The old splice trimmed the ends and wrote whatever was left.
9066 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
9067 d.caret = 10;
9068 d.set_code_language("two words");
9069 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
9070 assert!(d.status.is_some(), "the refusal should be reported");
9071 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
9072 }
9073
9074 #[test]
9075 fn link_destination_at_caret_reads_both_spellings() {
9076 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
9077 d.caret = 5;
9078 assert_eq!(
9079 d.link_destination_at_caret().as_deref(),
9080 Some("https://x.dev")
9081 );
9082 d.caret = 0;
9083 assert_eq!(d.link_destination_at_caret(), None);
9084
9085 // An autolink has no `destination`; its text is the URL.
9086 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
9087 a.caret = 10;
9088 assert_eq!(
9089 a.link_destination_at_caret().as_deref(),
9090 Some("https://x.dev")
9091 );
9092 a.caret = 21;
9093 assert_eq!(a.link_destination_at_caret(), None);
9094 }
9095
9096 #[test]
9097 fn locate_finds_the_block_a_declared_id_names() {
9098 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
9099 // verse. The locator has to land on the *verse*, which is the whole
9100 // reason a link carries one.
9101 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
9102 {#v2}\nYea, I make a record in the language of my father.\n";
9103 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9104 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
9105 assert_eq!(
9106 d.source[v2.start..v2.end].trim_end(),
9107 "Yea, I make a record in the language of my father."
9108 );
9109 // The attribute line is not part of it: `start` is a place to put a
9110 // caret, and `{#v2}` is markup the caret has no business landing in.
9111 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
9112 assert_eq!(d.locate("v99"), None);
9113 }
9114
9115 #[test]
9116 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
9117 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
9118 // Markdown heading is literal text. So `#the-second-part` can only be
9119 // the heading's own words, which is the rule every Markdown renderer
9120 // already follows and therefore the one a link was authored against.
9121 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
9122 let mut d = doc_with("locate_md", src);
9123 let hit = d.locate("the-second-part").expect("the heading's slug");
9124 assert!(d.source[hit.start..].starts_with("## The Second Part"));
9125 // Bounded by the next heading that isn't under it, so a peek shows the
9126 // section rather than only its title.
9127 assert_eq!(
9128 &d.source[hit.start..hit.end],
9129 "## The Second Part\n\nbody\n\n"
9130 );
9131
9132 // A subsection does not end its parent: `# Title` runs to `## Third`'s
9133 // sibling only because there is no other `#`, so it covers the lot.
9134 let title = d.locate("title").expect("the top heading");
9135 assert_eq!(title.end, d.source.len());
9136 }
9137
9138 #[test]
9139 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
9140 // djot mints `Some-Heading-Here`; a link to it is written
9141 // `#some-heading-here` by nearly everything that writes links. Both
9142 // spellings are one question.
9143 let src = "## Some Heading Here\n\nbody\n";
9144 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9145 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
9146 let slugged = d.locate("some-heading-here").expect("the link's spelling");
9147 assert_eq!(exact, slugged);
9148 // The section, not the heading line — there is more to show than a title.
9149 assert_eq!(&d.source[exact.start..exact.end], src);
9150 }
9151
9152 #[test]
9153 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
9154 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
9155 assert_eq!(d.locate(""), None);
9156 assert_eq!(d.locate(" "), None);
9157 // All punctuation: it names nothing, and must not be read as "match the
9158 // first heading whose slug is also empty".
9159 assert_eq!(d.locate("!!!"), None);
9160 }
9161
9162 #[test]
9163 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
9164 // The document's mistake, and the answer every other anchor
9165 // implementation gives — the alternative is for a link to mean whichever
9166 // of the two a walk happened to reach first.
9167 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
9168 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9169 let hit = d.locate("dup").expect("the first `{#dup}`");
9170 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
9171 }
9172
9173 #[test]
9174 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
9175 // The button's whole job: a reference where the caret was, a definition
9176 // to give it meaning, and the caret waiting in the empty note so the
9177 // next keystroke is the note's first word.
9178 let mut d = doc_with("fn_insert", "A claim and more.\n");
9179 d.caret = 7; // just past "A claim"
9180 d.insert_footnote();
9181 assert!(
9182 d.source.starts_with("A claim[^1] and more."),
9183 "{:?}",
9184 d.source
9185 );
9186 assert!(
9187 d.source.contains("[^1]:"),
9188 "the definition too: {:?}",
9189 d.source
9190 );
9191 assert_eq!(d.status, None);
9192
9193 let reference = d.source.find("[^1]").unwrap();
9194 let note = d
9195 .footnote_at(reference + 2)
9196 .expect("the reference just written");
9197 assert_eq!(note.label, "1");
9198 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
9199 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
9200 // …and typing there is typing into the note, not near it.
9201 d.insert("the note");
9202 assert_eq!(
9203 d.footnote_at(reference + 2).and_then(|f| f.text),
9204 Some("the note".to_string())
9205 );
9206 }
9207
9208 #[test]
9209 fn insert_footnote_numbers_past_the_notes_already_written() {
9210 // A second press must not hand back a label somebody else is using: twig
9211 // reuses a defined label rather than appending a rival definition, so a
9212 // repeat of `1` would quietly point the new reference at the old note.
9213 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
9214 d.caret = 7; // past `[^1]`, before " two."
9215 d.insert_footnote();
9216 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
9217 assert_eq!(d.source.matches("[^2]:").count(), 1);
9218 }
9219
9220 #[test]
9221 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
9222 // `[^2]` with no definition is still a 2 that means something to whoever
9223 // wrote it — stepping over it would mint a note for their reference. A
9224 // word label takes no number, so it blocks none.
9225 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
9226 d.caret = d.source.find(" c").unwrap();
9227 d.insert_footnote();
9228 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
9229 assert!(
9230 d.source.starts_with("a[^2] b[^why][^1] c"),
9231 "{:?}",
9232 d.source
9233 );
9234 }
9235
9236 #[test]
9237 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
9238 // A reference annotates the words before it. Consuming the selection —
9239 // which is what an insert normally does — would delete the very claim
9240 // the author selected in order to footnote.
9241 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
9242 d.anchor = Some(2);
9243 d.caret = 7; // "claim" selected
9244 d.insert_footnote();
9245 assert!(
9246 d.source.starts_with("A claim[^1] and more."),
9247 "{:?}",
9248 d.source
9249 );
9250 }
9251
9252 #[test]
9253 fn a_note_just_written_still_knows_where_its_reference_is() {
9254 // The authoring loop in one test: press the button, type the note, ask to
9255 // go back. The caret ends at the note's last byte — which is the *end* of
9256 // the definition's span, the one offset the query used to exclude — so
9257 // this is where the round trip either works or doesn't.
9258 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
9259 d.caret = 7;
9260 d.insert_footnote();
9261 d.insert("the note");
9262 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
9263 let back = d
9264 .footnote_definition_at_caret()
9265 .expect("still in the note we just typed");
9266 assert_eq!(back.label, "1");
9267 // …and following it lands on the reference's label, where a reader's
9268 // return leg lands.
9269 assert_eq!(back.offset, Some(9));
9270 assert_eq!(&d.source[9..10], "1");
9271 }
9272
9273 #[test]
9274 fn insert_footnote_takes_one_undo_for_both_halves() {
9275 // twig writes the pair as a single edit; the point of that is here.
9276 let before = "A claim and more.\n";
9277 let mut d = doc_with("fn_insert_undo", before);
9278 d.caret = 7;
9279 d.insert_footnote();
9280 assert_ne!(d.source, before);
9281 d.undo();
9282 assert_eq!(d.source, before, "one undo takes back both halves");
9283 }
9284
9285 #[test]
9286 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
9287 // HTML is authorable — it spells the inline marks — and has no footnote.
9288 // The refusal says so rather than writing brackets that would render as
9289 // brackets.
9290 let src = "<p>A claim.</p>\n";
9291 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
9292 assert!(!Capabilities::of(Format::Html).footnote);
9293 d.caret = 5;
9294 d.insert_footnote();
9295 assert_eq!(d.source, src, "nothing written");
9296 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
9297 }
9298
9299 #[test]
9300 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
9301 // The empty body is the one place this could go wrong: the definition
9302 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
9303 // byte early would draw up in the paragraph above the note it belongs to.
9304 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
9305 d.place_caret(7, false);
9306 d.insert_footnote();
9307 d.build_visual(80); // the frame a frontend draws after the edit
9308 assert_eq!(
9309 d.vmap.snap_to_stop(d.caret),
9310 d.caret,
9311 "the caret sits on a stop"
9312 );
9313 let (row, _) = d.caret_pos();
9314 assert!(
9315 drawn_rows(&d)[row].contains("[1]"),
9316 "the caret is on the note's row, not above it: {:?}",
9317 drawn_rows(&d)
9318 );
9319 }
9320
9321 #[test]
9322 fn footnote_at_caret_resolves_a_reference_to_its_note() {
9323 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
9324 // blank line, as one has to.
9325 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
9326 d.caret = 9;
9327 let f = d
9328 .footnote_at_caret()
9329 .expect("the caret stands in a reference");
9330 assert_eq!(f.label, "1");
9331 assert_eq!(f.text.as_deref(), Some("the note"));
9332 // The offset points at the note's first word, not at the definition's
9333 // `[` — the marker is decoration with no caret stop on it.
9334 assert_eq!(f.offset, Some(29));
9335 assert_eq!(&d.source[29..37], "the note");
9336 // …and `end` closes the range, so a frontend can ask which rendered rows
9337 // the note occupies rather than re-deriving them from the text.
9338 assert_eq!(f.end, Some(37));
9339 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
9340 }
9341
9342 /// Two definitions in a row: each is its own note, and neither reaches into
9343 /// the other.
9344 ///
9345 /// A djot definition's span used to run past the blank line into the first
9346 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
9347 /// offsets named the *next* note's rows too, showing a reader two footnotes
9348 /// when they had asked about one. twig 3.1 ends the span after the block's
9349 /// own last line; the test outlives the workaround leaf carried for it.
9350 #[test]
9351 fn footnote_at_stops_a_note_at_the_definition_after_it() {
9352 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
9353 for format in [Format::Markdown, Format::Djot] {
9354 let mut d = Doc::from_source(src.to_string(), format).unwrap();
9355 d.caret = 7;
9356 let f = d.footnote_at_caret().expect("a reference");
9357 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
9358 assert_eq!(
9359 &src[f.offset.unwrap()..f.end.unwrap()],
9360 "first note.",
9361 "in {format:?}"
9362 );
9363 }
9364 }
9365
9366 /// The other side of that boundary: a blank line *inside* a definition is
9367 /// interior to it, and the note keeps its second paragraph.
9368 ///
9369 /// This is what the old body scan cost. It stopped at the first line not
9370 /// indented under the note — a blank line is not — so a two-paragraph note
9371 /// came back as its first paragraph, and "go to note" framed half of it.
9372 /// Reading the span twig gives is both simpler and right.
9373 #[test]
9374 fn footnote_at_keeps_a_notes_second_paragraph() {
9375 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
9376 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9377 d.caret = 7;
9378 let f = d.footnote_at_caret().expect("a reference");
9379 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
9380 // And it stops there — `After.` is the next block, not more note.
9381 assert_eq!(
9382 &src[f.offset.unwrap()..f.end.unwrap()],
9383 f.text.as_deref().unwrap()
9384 );
9385 assert!(!f.text.as_deref().unwrap().contains("After"));
9386 }
9387
9388 #[test]
9389 fn footnote_at_bounds_a_note_whose_body_is_empty() {
9390 // `[^1]:` with nothing after it. The range is empty rather than
9391 // inverted, and still points inside the definition — which is what keeps
9392 // a frontend's row lookup from walking off into the block above.
9393 let src = "A claim[^1].\n\n[^1]:\n";
9394 let mut d = doc_with("fn_empty_body", src);
9395 d.caret = 9;
9396 let f = d.footnote_at_caret().expect("a reference");
9397 assert_eq!(f.text.as_deref(), Some(""));
9398 assert_eq!(f.offset, f.end, "an empty note is an empty range");
9399 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
9400 }
9401
9402 #[test]
9403 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
9404 let mut d = doc_with(
9405 "fn_at_caret_none",
9406 "A claim[^1] and more.\n\n[^1]: the note\n",
9407 );
9408 d.caret = 2; // in the prose
9409 assert_eq!(d.footnote_at_caret(), None);
9410 }
9411
9412 #[test]
9413 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
9414 // The two are deliberately separate: a reference names a note in this
9415 // document, a link names somewhere to leave for, and answering one with
9416 // the other is what made a reference click do nothing at all.
9417 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
9418 d.caret = 3; // the `1` of `[^1]`
9419 assert!(d.footnote_at_caret().is_some());
9420 assert_eq!(
9421 d.link_destination_at_caret(),
9422 None,
9423 "a reference is not a link"
9424 );
9425
9426 d.caret = 10; // inside the link's label
9427 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
9428 assert_eq!(
9429 d.link_destination_at_caret().as_deref(),
9430 Some("https://x.dev")
9431 );
9432 }
9433
9434 #[test]
9435 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
9436 // A `[^99]` the document never defines is a real state — a note deleted
9437 // out from under its reference — and the label is what lets a frontend
9438 // say so. `None` here would be indistinguishable from "not on a
9439 // reference", which is the wrong thing to tell a reader.
9440 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
9441 d.caret = 9;
9442 let f = d
9443 .footnote_at_caret()
9444 .expect("the reference is still a reference");
9445 assert_eq!(f.label, "99");
9446 assert_eq!(f.text, None);
9447 assert_eq!(f.offset, None);
9448 }
9449
9450 #[test]
9451 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
9452 // Labels are not always numbers, and a note's body runs past its first
9453 // line — the indented continuation belongs to the note, so it comes back
9454 // with it (source bytes, verbatim, as documented).
9455 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
9456 let mut d = doc_with("fn_word_label", src);
9457 d.caret = 6;
9458 let f = d
9459 .footnote_at_caret()
9460 .expect("the caret stands in a reference");
9461 assert_eq!(f.label, "note");
9462 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
9463 }
9464
9465 #[test]
9466 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
9467 // The point of the offset form: a pointer hovering a reference asks what
9468 // note it names, and must not drag the caret along to ask.
9469 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
9470 d.caret = 0;
9471 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
9472 assert_eq!(f.label, "1");
9473 assert_eq!(f.text.as_deref(), Some("the note"));
9474 assert_eq!(d.caret, 0, "asking must not move the caret");
9475 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
9476 }
9477
9478 #[test]
9479 fn footnote_definition_at_caret_points_back_at_the_reference() {
9480 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
9481 // the caret can rest on — is at 9.
9482 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
9483 d.caret = 30; // inside the note's body
9484 let f = d
9485 .footnote_definition_at_caret()
9486 .expect("the caret stands in a definition");
9487 assert_eq!(f.label, "1");
9488 assert_eq!(f.offset, Some(9));
9489 assert_eq!(&d.source[7..11], "[^1]");
9490 }
9491
9492 #[test]
9493 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
9494 // The two legs have to meet: wherever `footnote_at` sends the caret, the
9495 // definition query must answer for — otherwise arriving at a note leaves
9496 // the reader somewhere the way back isn't offered.
9497 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
9498 let mut d = doc_with("fn_def_marker", src);
9499 let landed = d.footnote_at(9).unwrap().offset.unwrap();
9500 assert_eq!(
9501 d.footnote_definition_at(landed).and_then(|f| f.offset),
9502 Some(9),
9503 "the note a reference sends you to offers the way back"
9504 );
9505 }
9506
9507 #[test]
9508 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
9509 // The two queries answer for disjoint places, which is what lets one
9510 // gesture mean "down to the note" in one and "back up" in the other
9511 // without either having to remember which way the reader is going.
9512 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
9513 d.caret = 2; // prose
9514 assert_eq!(d.footnote_definition_at_caret(), None);
9515 d.caret = 9; // the reference
9516 assert_eq!(d.footnote_definition_at_caret(), None);
9517 assert!(
9518 d.footnote_at_caret().is_some(),
9519 "which is the reference's own query"
9520 );
9521 }
9522
9523 #[test]
9524 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
9525 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
9526 // note", which is false and leaves a frontend unable to explain why the
9527 // way back is missing.
9528 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
9529 let mut d = doc_with("fn_def_orphan", src);
9530 d.caret = src.find("orphan").unwrap();
9531 let f = d
9532 .footnote_definition_at_caret()
9533 .expect("an orphan is still a definition");
9534 assert_eq!(f.label, "2");
9535 assert_eq!(f.offset, None);
9536 }
9537
9538 #[test]
9539 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
9540 // One label, cited twice. The first is where the reader most likely came
9541 // from, and the only answer that doesn't depend on how they got here.
9542 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
9543 let mut d = doc_with("fn_def_repeat", src);
9544 d.caret = src.find("the note").unwrap();
9545 let f = d.footnote_definition_at_caret().expect("a definition");
9546 assert_eq!(
9547 f.offset,
9548 Some(5),
9549 "the first `[^a]`'s label, not the second's"
9550 );
9551 assert_eq!(&src[3..7], "[^a]");
9552 }
9553
9554 #[test]
9555 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
9556 // Down and back up, each leg found from the document rather than from a
9557 // memory of the other — so it still works for a reader who scrolled to
9558 // the notes instead of jumping there.
9559 //
9560 // `place_caret` rather than assigning `caret`, because that is what a
9561 // frontend calls: it snaps to a real caret stop, and a jump that lands
9562 // on a byte the caret can't rest on would arrive somewhere the return
9563 // leg no longer answers for. `build_map` first, since snapping is a
9564 // no-op until the map exists — which is exactly how this went unnoticed
9565 // when the offsets pointed at the `[^` markers.
9566 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
9567 d.build_map(None);
9568 d.place_caret(9, false);
9569 let down = d
9570 .footnote_at_caret()
9571 .expect("a reference")
9572 .offset
9573 .expect("a note");
9574 d.place_caret(down, false);
9575 let up = d
9576 .footnote_definition_at_caret()
9577 .expect("a definition")
9578 .offset
9579 .expect("a reference");
9580 d.place_caret(up, false);
9581 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
9582 assert_eq!(
9583 d.footnote_at_caret().expect("back on the reference").label,
9584 "1"
9585 );
9586 }
9587
9588 #[test]
9589 fn insert_link_hands_the_destination_to_twig_raw() {
9590 // Escaping is twig's, and format-specific: Markdown ends a destination
9591 // at the first space and needs the `<…>` form, where djot would read
9592 // those angle brackets as part of the URL.
9593 let mut d = doc_with("link_space", "word\n");
9594 d.anchor = Some(0);
9595 d.caret = 4;
9596 d.insert_link("a b");
9597 assert_eq!(d.source, "[word](<a b>)\n");
9598 }
9599
9600 #[test]
9601 fn insert_link_reports_a_destination_no_format_can_carry() {
9602 let mut d = doc_with("link_bad", "word\n");
9603 d.anchor = Some(0);
9604 d.caret = 4;
9605 d.insert_link("a\nb");
9606 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
9607 assert!(
9608 d.status.is_some(),
9609 "InvalidArgument should reach the status line"
9610 );
9611 assert!(!d.dirty);
9612 }
9613
9614 #[test]
9615 fn insert_link_works_in_wysiwyg_view() {
9616 let mut d = wysiwyg_doc("link_wys", "word here\n");
9617 d.anchor = Some(0);
9618 d.caret = 4;
9619 d.insert_link("http://x.dev");
9620 assert_eq!(d.source, "[word](http://x.dev) here\n");
9621 assert_eq!(d.selected_text(), Some("word"));
9622 // The map the caret has to keep riding is rebuilt each frame; motion
9623 // over the fresh one must still land on a real stop (the debug_assert).
9624 d.build_visual(80);
9625 d.move_right(false);
9626 d.move_left(false);
9627 }
9628
9629 #[test]
9630 fn click_maps_a_row_col_to_a_byte_offset() {
9631 let mut d = doc_with("click", "ab\ncd\n");
9632 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
9633 assert_eq!(d.caret, 4);
9634 }
9635
9636 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
9637 // stop just as the `(row, col)` click path does, so the caret can never come
9638 // to rest in the blank gap between two paragraphs — where it would draw in one
9639 // place and type in another.
9640 #[test]
9641 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
9642 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
9643 // caret stop (stops are 0,1,3,4).
9644 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
9645 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
9646 d.place_caret(2, false);
9647 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9648 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
9649 }
9650
9651 #[test]
9652 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
9653 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
9654 d.place_caret(0, false); // anchor at the start of "A"
9655 d.place_caret(2, true); // drag into the gap
9656 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9657 let (s, e) = d.selection().expect("a selection");
9658 assert!(
9659 d.vmap.is_stop(s) && d.vmap.is_stop(e),
9660 "selection {s}..{e} off a stop"
9661 );
9662 }
9663
9664 #[test]
9665 fn place_caret_on_a_real_stop_is_left_untouched() {
9666 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
9667 d.place_caret(3, false); // the start of "B" — a genuine stop
9668 assert_eq!(d.caret, 3);
9669 }
9670
9671 // An *empty paragraph* (two blank lines, an intentional blank line the user
9672 // opened) is a real caret stop, unlike the gap — a click into it must stay.
9673 #[test]
9674 fn place_caret_rests_in_an_empty_paragraph() {
9675 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
9676 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
9677 assert!(d.vmap.is_stop(empty));
9678 d.place_caret(empty, false);
9679 assert_eq!(d.caret, empty);
9680 }
9681
9682 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
9683 // a drag over the word `bold` ends there, and a caret placed there stays.
9684 #[test]
9685 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
9686 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
9687 let mut d = wysiwyg_doc("place_mark_end", src);
9688 let start = src.find("bold").unwrap();
9689 d.place_caret(start, false);
9690 d.place_caret(start + 4, true);
9691 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
9692 d.toggle(InlineKind::Strong);
9693 assert_eq!(d.source, src.replace("**bold**", "bold"));
9694 }
9695
9696 #[test]
9697 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
9698 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
9699 d.caret = 7; // before the `d`
9700 d.move_right(false);
9701 assert_eq!(d.caret, 8, "onto the end of the bold");
9702 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9703 d.move_right(false);
9704 assert_eq!(d.caret, 10, "past the closing `**`");
9705 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9706 d.move_left(false);
9707 assert_eq!(d.caret, 8);
9708 d.move_left(false);
9709 assert_eq!(d.caret, 7);
9710 // Typing at the inner home extends the bold.
9711 d.caret = 8;
9712 d.insert("!");
9713 assert_eq!(d.source, "a **bold!** b");
9714 }
9715
9716 #[test]
9717 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
9718 // The splice path shifts the home with the block it is in, and the
9719 // re-rendered block finds its own again.
9720 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
9721 d.build_visual_unwrapped();
9722 d.edit(0, 0, "zz");
9723 d.build_visual_unwrapped();
9724 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
9725 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
9726 let at = d.source.find("bold").unwrap();
9727 d.edit(at, at, "very ");
9728 d.build_visual_unwrapped();
9729 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
9730 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
9731 }
9732
9733 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
9734 doc_in(View::Wysiwyg, name, body)
9735 }
9736
9737 /// How many list items the source actually parses into — the check that a
9738 /// marker Leaf wrote is a marker the format agrees is one.
9739 fn list_items(doc: &mut Doc) -> usize {
9740 doc.editor
9741 .nodes()
9742 .unwrap()
9743 .iter()
9744 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9745 .count()
9746 }
9747
9748 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9749 /// incremental (`build_spliced` / `build_cached`) path must always match.
9750 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9751 reference_map_revealing(source, None)
9752 }
9753
9754 /// [`reference_map`] with a reveal line — the ground truth for the
9755 /// `MarkupMode::Full` builds, where the map is a function of the caret's
9756 /// line as well as the text.
9757 fn reference_map_revealing(
9758 source: &str,
9759 reveal: Option<Range<usize>>,
9760 ) -> crate::wysiwyg::VisualMap {
9761 // The same parse `Doc` uses. With twig's plain defaults instead, the two
9762 // sides disagree on what the *document* is before the renderer is even
9763 // reached — a bare `:word` is a text directive to one and prose to the
9764 // other — and the mismatch reads as a splice bug that isn't one.
9765 let mut ed =
9766 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9767 let nodes = ed.nodes().unwrap();
9768 crate::wysiwyg::build(
9769 &nodes,
9770 source,
9771 None,
9772 false,
9773 &std::collections::HashMap::new(),
9774 reveal,
9775 )
9776 }
9777
9778 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
9779 if a.rows.len() != b.rows.len() {
9780 return true;
9781 }
9782 for (ra, rb) in a.rows.iter().zip(&b.rows) {
9783 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
9784 return true;
9785 }
9786 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
9787 if ga.ch != gb.ch || ga.src != gb.src {
9788 return true;
9789 }
9790 }
9791 }
9792 false
9793 }
9794
9795 #[test]
9796 fn incremental_build_matches_a_fresh_build_across_edits() {
9797 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
9798 // fast path, gated on twig's `dirty_range`) or falls back to
9799 // `build_cached`. After each edit the map must be byte-identical to a
9800 // from-scratch build — this is the correctness net under the splice.
9801 let docs = [
9802 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
9803 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
9804 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
9805 // A footnote definition is a root beside `doc`, merged back into the
9806 // top-level list by `wysiwyg::top_blocks`. The random edits below
9807 // make and unmake definitions as they go (a deleted `:` turns one
9808 // back into a paragraph, and vice versa), which is exactly the
9809 // structural churn the splice path has to notice and bail out of.
9810 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
9811 // A comment is a top-level block that draws no rows — a layout entry
9812 // at zero rows either side of blocks that do. The edits below type
9813 // into the blocks around it (a splice past a hidden block), and
9814 // break the comment open into prose and back (a structural change).
9815 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
9816 // Link reference definitions: a hidden block that an edit can turn
9817 // into a paragraph (a deleted `:`) and back, and whose own bytes an
9818 // edit can land in.
9819 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
9820 ];
9821 // A deterministic mix: mostly single characters (which stay inside one
9822 // block → splice), plus edits that reshape structure (a paragraph break,
9823 // a heading marker, a code fence → fallback), so both paths are exercised.
9824 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
9825 for src in docs {
9826 let mut d = wysiwyg_doc("diff", src);
9827 d.build_visual_unwrapped();
9828 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
9829
9830 for step in 0..60usize {
9831 let len = d.source.len();
9832 let raw = (step * 13 + 5) % (len + 1);
9833 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9834 let pre = d.source.clone();
9835 let action;
9836 if step % 3 == 0 && pos < len {
9837 let end = (pos + 1..=len)
9838 .find(|&i| d.source.is_char_boundary(i))
9839 .unwrap();
9840 action = format!("delete [{pos},{end})");
9841 d.edit(pos, end, "");
9842 } else {
9843 let ins = inserts[step % inserts.len()];
9844 action = format!("insert {ins:?} @ {pos}");
9845 d.edit(pos, pos, ins);
9846 }
9847 d.build_visual_unwrapped();
9848 if maps_differ(&d.vmap, &reference_map(&d.source)) {
9849 panic!(
9850 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
9851 d.source
9852 );
9853 }
9854 }
9855 }
9856 }
9857
9858 /// A frontend is handed [`Doc::vmap`] and may present it differently:
9859 /// leaf-ratatui splices blank filler rows under an oversized heading so the
9860 /// raster it paints there has somewhere to stand, and leaves them in the map
9861 /// because the caret and the mouse both read it between frames. The splice
9862 /// path addresses that map by *row index*, against the block layout the last
9863 /// build recorded — so handed a map with rows in it that no block owns, it
9864 /// laid the re-rendered block over one of the fillers and carried the rows
9865 /// the block really occupied into the suffix. One stranded copy of the
9866 /// edited line, and everything below it a row further down, per keystroke.
9867 ///
9868 /// A map that isn't the one the layout describes is a map this path can't
9869 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
9870 #[test]
9871 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
9872 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
9873 d.build_visual_unwrapped();
9874
9875 // Stand in for the heading filler rows: two blank rows past the heading
9876 // that no block accounts for. Cloning a real row keeps every field
9877 // plausible — it is the row *count* the splice can't survive.
9878 let filler = d.vmap.rows[0].clone();
9879 d.vmap.rows.insert(1, filler.clone());
9880 d.vmap.rows.insert(1, filler);
9881
9882 // An edit inside the last block: the single-block case the splice path
9883 // is for, and the one the frontend hits on every keystroke.
9884 let at = d.source.len() - 1;
9885 d.edit(at, at, "!");
9886 d.build_visual_unwrapped();
9887
9888 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
9889 }
9890
9891 #[test]
9892 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
9893 // The same correctness net as `incremental_build_matches_a_fresh_build_
9894 // across_edits`, under `MarkupMode::Full` — where the map depends on
9895 // the caret's *line* as well as the text, so the two caches have a new
9896 // way to be wrong. Both are exercised: the block cache can hand back
9897 // rows built for a line that is no longer the revealed one, and the
9898 // splice path can reuse a suffix that still has yesterday's line raw.
9899 //
9900 // Caret motion is interleaved with the edits deliberately, because a
9901 // caret that only ever moved with the edit would never cross a line
9902 // without also dirtying it — the case where a stale reveal survives.
9903 let docs = [
9904 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
9905 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
9906 ];
9907 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
9908 for src in docs {
9909 let mut d = wysiwyg_doc("reveal_diff", src);
9910 d.set_markup_mode(MarkupMode::Full);
9911
9912 for step in 0..60usize {
9913 let len = d.source.len();
9914 let raw = (step * 13 + 5) % (len + 1);
9915 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9916 let pre = d.source.clone();
9917 let action;
9918 if step % 3 == 0 && pos < len {
9919 let end = (pos + 1..=len)
9920 .find(|&i| d.source.is_char_boundary(i))
9921 .unwrap();
9922 action = format!("delete [{pos},{end})");
9923 d.edit(pos, end, "");
9924 } else {
9925 let ins = inserts[step % inserts.len()];
9926 action = format!("insert {ins:?} @ {pos}");
9927 d.edit(pos, pos, ins);
9928 }
9929 // Walk the caret somewhere else in the document, independently
9930 // of where the edit landed.
9931 let want = (step * 29 + 11) % (d.source.len() + 1);
9932 d.caret = (want..=d.source.len())
9933 .find(|&i| d.source.is_char_boundary(i))
9934 .unwrap();
9935 d.build_visual_unwrapped();
9936
9937 let want = reference_map_revealing(&d.source, d.reveal_line());
9938 if maps_differ(&d.vmap, &want) {
9939 panic!(
9940 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
9941 d.caret, d.source
9942 );
9943 }
9944 }
9945 }
9946 }
9947
9948 #[test]
9949 fn caret_motion_across_lines_rebuilds_only_under_full() {
9950 // The cache-key change has to earn its keep in both directions: `Full`
9951 // must rebuild when the caret changes line (or the reveal would never
9952 // move), and the hidden modes must *not* (or every arrow key would pay
9953 // for a feature they don't use). The existing `cache_motion` test pins
9954 // the second for the default mode; this pins the pair against a mode
9955 // change alone.
9956 let body = "*one* here\n\n*two* there\n";
9957
9958 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9959 full.set_markup_mode(MarkupMode::Full);
9960 caret_at(&mut full, "one");
9961 let before = full.revision();
9962 caret_at(&mut full, "two");
9963 assert_eq!(full.revision(), before, "motion is not an edit");
9964 assert!(
9965 drawn_rows(&full).iter().any(|r| r == "*two* there"),
9966 "the map followed the caret: {:?}",
9967 drawn_rows(&full)
9968 );
9969
9970 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9971 caret_at(&mut hidden, "one");
9972 let key = hidden.vmap_key.clone();
9973 caret_at(&mut hidden, "two");
9974 assert_eq!(
9975 hidden.vmap_key, key,
9976 "a hidden mode rebuilds nothing on motion"
9977 );
9978 }
9979
9980 #[test]
9981 fn wysiwyg_down_crosses_a_paragraph_boundary() {
9982 // Regression: the blank separator row used to share the previous
9983 // paragraph's end offset, so Down got pinned at the boundary (while Up
9984 // still crossed). Both directions must step through it symmetrically.
9985 //
9986 // It's now stepped *over* rather than onto: the blank line between two
9987 // paragraphs is the boundary being drawn, not a line of the document, so
9988 // one press of Down crosses it. The goal column survives the crossing —
9989 // col 3 at the end of "abc" is col 3 at the end of "def".
9990 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9991 d.caret = 3; // end of "abc" (row 0)
9992 d.move_down(false);
9993 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9994 assert_eq!(d.caret, 8); // end of "def", col 3 kept
9995 d.move_up(false);
9996 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9997 assert_eq!(d.caret, 3);
9998 }
9999
10000 #[test]
10001 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
10002 // The second Up and the second Down here run off the ends of the
10003 // document, which is no longer a place a press is swallowed: they carry
10004 // the caret to the start and the end of the text. The claim in the
10005 // middle — that a Down retraces the Up that crossed the paragraph gap —
10006 // is the one this test is for, and it is asserted where it is made.
10007 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
10008 d.caret = 5; // start of "def"
10009 let start = d.caret_pos();
10010 d.move_up(false);
10011 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
10012 d.move_up(false);
10013 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
10014 d.move_down(false);
10015 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
10016 d.move_down(false);
10017 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
10018 }
10019
10020 #[test]
10021 fn wysiwyg_new_paragraph_shows_before_typing() {
10022 // Regression: two Enters at the end of a paragraph produced trailing
10023 // newlines with no AST node, so the caret appeared stuck on the old line
10024 // until a character was typed. It must ride down onto the new line now.
10025 let mut d = doc_with("wys_newpara", "abc\n");
10026 d.view = View::Wysiwyg;
10027 d.caret = 3;
10028 d.insert("\n");
10029 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
10030 assert_eq!(d.source, "abc\n\n\n");
10031 d.build_visual(80);
10032 let (row, _) = d.caret_pos();
10033 assert!(
10034 row >= 2,
10035 "caret should have moved down to the new line, got row {row}"
10036 );
10037 assert!(
10038 d.vmap.num_rows() >= 3,
10039 "the blank lines should render as rows"
10040 );
10041 }
10042
10043 #[test]
10044 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
10045 // The reported bug: Enter at the end of a paragraph that has another
10046 // paragraph below put the caret at the *start of the next paragraph* —
10047 // the empty paragraph it opened had no row, so the caret snapped onto
10048 // "World". It must now sit on its own empty line, with a blank spacer
10049 // above it (the paragraph gap).
10050 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
10051 d.caret = 5; // end of "Hello"
10052 d.newline();
10053 d.build_visual(80);
10054 let (row, col) = d.caret_pos();
10055 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
10056 assert_eq!(
10057 d.vmap.row_width(row),
10058 0,
10059 "caret's row must be empty, not 'World'"
10060 );
10061 assert!(
10062 row >= 2,
10063 "a blank spacer row should sit above the caret, got row {row}"
10064 );
10065 // The row above the caret is a real (empty) gap, and "Hello" stays put.
10066 assert_eq!(
10067 d.vmap.row_width(row - 1),
10068 0,
10069 "the row above the caret is a gap"
10070 );
10071 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
10072 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
10073 }
10074
10075 #[test]
10076 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
10077 // At the document end a single Enter must also show the paragraph gap —
10078 // a blank spacer row above the caret — so the layout already matches how
10079 // it will look once the new paragraph has text.
10080 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
10081 d.caret = 5; // end of "Hello", no trailing newline
10082 d.newline(); // source becomes "Hello\n\n"
10083 d.build_visual(80);
10084 let (row, col) = d.caret_pos();
10085 assert_eq!(col, 0);
10086 assert!(
10087 row >= 2,
10088 "caret should sit below a blank spacer, got row {row}"
10089 );
10090 assert_eq!(
10091 d.vmap.row_width(row - 1),
10092 0,
10093 "the row above the caret is a gap"
10094 );
10095 }
10096
10097 #[test]
10098 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
10099 // The spacer is view-only: typing the new paragraph must not reflow the
10100 // caret onto a different row — the transient view already matched the
10101 // settled one.
10102 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
10103 d.caret = 5;
10104 d.newline();
10105 d.build_visual(80);
10106 let before = d.caret_pos();
10107 d.insert("New");
10108 d.build_visual(80);
10109 let after = d.caret_pos();
10110 assert_eq!(
10111 after.0, before.0,
10112 "typing must not move the caret to another row ({before:?} -> {after:?})"
10113 );
10114 }
10115
10116 #[test]
10117 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
10118 // Return at the end of the block's last line writes an empty line the
10119 // map used to drop, so the caret landed on `after` and the next
10120 // keystroke went into the paragraph below instead of into the code.
10121 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
10122 d.caret = d.source.find("beta").unwrap() + "beta".len();
10123 d.build_visual(80);
10124 let before = d.caret_pos().0;
10125
10126 d.newline();
10127 d.build_visual(80);
10128 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
10129
10130 let (row, col) = d.caret_pos();
10131 assert_eq!(row, before + 1, "the caret moves down one row");
10132 assert_eq!(col, 0, "onto the head of the empty line");
10133 let span = d.vmap.code_blocks[0].rows_span.clone();
10134 assert!(
10135 span.contains(&row),
10136 "caret row {row} is outside the block's rows {span:?}"
10137 );
10138
10139 // The whole point: what is typed next is code.
10140 d.insert("gamma");
10141 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
10142 }
10143
10144 #[test]
10145 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
10146 let fm = "---\ntitle: hi\n---\n";
10147 let body = format!("{fm}# leaf\n\nbody\n");
10148 let mut d = wysiwyg_doc("wys_fm", &body);
10149 // Opening lifts the caret out of the now-hidden frontmatter.
10150 assert_eq!(
10151 d.caret,
10152 fm.len(),
10153 "caret should start at the first real block"
10154 );
10155 // Left at the content start can't step back into frontmatter.
10156 d.move_left(false);
10157 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
10158 // Doc-start lands on the content floor, not offset 0.
10159 d.move_doc_start(false);
10160 assert_eq!(d.caret, fm.len());
10161 // Select-all + copy never include the frontmatter bytes.
10162 d.select_all();
10163 let sel = d.selected_text().unwrap().to_string();
10164 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
10165 assert!(
10166 sel.starts_with("# leaf"),
10167 "selection should begin at content: {sel:?}"
10168 );
10169 }
10170
10171 #[test]
10172 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
10173 // A fresh note is frontmatter and nothing else. With no rendered block
10174 // to floor the caret it opened at offset 0 — before the opening `---` —
10175 // so the first keystroke wrote itself in front of the metadata and the
10176 // file came out as `This---\ntitle: …`.
10177 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
10178 let mut d = wysiwyg_doc("wys_fm_only", fm);
10179 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
10180 // Nothing is rendered, so the caret draws at the origin of an empty view
10181 // — the same place an empty document puts it.
10182 assert_eq!(d.caret_pos(), (0, 0));
10183 d.insert("This");
10184 assert_eq!(d.source, format!("{fm}This"));
10185 }
10186
10187 /// `select_range` is the verb for a range a host already knows the bytes of,
10188 /// so it must not snap — and must still hold every invariant `place_caret`
10189 /// holds, the frontmatter floor above all.
10190 #[test]
10191 fn select_range_takes_the_range_as_given_but_still_floors_it() {
10192 let fm = "---\ntitle: foo\n---\n\n";
10193 let body = format!("{fm}body foo here\n");
10194 let mut d = wysiwyg_doc("wys_select_range", &body);
10195
10196 // The `foo` in the body: taken exactly, not snapped to a caret stop.
10197 let at = body.rfind("foo").unwrap();
10198 d.select_range(at, at + 3);
10199 assert_eq!(d.selection(), Some((at, at + 3)));
10200 assert_eq!(d.selected_text(), Some("foo"));
10201
10202 // The `foo` in the hidden frontmatter: below the floor, so both ends
10203 // come up to it rather than parking the caret in the metadata, where a
10204 // later keystroke would rewrite `title:`.
10205 let hidden = body.find("foo").unwrap();
10206 assert!(hidden < d.vmap.content_start);
10207 d.select_range(hidden, hidden + 3);
10208 assert!(
10209 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
10210 "a range under the floor must not leave the caret in the frontmatter"
10211 );
10212
10213 // Past the end, and mid-character, are both brought back to something
10214 // sliceable rather than panicking the next reader of the range.
10215 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
10216 let mut d = multi;
10217 d.select_range(2, 9_999);
10218 assert_eq!(d.caret, d.source.len());
10219 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
10220 assert!(d.source.is_char_boundary(d.caret));
10221 }
10222
10223 /// The bug `select_range` exists for: a match butting up against a hidden
10224 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
10225 /// the one before the `**`.
10226 #[test]
10227 fn select_range_does_not_snap_off_a_hidden_delimiter() {
10228 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
10229 let at = d.source.find("needle").unwrap();
10230 d.select_range(at, at + 6);
10231 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
10232 }
10233
10234 #[test]
10235 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
10236 // Backspace deletes `prev_boundary..caret` directly; at the first real
10237 // block that boundary is inside the hidden frontmatter, so it must be a
10238 // no-op rather than eating the closing `---`.
10239 let fm = "---\ntitle: hi\n---\n";
10240 let body = format!("{fm}leaf\n");
10241 let mut d = wysiwyg_doc("wys_fm_bs", &body);
10242 assert_eq!(d.caret, fm.len());
10243 d.backspace();
10244 assert_eq!(d.source, body, "backspace must not touch frontmatter");
10245 d.delete_word_back();
10246 assert_eq!(
10247 d.source, body,
10248 "word-delete must not touch frontmatter either"
10249 );
10250 }
10251
10252 #[test]
10253 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
10254 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
10255 // fenced div, really, since core parses these on for every document
10256 // now (`parse_extensions`). The container is a `directive` node, an
10257 // `is_block_container` kind like `block_quote`, so the caret works
10258 // inside its child paragraph exactly as it would inside a quote: typing
10259 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
10260 // untouched.
10261 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
10262 let mut d = wysiwyg_doc("wys_vis", body);
10263 d.caret = body.find("hello").unwrap() + "hello".len();
10264 d.insert("!");
10265 assert_eq!(
10266 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
10267 "typing inside the block edits its content in place"
10268 );
10269 assert!(
10270 d.source.contains(":::vis{.public .family}"),
10271 "opening fence survives"
10272 );
10273 assert!(d.source.contains(":::\nafter"), "closing fence survives");
10274 }
10275
10276 #[test]
10277 fn source_view_still_reaches_frontmatter() {
10278 // The metadata is only *hidden*, never lost: the source view edits and
10279 // selects it in full, and it's always preserved on save.
10280 let fm = "---\ntitle: hi\n---\n";
10281 let body = format!("{fm}# leaf\n");
10282 let mut d = doc_with("src_fm", &body);
10283 d.select_all();
10284 let sel = d.selected_text().unwrap();
10285 assert!(
10286 sel.contains("title"),
10287 "source view should select everything"
10288 );
10289 d.move_doc_start(false);
10290 assert_eq!(d.caret, 0, "source view can reach offset 0");
10291 }
10292
10293 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
10294
10295 #[test]
10296 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
10297 // The border and padding between two cells all share one source offset,
10298 // so a column-stepping caret would sit on `│` and then stall there
10299 // forever. Right must step: end of "Name" -> start of "Qty".
10300 let mut d = wysiwyg_doc("tbl_right", TABLE);
10301 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
10302 d.move_right(false);
10303 assert_eq!(
10304 d.caret,
10305 TABLE.find("Qty").unwrap(),
10306 "should land in the next cell"
10307 );
10308 let (r, c) = d.caret_pos();
10309 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
10310 }
10311
10312 #[test]
10313 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
10314 let mut d = wysiwyg_doc("tbl_left", TABLE);
10315 d.caret = TABLE.find("Qty").unwrap();
10316 d.move_left(false);
10317 assert_eq!(
10318 d.caret,
10319 TABLE.find("Name").unwrap() + 4,
10320 "end of the previous cell"
10321 );
10322 }
10323
10324 #[test]
10325 fn wysiwyg_down_steps_over_a_table_rule() {
10326 // Between the header and the first body row sits a `├───┼───┤` rule.
10327 // It's drawn but holds no caret, so one Down must reach "Pear".
10328 let mut d = wysiwyg_doc("tbl_down", TABLE);
10329 d.caret = TABLE.find("Name").unwrap();
10330 d.move_down(false);
10331 assert_eq!(
10332 d.caret,
10333 TABLE.find("Pear").unwrap(),
10334 "one Down reaches the body row"
10335 );
10336 d.move_down(false);
10337 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
10338 }
10339
10340 #[test]
10341 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
10342 let mut d = wysiwyg_doc("tbl_tab", TABLE);
10343 d.caret = TABLE.find("Name").unwrap();
10344 // A hop lands with the destination cell's whole content selected, the
10345 // caret at its end — so typing replaces the cell like a form field.
10346 assert!(d.cell_hop(true));
10347 assert_eq!(
10348 d.selected_text(),
10349 Some("Qty"),
10350 "the target cell comes up selected"
10351 );
10352 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
10353 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
10354 assert_eq!(d.selected_text(), Some("Pear"));
10355 assert!(d.cell_hop(false));
10356 assert_eq!(d.selected_text(), Some("Qty"));
10357 }
10358
10359 #[test]
10360 fn tab_outside_a_table_is_not_a_cell_hop() {
10361 // `cell_hop` reports false so the frontend can indent as usual.
10362 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
10363 d.caret = 4;
10364 assert!(!d.cell_hop(true));
10365 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
10366 }
10367
10368 #[test]
10369 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
10370 let mut d = wysiwyg_doc("tbl_edge", TABLE);
10371 d.caret = TABLE.rfind("12").unwrap(); // the final cell
10372 assert!(!d.cell_hop(true), "no cell after the last one");
10373 d.caret = TABLE.find("Name").unwrap();
10374 assert!(!d.cell_hop(false), "no cell before the first one");
10375 }
10376
10377 #[test]
10378 fn wysiwyg_vertical_cell_motion_holds_the_column() {
10379 // Down/Up step to the cell above/below in the *same column*, not back to
10380 // the top-left the way a naive row/col motion over the picture would.
10381 let mut d = wysiwyg_doc("tbl_vert", TABLE);
10382 d.caret = TABLE.find("Qty").unwrap();
10383 // Each vertical hop selects the destination cell, holding the column.
10384 assert!(d.cell_move_vertical(true));
10385 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
10386 assert!(d.cell_move_vertical(true));
10387 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
10388 assert!(!d.cell_move_vertical(true), "no row below the last");
10389 assert!(d.cell_move_vertical(false));
10390 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
10391 assert!(d.cell_move_vertical(false));
10392 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
10393 assert!(!d.cell_move_vertical(false), "no row above the header");
10394 }
10395
10396 #[test]
10397 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
10398 let mut d = wysiwyg_doc("tbl_grow", TABLE);
10399 d.caret = TABLE.rfind("12").unwrap();
10400 let rows_before = d.source.matches('\n').count();
10401 assert!(d.cell_tab(true), "acts as a table key");
10402 assert_eq!(
10403 d.source.matches('\n').count(),
10404 rows_before + 1,
10405 "a fresh row was appended"
10406 );
10407 assert!(d.caret_in_table(), "the caret entered the new row");
10408 // The caret sits in the new row's first cell — past the old last cell.
10409 assert!(d.caret > TABLE.rfind("12").unwrap());
10410 }
10411
10412 #[test]
10413 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
10414 let mut d = wysiwyg_doc("tbl_ret", TABLE);
10415 d.caret = TABLE.find("Name").unwrap();
10416 assert!(d.cell_return(), "acts as a table key");
10417 assert_eq!(
10418 d.selected_text(),
10419 Some("Pear"),
10420 "Return drops one cell, selecting it"
10421 );
10422 // From the last row, Return appends a row and enters it.
10423 d.caret = TABLE.rfind("Fig").unwrap();
10424 let rows_before = d.source.matches('\n').count();
10425 assert!(d.cell_return());
10426 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
10427 assert!(d.caret_in_table());
10428 }
10429
10430 #[test]
10431 fn return_and_tab_outside_a_table_decline() {
10432 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
10433 d.caret = 4;
10434 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
10435 assert!(!d.cell_tab(true), "no table: the frontend indents");
10436 assert!(
10437 !d.cell_line_break(),
10438 "no table: the frontend breaks the line"
10439 );
10440 }
10441
10442 #[test]
10443 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
10444 let mut d = wysiwyg_doc("tbl_break", TABLE);
10445 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
10446 assert!(d.cell_line_break(), "acts as a table key");
10447 assert!(
10448 d.source.contains("Pear<br>"),
10449 "spelled as an inline <br>: {}",
10450 d.source
10451 );
10452 assert!(d.caret_in_table(), "still in the cell, past the break");
10453 // The break renders as a real line: the "Pear" cell now draws two lines,
10454 // so the table's picture is one row taller than a single-line table.
10455 d.build_visual(80);
10456 let table = &d.vmap.tables[0];
10457 let cell = &table.grid[1].cells[0]; // first body row, first column
10458 assert!(
10459 cell.glyphs.iter().any(|g| g.ch == '\n'),
10460 "the cell carries the break as a newline glyph for the frontend to split"
10461 );
10462 }
10463
10464 #[test]
10465 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
10466 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
10467 // back as structure — the whole point of routing through insert_line_break
10468 // instead of splicing raw `<br>` bytes.
10469 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
10470 d.caret = TABLE.find("Pear").unwrap() + 4;
10471 assert!(d.cell_line_break());
10472 let kinds: Vec<Kind> = d
10473 .editor
10474 .nodes()
10475 .unwrap()
10476 .iter()
10477 .map(|n| n.kind.clone())
10478 .collect();
10479 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
10480 assert!(
10481 !kinds.contains(&Kind::RawInline),
10482 "still raw HTML: {kinds:?}"
10483 );
10484 }
10485
10486 #[test]
10487 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
10488 // The `<br>` draws as one newline glyph, so Backspace over it must take
10489 // all four bytes — a one-byte delete would strand a visible `<br` in the
10490 // cell (the reported bug).
10491 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
10492 d.caret = TABLE.find("Pear").unwrap() + 4;
10493 assert!(d.cell_line_break());
10494 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
10495 d.backspace(); // caret sits just past the break
10496 assert!(
10497 !d.source.contains("<br"),
10498 "no half-deleted <br left: {}",
10499 d.source
10500 );
10501 assert!(
10502 d.source.contains("| Pear |"),
10503 "the cell is back to one line: {}",
10504 d.source
10505 );
10506 }
10507
10508 #[test]
10509 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
10510 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
10511 d.caret = TABLE.find("Pear").unwrap() + 4;
10512 assert!(d.cell_line_break());
10513 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
10514 d.delete_forward();
10515 assert!(
10516 !d.source.contains("<br"),
10517 "no half-deleted <br: {}",
10518 d.source
10519 );
10520 assert!(
10521 d.source.contains("| Pear |"),
10522 "cell back to one line: {}",
10523 d.source
10524 );
10525 }
10526
10527 #[test]
10528 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
10529 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
10530 // still consumed (a real newline would split the one-line row), but the
10531 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
10532 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
10533 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10534 d.caret = src.find("Pear").unwrap() + 4;
10535 assert!(d.caret_in_table(), "caret should be inside the djot table");
10536 assert!(
10537 d.cell_line_break(),
10538 "the key is consumed, not passed to the frontend"
10539 );
10540 assert_eq!(d.source, src, "the djot cell is left untouched");
10541 assert!(
10542 !d.source.contains("<br>"),
10543 "no non-idiomatic <br> spliced into djot"
10544 );
10545 assert!(
10546 d.status.is_some(),
10547 "the refusal is surfaced on the status line"
10548 );
10549 }
10550
10551 #[test]
10552 fn typing_in_a_cell_edits_that_cell() {
10553 // Editing comes free once offsets map correctly: the caret is a source
10554 // offset, so a normal splice lands inside the pipe table.
10555 let mut d = wysiwyg_doc("tbl_type", TABLE);
10556 d.caret = TABLE.find("Pear").unwrap() + 4;
10557 d.insert("s");
10558 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
10559 }
10560
10561 #[test]
10562 fn motion_and_delete_treat_an_emoji_as_one_character() {
10563 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
10564 // bytes, several codepoints. Right-arrow must clear it in one step, and
10565 // backspace must remove the whole cluster, not a stray joiner.
10566 let family = "👨👩👧";
10567 let mut d = doc_with("emoji", &format!("a{family}b\n"));
10568 d.caret = 1; // just after 'a', before the emoji
10569 d.move_right(false);
10570 assert_eq!(
10571 d.caret,
10572 1 + family.len(),
10573 "one step clears the whole cluster"
10574 );
10575 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
10576
10577 d.backspace(); // delete the emoji as a unit
10578 assert_eq!(d.source, "ab\n");
10579 assert_eq!(d.caret, 1);
10580 }
10581
10582 #[test]
10583 fn motion_handles_a_combining_accent_as_one_character() {
10584 // "e" + U+0301 (combining acute) renders as one é.
10585 let mut d = doc_with("combining", "e\u{0301}x\n");
10586 d.caret = 0;
10587 d.move_right(false);
10588 assert_eq!(
10589 d.caret,
10590 "e\u{0301}".len(),
10591 "steps past base + combining mark"
10592 );
10593 }
10594
10595 #[test]
10596 fn undo_then_redo_round_trips_an_edit() {
10597 let mut d = doc_with("undo", "hello\n");
10598 d.caret = 5;
10599 d.insert("!");
10600 assert_eq!(d.source, "hello!\n");
10601 d.undo();
10602 assert_eq!(d.source, "hello\n");
10603 assert_eq!(d.caret, 5, "undo restores the caret");
10604 d.redo();
10605 assert_eq!(d.source, "hello!\n");
10606 }
10607
10608 #[test]
10609 fn a_run_of_typing_undoes_as_one_step() {
10610 let mut d = doc_with("coalesce", "\n");
10611 d.caret = 0;
10612 d.insert("a");
10613 d.insert("b");
10614 d.insert("c");
10615 assert_eq!(d.source, "abc\n");
10616 d.undo(); // the whole typed run, not just "c"
10617 assert_eq!(d.source, "\n");
10618 d.undo(); // nothing left — the run was one step
10619 assert_eq!(d.source, "\n");
10620 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10621 }
10622
10623 // ── IME composition ──────────────────────────────────────────────────────
10624
10625 #[test]
10626 fn a_composition_run_undoes_as_one_step() {
10627 let mut d = doc_with("compose", "\n");
10628 d.caret = 0;
10629 // What an IME does: each step replaces the last one's provisional bytes.
10630 d.edit_composing(0, 0, "k");
10631 d.edit_composing(0, 1, "か");
10632 d.edit_composing(0, 3, "かん");
10633 d.edit_composing(0, 6, "感"); // the commit
10634 d.end_composition();
10635 assert_eq!(d.source, "感\n");
10636 d.undo(); // the whole composition, not its last keystroke
10637 assert_eq!(d.source, "\n");
10638 assert_eq!(d.status.as_deref(), None, "the run was a single step");
10639 }
10640
10641 #[test]
10642 fn two_compositions_are_two_undo_steps() {
10643 let mut d = doc_with("compose_two", "\n");
10644 d.caret = 0;
10645 d.edit_composing(0, 0, "か");
10646 d.edit_composing(0, 3, "蚊");
10647 d.end_composition();
10648 d.edit_composing(3, 3, "き");
10649 d.edit_composing(3, 6, "木");
10650 d.end_composition();
10651 assert_eq!(d.source, "蚊木\n");
10652 d.undo();
10653 assert_eq!(d.source, "蚊\n", "only the second composition");
10654 d.undo();
10655 assert_eq!(d.source, "\n");
10656 }
10657
10658 #[test]
10659 fn a_composition_does_not_fold_into_the_typing_around_it() {
10660 let mut d = doc_with("compose_typing", "\n");
10661 d.caret = 0;
10662 d.insert("a");
10663 d.insert("b");
10664 d.edit_composing(2, 2, "か");
10665 d.edit_composing(2, 5, "蚊");
10666 d.end_composition();
10667 d.insert("c");
10668 assert_eq!(d.source, "ab蚊c\n");
10669 d.undo();
10670 assert_eq!(d.source, "ab蚊\n");
10671 d.undo();
10672 assert_eq!(d.source, "ab\n");
10673 d.undo();
10674 assert_eq!(d.source, "\n");
10675 }
10676
10677 #[test]
10678 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
10679 let mut d = doc_with("compose_spurious", "\n");
10680 d.caret = 0;
10681 d.insert("a");
10682 d.end_composition(); // an IME unmarking unprompted
10683 d.insert("b");
10684 assert_eq!(d.source, "ab\n");
10685 d.undo();
10686 assert_eq!(d.source, "\n", "still one typed run");
10687 }
10688
10689 // ── the clipboard's rich flavor ──────────────────────────────────────────
10690
10691 #[test]
10692 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
10693 let mut d = doc_with("sel_inline", "a **bold** c\n");
10694 d.anchor = Some(2);
10695 d.caret = 10; // `**bold**`, inside the paragraph
10696 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
10697 }
10698
10699 #[test]
10700 fn a_whole_block_selection_keeps_its_paragraph() {
10701 let mut d = doc_with("sel_block", "a **bold** c\n");
10702 d.anchor = Some(0);
10703 d.caret = 12; // the entire paragraph
10704 assert_eq!(
10705 d.selection_html().as_deref(),
10706 Some("<p>a <strong>bold</strong> c</p>")
10707 );
10708 }
10709
10710 #[test]
10711 fn a_multi_block_selection_keeps_its_structure() {
10712 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
10713 d.select_all();
10714 let html = d.selection_html().expect("renders");
10715 assert!(html.contains("<p>para</p>"), "{html:?}");
10716 assert!(html.contains("<li>one</li>"), "{html:?}");
10717 }
10718
10719 #[test]
10720 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
10721 // The fragment `Head` is a paragraph standalone; the *document* says it
10722 // sits inside one block, so the wrapper is an artifact either way.
10723 let mut d = doc_with("sel_heading", "# Head line\n");
10724 d.anchor = Some(2);
10725 d.caret = 6;
10726 assert_eq!(d.selection_html().as_deref(), Some("Head"));
10727 }
10728
10729 #[test]
10730 fn no_selection_publishes_no_html() {
10731 let mut d = doc_with("sel_none", "a b\n");
10732 d.caret = 1;
10733 assert_eq!(d.selection_html(), None);
10734 }
10735
10736 #[test]
10737 fn pasting_html_converts_it_and_is_one_undo_step() {
10738 let mut d = doc_with("paste_html", "x\n");
10739 d.caret = 1;
10740 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
10741 assert_eq!(d.source, "xa **b** c\n");
10742 d.undo();
10743 assert_eq!(d.source, "x\n", "the whole paste, in one step");
10744 }
10745
10746 #[test]
10747 fn pasting_html_replaces_the_selection() {
10748 let mut d = doc_with("paste_html_sel", "keep drop\n");
10749 d.anchor = Some(5);
10750 d.caret = 9;
10751 assert!(d.paste_html("<em>new</em>"));
10752 assert_eq!(d.source, "keep *new*\n");
10753 }
10754
10755 #[test]
10756 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
10757 let mut d = doc_with("paste_html_bad", "x\n");
10758 d.caret = 1;
10759 // twig builds no table from HTML; raw `<table>` in prose is worse than
10760 // the plain flavor the caller still holds.
10761 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
10762 assert_eq!(d.source, "x\n", "declined edits nothing");
10763 }
10764
10765 #[test]
10766 fn copy_then_paste_round_trips_through_the_html_flavor() {
10767 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
10768 d.select_all();
10769 let html = d.selection_html().expect("renders");
10770 let mut into = doc_with("clip_round_dst", "\n");
10771 into.caret = 0;
10772 assert!(into.paste_html(&html));
10773 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
10774 }
10775
10776 #[test]
10777 fn moving_the_caret_starts_a_new_undo_group() {
10778 let mut d = doc_with("break", "\n");
10779 d.caret = 0;
10780 d.insert("a");
10781 d.insert("b"); // "ab\n", caret at 2
10782 d.move_left(false); // breaks the run
10783 d.insert("X"); // "aXb\n"
10784 assert_eq!(d.source, "aXb\n");
10785 d.undo();
10786 assert_eq!(
10787 d.source, "ab\n",
10788 "first undo removes only the post-move insert"
10789 );
10790 d.undo();
10791 assert_eq!(d.source, "\n", "second undo removes the earlier run");
10792 }
10793
10794 #[test]
10795 fn undo_reverses_a_format_toggle() {
10796 let mut d = doc_with("fmt_undo", "a word b\n");
10797 d.anchor = Some(2);
10798 d.caret = 6;
10799 d.toggle(InlineKind::Strong);
10800 assert_eq!(d.source, "a **word** b\n");
10801 d.undo();
10802 assert_eq!(d.source, "a word b\n");
10803 }
10804
10805 #[test]
10806 fn undo_back_to_the_saved_state_clears_dirty() {
10807 let mut d = doc_with("dirty_undo", "hello\n");
10808 assert!(!d.dirty);
10809 d.caret = 5;
10810 d.insert("!");
10811 assert!(d.dirty);
10812 d.undo();
10813 assert!(
10814 !d.dirty,
10815 "undoing to the saved source is not a modification"
10816 );
10817 }
10818
10819 #[test]
10820 fn a_new_edit_invalidates_redo() {
10821 let mut d = doc_with("redo_inv", "\n");
10822 d.caret = 0;
10823 d.insert("a");
10824 d.undo();
10825 d.insert("b"); // diverges — the redo of "a" is now gone
10826 d.redo();
10827 assert_eq!(d.source, "b\n");
10828 }
10829
10830 #[test]
10831 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
10832 let mut d = doc_with("can_undo", "hello\n");
10833 assert!(
10834 !d.can_undo() && !d.can_redo(),
10835 "a fresh document has no history"
10836 );
10837 d.caret = 5;
10838 d.insert("!");
10839 assert!(
10840 d.can_undo() && !d.can_redo(),
10841 "an edit is a step to take back"
10842 );
10843 d.undo();
10844 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
10845 d.redo();
10846 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
10847 d.undo();
10848 d.insert("?");
10849 assert!(
10850 d.can_undo() && !d.can_redo(),
10851 "a fresh edit ends the redo chain"
10852 );
10853 // A coalesced run over-counts steps — the bound is what a menu needs,
10854 // and it reconciles the moment twig reports the history empty.
10855 d.insert("a");
10856 d.insert("b");
10857 while d.can_undo() {
10858 d.undo();
10859 }
10860 assert_eq!(d.source, "hello\n");
10861 assert!(!d.can_undo());
10862 // A reading surface has nothing to undo, whatever the history holds.
10863 d.redo();
10864 d.set_read_only(true);
10865 assert!(!d.can_undo() && !d.can_redo());
10866 }
10867
10868 #[test]
10869 fn undo_on_empty_history_is_a_no_op() {
10870 let mut d = doc_with("undo_empty", "hi\n");
10871 d.undo();
10872 assert_eq!(d.source, "hi\n");
10873 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10874 }
10875
10876 #[test]
10877 fn a_one_character_paste_is_its_own_undo_step() {
10878 for view in [View::Source, View::Wysiwyg] {
10879 let mut d = doc_in(view, "paste_step", "ab\n");
10880 d.caret = 0;
10881 d.insert("x");
10882 d.insert("y"); // a run of typing
10883 d.paste("z"); // one character, but pasted — not part of that run
10884 assert_eq!(d.source, "xyzab\n");
10885 d.undo();
10886 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
10887 assert_eq!(d.caret, 2, "and hands back the caret it found");
10888 d.undo();
10889 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
10890 }
10891 }
10892
10893 #[test]
10894 fn the_same_character_typed_still_joins_the_run() {
10895 // The other half of the pair: `z` is a keystroke here and a paste above,
10896 // and the two undo differently. Nothing about the *string* says which —
10897 // which is why provenance has to come from the door the caller uses.
10898 for view in [View::Source, View::Wysiwyg] {
10899 let mut d = doc_in(view, "typed_run", "ab\n");
10900 d.caret = 0;
10901 d.insert("x");
10902 d.insert("y");
10903 d.insert("z");
10904 d.undo();
10905 assert_eq!(d.source, "ab\n", "one run, one step");
10906 }
10907 }
10908
10909 #[test]
10910 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
10911 for view in [View::Source, View::Wysiwyg] {
10912 let mut d = doc_in(view, "undo_caret", "hello world\n");
10913 d.caret = 11; // standing at the end of "world", away from the edit
10914 d.edit(0, 5, "goodbye");
10915 assert_eq!(d.source, "goodbye world\n");
10916 d.undo();
10917 assert_eq!(d.source, "hello world\n");
10918 // The undone edit ends at offset 5; the user was at 11.
10919 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
10920 }
10921 }
10922
10923 #[test]
10924 fn undo_restores_the_selection_the_edit_replaced() {
10925 for view in [View::Source, View::Wysiwyg] {
10926 let mut d = doc_in(view, "undo_sel", "a word b\n");
10927 d.anchor = Some(2);
10928 d.caret = 6; // "word" selected
10929 d.insert("X");
10930 assert_eq!(d.source, "a X b\n");
10931 d.undo();
10932 assert_eq!(d.source, "a word b\n");
10933 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
10934 }
10935 }
10936
10937 #[test]
10938 fn redo_restores_the_caret_the_edit_left_behind() {
10939 for view in [View::Source, View::Wysiwyg] {
10940 let mut d = doc_in(view, "redo_caret", "hello world\n");
10941 d.caret = 11;
10942 d.edit(0, 5, "goodbye");
10943 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
10944 d.undo();
10945 d.redo();
10946 assert_eq!(d.source, "goodbye world\n");
10947 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
10948 }
10949 }
10950
10951 #[test]
10952 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
10953 for view in [View::Source, View::Wysiwyg] {
10954 let mut d = doc_in(view, "run_caret", "hi\n");
10955 d.caret = 2;
10956 d.insert("a");
10957 d.insert("b");
10958 d.insert("c");
10959 assert_eq!(d.source, "hiabc\n");
10960 d.undo();
10961 assert_eq!(d.source, "hi\n");
10962 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
10963 d.redo();
10964 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
10965 }
10966 }
10967
10968 #[test]
10969 fn undo_restores_the_caret_across_a_format_toggle() {
10970 // A toggle reaches twig without going through `splice`, so it has to
10971 // record its own step — miss it and every stack depth below it is off by
10972 // one, and undo starts handing back another edit's caret.
10973 for view in [View::Source, View::Wysiwyg] {
10974 let mut d = doc_in(view, "fmt_caret", "a word b\n");
10975 d.caret = 8;
10976 d.anchor = Some(2);
10977 d.caret = 6;
10978 d.toggle(InlineKind::Strong);
10979 assert_eq!(d.source, "a **word** b\n");
10980 d.undo();
10981 assert_eq!(d.source, "a word b\n");
10982 assert_eq!(
10983 d.selection(),
10984 Some((2, 6)),
10985 "the toggled selection comes back"
10986 );
10987 }
10988 }
10989
10990 #[test]
10991 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
10992 // The drift that would never announce itself: twig drops its redo stack
10993 // on any fresh edit, so a leaf redo entry that outlives it would restore
10994 // a caret from the timeline that edit abandoned.
10995 for view in [View::Source, View::Wysiwyg] {
10996 let mut d = doc_in(view, "redo_trunc", "hello world\n");
10997 d.caret = 11;
10998 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
10999 d.undo();
11000 assert_eq!(d.caret, 11);
11001 d.caret = 0;
11002 d.insert("X"); // diverges: A's redo is gone from twig
11003 assert_eq!(d.source, "Xhello world\n");
11004
11005 d.redo();
11006 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
11007 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
11008 d.undo();
11009 assert_eq!(d.source, "hello world\n");
11010 assert_eq!(
11011 d.caret, 0,
11012 "the surviving step's caret, not the dropped one"
11013 );
11014 }
11015 }
11016
11017 #[test]
11018 fn indent_and_outdent_move_the_caret_line_with_its_text() {
11019 for view in [View::Source, View::Wysiwyg] {
11020 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
11021 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
11022 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
11023 // Indentation the caret is standing *in* collapses to the line start
11024 // rather than dragging the caret into the text.
11025 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
11026 // A line with none to give back is left exactly as it was.
11027 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
11028 // Less than a full level gives back what it has.
11029 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
11030 // A tab is one level however many spaces it isn't.
11031 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
11032 }
11033 }
11034
11035 #[test]
11036 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
11037 // Why the level is two spaces and not the four both frontends type
11038 // today. Four is markdown's indented-code-block marker, so a Tab on a
11039 // paragraph would silently restyle it as code — a width that changes
11040 // what the document *means* isn't an indent. Pinned because the number
11041 // is the kind of thing a later list-aware pass would reach for.
11042 let mut d = doc_with("indent_kind", "hello\n");
11043 d.caret = 2;
11044 d.indent();
11045 assert_eq!(d.source, " hello\n");
11046 assert!(
11047 d.nodes().iter().any(|n| n.kind == Kind::Para),
11048 "still prose after a Tab"
11049 );
11050 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
11051
11052 // The four-space level this replaces, for contrast: same text, and twig
11053 // reparses the paragraph into a code block.
11054 let mut wide = doc_with("indent_kind_4", " hello\n");
11055 wide.build_visual(80);
11056 assert!(
11057 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
11058 "four spaces is a code block, not an indented paragraph"
11059 );
11060 }
11061
11062 #[test]
11063 fn indent_nests_a_list_item_under_its_parent() {
11064 // Tab indents a list item by its own marker width, landing its marker at
11065 // the parent's content column so twig reparses it as a nested list.
11066 for view in [View::Source, View::Wysiwyg] {
11067 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
11068 d.caret = 6; // on the second item
11069 d.indent();
11070 assert_eq!(d.source, "- a\n - b\n");
11071 let lists = d
11072 .nodes()
11073 .iter()
11074 .filter(|n| n.kind == Kind::BulletList)
11075 .count();
11076 assert_eq!(lists, 2, "the indented item is a nested list");
11077 }
11078 }
11079
11080 #[test]
11081 fn indent_nests_an_ordered_item_at_its_marker_width() {
11082 // An ordered marker `1. ` is three columns wide, so a two-space step
11083 // (which nests a bullet) leaves it flat. Regression: Tab must use the
11084 // marker width, three, so the item actually nests — and the source
11085 // renumbers so the sub-list restarts at 1 and the outer list resumes.
11086 for view in [View::Source, View::Wysiwyg] {
11087 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
11088 d.caret = d.source.find('b').unwrap();
11089 d.indent();
11090 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
11091 let lists = d
11092 .nodes()
11093 .iter()
11094 .filter(|n| n.kind == Kind::OrderedList)
11095 .count();
11096 assert_eq!(lists, 2, "the indented item is a nested ordered list");
11097 }
11098 }
11099
11100 #[test]
11101 fn indent_leaves_a_lists_first_item_put() {
11102 // The first item of a list has no sibling above it to nest under, so Tab
11103 // is a no-op there — the marker stays at column zero rather than being
11104 // shoved into indentation twig can't read as a sub-list.
11105 for view in [View::Source, View::Wysiwyg] {
11106 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
11107 d.caret = 1; // on the FIRST item
11108 d.indent();
11109 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
11110 // The sibling below still nests, proving the guard is per-item.
11111 d.caret = d.source.find('b').unwrap();
11112 d.indent();
11113 assert_eq!(d.source, "- a\n - b\n");
11114 }
11115 }
11116
11117 #[test]
11118 fn hidden_mode_keeps_typed_markup_literal() {
11119 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
11120 // twig escapes what would open markup, so the source is `\*hi\*` and the
11121 // AST is a plain string. Formatting is the commands' job in this mode.
11122 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
11123 d.insert("*hi*");
11124 assert_eq!(d.source, "\\*hi\\*");
11125 assert!(
11126 d.nodes()
11127 .iter()
11128 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
11129 );
11130 }
11131
11132 #[test]
11133 fn hidden_mode_escapes_a_line_start_block_marker() {
11134 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
11135 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
11136 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
11137 d.insert("# hi");
11138 assert_eq!(d.source, "\\# hi");
11139 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
11140 }
11141
11142 #[test]
11143 fn authoring_modes_keep_typed_markup_live() {
11144 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
11145 // (no escape), the same as source view — escaping is `None`'s alone, and
11146 // it's the axis, not the reveal, that decides.
11147 for (view, mode) in [
11148 (View::Wysiwyg, MarkupMode::Shortcuts),
11149 (View::Wysiwyg, MarkupMode::Full),
11150 (View::Source, MarkupMode::None),
11151 ] {
11152 let mut d = doc_in(view, "live_markup", "");
11153 d.set_markup_mode(mode);
11154 d.insert("*hi*");
11155 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
11156 }
11157 }
11158
11159 #[test]
11160 fn hidden_mode_overwrite_undoes_in_one_step() {
11161 // Typing over a selection escapes the replacement *and* stays a single
11162 // undo — the selection-delete and the literal insert fold together, so
11163 // one undo brings the whole selection back, like a plain overwrite.
11164 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
11165 d.anchor = Some(2);
11166 d.caret = 6; // "word"
11167 d.insert("*");
11168 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
11169 d.undo();
11170 assert_eq!(d.source, "a word b\n");
11171 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
11172 }
11173
11174 #[test]
11175 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
11176 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
11177 // the whole visual character, never stranding the hidden `\`.
11178 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
11179 d.insert("*");
11180 assert_eq!(d.source, "\\*");
11181 d.backspace();
11182 assert_eq!(d.source, "", "the escape backslash went with the *");
11183 // A *literal* backslash (source view, no escape) is an ordinary char.
11184 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
11185 s.caret = 3; // after `b`
11186 s.backspace();
11187 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
11188 }
11189
11190 #[test]
11191 fn hidden_mode_leaves_structural_markup_alone() {
11192 // Enter continues a bullet list by writing a real `- ` marker (an
11193 // `insert_raw`, not the typing path), so Hidden mode's escaping never
11194 // touches it — the list keeps working.
11195 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
11196 d.caret = 6;
11197 d.newline();
11198 d.insert("two");
11199 assert_eq!(d.source, "- item\n- two\n");
11200 }
11201
11202 #[test]
11203 fn markup_mode_defaults_to_none_and_round_trips() {
11204 // Diaryx's default is the clean `None` surface; a markup-fluent
11205 // frontend can climb the ladder, and the choice sticks.
11206 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
11207 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
11208 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
11209 d.set_markup_mode(mode);
11210 assert_eq!(d.markup_mode(), mode);
11211 }
11212 }
11213
11214 #[test]
11215 fn full_mode_reveals_only_the_caret_line() {
11216 // The mode's whole claim: the caret's line shows its raw delimiters and
11217 // every other line stays resolved. Two paragraphs with identical markup
11218 // so the only difference between the rows is where the caret is.
11219 let mut d = doc_in(
11220 View::Wysiwyg,
11221 "reveal_caret_line",
11222 "*one* here\n\n*two* there\n",
11223 );
11224 d.set_markup_mode(MarkupMode::Full);
11225
11226 caret_at(&mut d, "one");
11227 let rows = drawn_rows(&d);
11228 assert!(
11229 rows.iter().any(|r| r == "*one* here"),
11230 "caret's line raw: {rows:?}"
11231 );
11232 assert!(
11233 rows.iter().any(|r| r == "two there"),
11234 "other line resolved: {rows:?}"
11235 );
11236
11237 // Move to the other paragraph: the reveal follows, and the line just
11238 // left goes back to being resolved.
11239 caret_at(&mut d, "two");
11240 let rows = drawn_rows(&d);
11241 assert!(
11242 rows.iter().any(|r| r == "*two* there"),
11243 "caret's line raw: {rows:?}"
11244 );
11245 assert!(
11246 rows.iter().any(|r| r == "one here"),
11247 "left line resolved: {rows:?}"
11248 );
11249 }
11250
11251 #[test]
11252 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
11253 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
11254 // the same treatment as an emphasis's `*`: hidden while the caret is
11255 // elsewhere, shown in full where the caret lands. That falls out of
11256 // `delims` reading the bytes between the mark's span and its content
11257 // span, which is exactly `==🔴 ` and `==`, rather than from a table
11258 // of spellings — so the no-space form `==🟢green==` reveals right too.
11259 let mut d = doc_in(
11260 View::Wysiwyg,
11261 "reveal_coloured_mark",
11262 "a ==🔴 red== one\n\nb ==plain== two\n",
11263 );
11264 d.set_markup_mode(MarkupMode::Full);
11265
11266 caret_at(&mut d, "red");
11267 let rows = drawn_rows(&d);
11268 assert!(
11269 rows.iter().any(|r| r == "a ==🔴 red== one"),
11270 "the caret's line shows the colour it was written with: {rows:?}"
11271 );
11272 assert!(
11273 rows.iter().any(|r| r == "b plain two"),
11274 "and every other line stays resolved: {rows:?}"
11275 );
11276
11277 // Away from it, the emoji goes back to being markup — the reader sees
11278 // the words and the wash.
11279 caret_at(&mut d, "two");
11280 let rows = drawn_rows(&d);
11281 assert!(
11282 rows.iter().any(|r| r == "a red one"),
11283 "resolved again: {rows:?}"
11284 );
11285 }
11286
11287 #[test]
11288 fn hidden_modes_never_reveal_wherever_the_caret_is() {
11289 // The two rungs below `Full` share a rendering: delimiters stay hidden
11290 // even under the caret. `Shortcuts` differing from `None` only in what
11291 // typing does is exactly the point of splitting the axes.
11292 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
11293 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
11294 d.set_markup_mode(mode);
11295 caret_at(&mut d, "one");
11296 let rows = drawn_rows(&d);
11297 assert!(
11298 rows.iter().any(|r| r == "one here"),
11299 "{mode:?} hides: {rows:?}"
11300 );
11301 assert!(
11302 !rows.iter().any(|r| r.contains('*')),
11303 "{mode:?} shows no `*`: {rows:?}"
11304 );
11305 }
11306 }
11307
11308 #[test]
11309 fn revealed_delimiters_are_the_authors_own_spelling() {
11310 // Delimiters are re-read from the source rather than synthesized per
11311 // kind, so a line comes back spelled the way it was written: `_em_` does
11312 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
11313 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
11314 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
11315 d.set_markup_mode(MarkupMode::Full);
11316 caret_at(&mut d, "em");
11317 let rows = drawn_rows(&d);
11318 assert!(
11319 rows.iter().any(|r| r == body.trim_end()),
11320 "the revealed line is its own source: {rows:?}"
11321 );
11322 }
11323
11324 #[test]
11325 fn revealed_heading_shows_its_hashes() {
11326 // The `# ` marker is a block-level prefix, not an inline delimiter, so
11327 // it takes its own path — but it reveals on the same rule.
11328 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
11329 d.set_markup_mode(MarkupMode::Full);
11330
11331 caret_at(&mut d, "Title");
11332 assert!(
11333 drawn_rows(&d).iter().any(|r| r == "# Title"),
11334 "{:?}",
11335 drawn_rows(&d)
11336 );
11337
11338 caret_at(&mut d, "body");
11339 let rows = drawn_rows(&d);
11340 assert!(
11341 rows.iter().any(|r| r == "Title"),
11342 "hashes hidden again: {rows:?}"
11343 );
11344 }
11345
11346 #[test]
11347 fn revealed_delimiters_are_caret_stops() {
11348 // A delimiter that is drawn but can't be reached is worse than one
11349 // that's hidden: the mode exists so the markup can be *edited*. Every
11350 // revealed byte must be somewhere the caret can stand.
11351 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
11352 d.set_markup_mode(MarkupMode::Full);
11353 caret_at(&mut d, "em");
11354 let opener = d.source.find('*').unwrap();
11355 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
11356 assert!(
11357 d.vmap.is_stop(opener + 3),
11358 "the closing `*` is a caret stop"
11359 );
11360 }
11361
11362 #[test]
11363 fn setext_heading_reveals_nothing_across_its_newline() {
11364 // A setext heading's underline is on another line, so it is not the
11365 // caret line's to reveal — and emitting it would inject a `\n` glyph
11366 // that splits the row where the author wrote no break.
11367 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
11368 d.set_markup_mode(MarkupMode::Full);
11369 caret_at(&mut d, "Title");
11370 let rows = drawn_rows(&d);
11371 assert!(
11372 rows.iter().any(|r| r == "Title"),
11373 "title renders alone: {rows:?}"
11374 );
11375 assert!(
11376 !rows.iter().any(|r| r.contains('=')),
11377 "no underline leaks in: {rows:?}"
11378 );
11379 }
11380
11381 #[test]
11382 fn markup_mode_axes_split_the_ladder() {
11383 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
11384 // that authors markup but still hides it, and it's the only rung where
11385 // the two axes disagree.
11386 assert!(!MarkupMode::None.authors());
11387 assert!(!MarkupMode::None.reveals_caret_line());
11388 assert!(MarkupMode::Shortcuts.authors());
11389 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
11390 assert!(MarkupMode::Full.authors());
11391 assert!(MarkupMode::Full.reveals_caret_line());
11392 }
11393
11394 #[test]
11395 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
11396 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
11397 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
11398 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
11399 // nested bullet and `hello` stays prose: the file round-trips instead of
11400 // hiding a heading the user never asked for.
11401 for view in [View::Source, View::Wysiwyg] {
11402 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
11403 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
11404 d.indent();
11405 assert_eq!(d.source, "- hello\n * \n");
11406 assert!(
11407 d.nodes().iter().all(|n| n.kind != Kind::Heading),
11408 "no heading"
11409 );
11410 // And it's genuinely a nested list, not a flat one.
11411 assert_eq!(
11412 d.nodes()
11413 .iter()
11414 .filter(|n| n.kind == Kind::BulletList)
11415 .count(),
11416 2
11417 );
11418 }
11419 }
11420
11421 #[test]
11422 fn indenting_a_dash_item_with_content_keeps_its_dash() {
11423 // With content, `- x` can't be a setext underline, so there's nothing to
11424 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
11425 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
11426 d.caret = d.source.find('x').unwrap();
11427 d.indent();
11428 assert_eq!(d.source, "- hello\n - x\n");
11429 }
11430
11431 #[test]
11432 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
11433 // The dash→`*` repair coalesces into the Tab, so a single undo restores
11434 // the whole pre-Tab state rather than stranding a half-collapsed doc.
11435 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
11436 d.caret = d.source.find("- \n").unwrap() + 2;
11437 d.indent();
11438 assert_eq!(d.source, "- hello\n * \n");
11439 d.undo();
11440 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
11441 }
11442
11443 #[test]
11444 fn indent_leaves_a_nested_lists_first_item_put_too() {
11445 // The guard is about siblings, not depth: the first item of an *inner*
11446 // list (already nested under `a`) still has nothing before it at its own
11447 // level, so Tab can't take it deeper.
11448 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
11449 d.caret = d.source.find('b').unwrap();
11450 d.indent();
11451 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
11452 // But `c` (a sibling of `b`) nests under `b`.
11453 d.caret = d.source.find('c').unwrap();
11454 d.indent();
11455 assert_eq!(d.source, "- a\n - b\n - c\n");
11456 }
11457
11458 #[test]
11459 fn backspace_at_a_nested_item_start_outdents_it() {
11460 // Backspace with the caret right after a nested item's marker gives back
11461 // one level of nesting, the mirror of Tab — and renumbers the flattened
11462 // ordered list back to a clean run.
11463 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
11464 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
11465 d.backspace();
11466 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11467 }
11468
11469 #[test]
11470 fn backspace_at_a_top_level_item_start_strips_the_marker() {
11471 // At the outermost level there's no nesting left to give back, so the same
11472 // keystroke drops the bullet and leaves a plain paragraph.
11473 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
11474 d.caret = d.source.find('b').unwrap(); // right after `- `
11475 d.backspace();
11476 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
11477 }
11478
11479 #[test]
11480 fn backspace_mid_item_still_deletes_a_character() {
11481 // The list behaviour is armed only at the item's content start; anywhere
11482 // else Backspace is the ordinary character delete.
11483 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
11484 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
11485 d.backspace();
11486 assert_eq!(d.source, "- b\n");
11487 }
11488
11489 #[test]
11490 fn backspace_at_a_heading_start_strips_the_marker() {
11491 // The `# ` is markup the rich view hides, so Backspace over it takes the
11492 // whole marker and leaves a paragraph. Deleting a byte of it instead left
11493 // `#Title` — no longer a heading, with the hash now literal text the user
11494 // never typed and has to delete again.
11495 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
11496 d.caret = d.source.find('T').unwrap(); // right after `## `
11497 d.backspace();
11498 assert_eq!(d.source, "Title\n");
11499 assert_eq!(
11500 d.caret, 0,
11501 "the caret stays with the text it was in front of"
11502 );
11503 }
11504
11505 #[test]
11506 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
11507 // Only the heading's own marker goes — the quote (or list) it sits in is
11508 // untouched, exactly as un-heading it should be.
11509 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
11510 d.caret = d.source.find('T').unwrap();
11511 d.backspace();
11512 assert_eq!(d.source, "> Title\n");
11513 }
11514
11515 #[test]
11516 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
11517 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
11518 // behind would surface the same stray hash the marker delete just avoided.
11519 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
11520 d.caret = d.source.find('T').unwrap();
11521 d.backspace();
11522 assert_eq!(d.source, "Title\n");
11523 // And it's one edit: a single undo puts the whole heading back.
11524 d.undo();
11525 assert_eq!(d.source, "# Title #\n");
11526 }
11527
11528 #[test]
11529 fn backspace_mid_heading_still_deletes_a_character() {
11530 // The heading behaviour is armed only at the content's start; anywhere
11531 // else Backspace is the ordinary character delete.
11532 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
11533 d.caret = d.source.find('b').unwrap();
11534 d.backspace();
11535 assert_eq!(d.source, "# b\n");
11536 }
11537
11538 #[test]
11539 fn source_view_backspace_still_edits_the_heading_marker_literally() {
11540 // In source view the `# ` is text on the screen the user is deleting a
11541 // byte of, so it keeps its literal meaning — the same split the list
11542 // ladder and Enter draw between the two views.
11543 let mut d = doc_with("bsp_head_src", "# Title\n");
11544 d.caret = d.source.find('T').unwrap();
11545 d.backspace();
11546 assert_eq!(d.source, "#Title\n");
11547 }
11548
11549 #[test]
11550 fn outdent_unnests_an_ordered_item_in_one_press() {
11551 // Shift+Tab gives back exactly the marker width the indent added, so a
11552 // nested ordered item unnests in a single press, and the flattened list
11553 // renumbers back to a clean 1, 2, 3.
11554 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
11555 d.caret = d.source.find('b').unwrap();
11556 d.outdent();
11557 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11558 let lists = d
11559 .nodes()
11560 .iter()
11561 .filter(|n| n.kind == Kind::OrderedList)
11562 .count();
11563 assert_eq!(lists, 1, "back to one flat list");
11564 }
11565
11566 #[test]
11567 fn table_insert_row_adds_a_row_below_the_caret() {
11568 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11569 d.caret = d.source.find('1').unwrap(); // in the body row
11570 d.table_insert_row(true);
11571 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
11572 }
11573
11574 #[test]
11575 fn table_insert_and_delete_column_at_the_caret() {
11576 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11577 d.caret = d.source.find('a').unwrap(); // column 0
11578 d.table_insert_column(true); // add a column to the right of `a`
11579 assert_eq!(
11580 d.source,
11581 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
11582 );
11583 d.caret = d.source.find('b').unwrap(); // now the third column
11584 d.table_delete_column();
11585 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
11586 }
11587
11588 // ── ragged formats ───────────────────────────────────────────────────────
11589 // No format spells every gesture. HTML writes the inline marks as a tag pair
11590 // and no heading, list, quote or link; Markdown spells five of the eight
11591 // marks — the highlight only because leaf parses with `highlight`, which is
11592 // why the question is asked with the extensions; djot spells all eight and
11593 // no in-cell break. leaf asks twig per
11594 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
11595 // op discover the fact on its own — one of them didn't.
11596
11597 /// An HTML document in the rich view, ready for a gesture.
11598 fn html_doc(body: &str) -> Doc {
11599 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
11600 d.view = View::Wysiwyg;
11601 d.build_visual(80);
11602 d
11603 }
11604
11605 #[test]
11606 fn a_table_gesture_leaves_an_html_table_alone() {
11607 // The regression this guard exists for. twig's table editor consults no
11608 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
11609 // HTML `<table>` as a *pipe table* and reported success: the whole
11610 // element replaced by `| a | b |`, silently, on one press of a toolbar
11611 // button. Every grid op went the same way.
11612 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
11613 // A table of named operations, which is what it looks like.
11614 #[allow(clippy::type_complexity)]
11615 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11616 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
11617 ("delete row", &|d: &mut Doc| d.table_delete_row()),
11618 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
11619 ("delete column", &|d: &mut Doc| d.table_delete_column()),
11620 ("align", &|d: &mut Doc| {
11621 d.table_set_alignment(Alignment::Right)
11622 }),
11623 ("move row", &|d: &mut Doc| d.table_move_row(true)),
11624 ("move column", &|d: &mut Doc| d.table_move_column(true)),
11625 ];
11626 for (name, op) in ops {
11627 let mut d = html_doc(src);
11628 d.caret = d.source.find('a').unwrap();
11629 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
11630 op(&mut d);
11631 assert_eq!(d.source, src, "{name} rewrote an HTML table");
11632 assert!(
11633 !d.dirty,
11634 "{name} marked the document dirty without editing it"
11635 );
11636 assert!(d.status.is_some(), "{name} refused without saying why");
11637 }
11638 }
11639
11640 #[test]
11641 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
11642 // A quote wraps a range rather than prefixing each line, a link's
11643 // destination lives in an attribute — different *shapes*, not a
11644 // different alphabet, so twig spells none of them and neither does leaf.
11645 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11646 // A table of named operations, which is what it looks like.
11647 #[allow(clippy::type_complexity)]
11648 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11649 ("quote", &|d: &mut Doc| d.toggle_blockquote()),
11650 ("list", &|d: &mut Doc| d.toggle_list(false)),
11651 ("task item", &|d: &mut Doc| d.toggle_task_item()),
11652 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
11653 ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
11654 ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
11655 ("video", &|d: &mut Doc| {
11656 d.insert_media(MediaKind::Video, "clip.mp4", "")
11657 }),
11658 ];
11659 for (name, op) in ops {
11660 let mut d = html_doc(src);
11661 let at = d.source.find("Hello").unwrap();
11662 d.caret = at;
11663 d.anchor = Some(at + 5); // a selection, for the ops that want one
11664 op(&mut d);
11665 assert_eq!(d.source, src, "{name} edited an HTML document");
11666 assert!(
11667 !d.dirty,
11668 "{name} marked the document dirty without editing it"
11669 );
11670 let status = d.status.as_deref().unwrap_or("");
11671 assert!(
11672 status.contains("html"),
11673 "{name}: the refusal should name the format, got {status:?}"
11674 );
11675 }
11676 }
11677
11678 #[test]
11679 fn html_spells_a_heading_as_its_tag_pair() {
11680 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
11681 // along — the one block gesture whose HTML shape it can write. So ⌘2
11682 // in an HTML document is a real edit, and ⌘0 takes it back.
11683 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
11684 let mut d = html_doc(src);
11685 d.caret = d.source.find("Hello").unwrap();
11686 d.toggle_heading(2);
11687 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
11688 assert!(d.dirty);
11689 assert_eq!(d.status, None, "a supported gesture reports nothing");
11690 d.toggle_heading(2);
11691 assert_eq!(d.source, src, "the same level again is back to a paragraph");
11692 }
11693
11694 #[test]
11695 fn html_spells_the_inline_marks_and_the_rule() {
11696 // The other half, and why one per-document flag stopped being enough:
11697 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
11698 // already emits and the parser reads straight back as the same mark —
11699 // and the rule button writes an `<hr>`. Refusing these on the old
11700 // "HTML is parse-only" reading would now be leaf's own limitation.
11701 let mut d = html_doc("<p>Hello world</p>\n");
11702 let at = d.source.find("world").unwrap();
11703 d.caret = at;
11704 d.anchor = Some(at + 5);
11705 d.toggle(InlineKind::Strong);
11706 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
11707 assert!(d.dirty);
11708 assert_eq!(d.status, None, "a supported gesture reports nothing");
11709
11710 // And off again — the toggle reverses, which is the property that makes
11711 // authoring in HTML worth offering rather than a one-way trip.
11712 d.toggle(InlineKind::Strong);
11713 assert_eq!(d.source, "<p>Hello world</p>\n");
11714
11715 let mut d = html_doc("<p>Hello world</p>\n");
11716 d.caret = d.source.find("world").unwrap();
11717 d.insert_thematic_break();
11718 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
11719 }
11720
11721 #[test]
11722 fn a_mark_the_format_cannot_spell_arms_nothing() {
11723 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
11724 // sticky mark for the next text typed. Guarding only the twig call
11725 // leaves that path live, promising a mark the gesture will not write and
11726 // then swallowing the error inside `insert`.
11727 //
11728 // Markdown carries this, on the superscript now rather than on the
11729 // highlight: `^x^` is text there in any configuration, whereas twig
11730 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
11731 // which every leaf document does.
11732 let mut d = doc_with("mark", "Hello world\n");
11733 d.view = View::Wysiwyg;
11734 d.build_visual(80);
11735 d.caret = d.source.find("world").unwrap();
11736 d.toggle(InlineKind::Superscript);
11737 assert!(d.pending_marks.is_empty(), "no mark should be armed");
11738 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
11739 d.insert("X");
11740 assert_eq!(d.source, "Hello Xworld\n");
11741 }
11742
11743 #[test]
11744 fn markdown_authors_a_highlight_and_a_strikethrough() {
11745 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
11746 // write. `==x==` is authorable because leaf's own `parse_extensions`
11747 // turns `highlight` on — twig will only mint bytes this editor's reparse
11748 // reads back — and `~~x~~` because GFM strikethrough is parsed by
11749 // default, so the refusal there was never right for any leaf document.
11750 for (kind, marked) in [
11751 (InlineKind::Mark, "a ==word== b\n"),
11752 (InlineKind::Delete, "a ~~word~~ b\n"),
11753 ] {
11754 let mut d = doc_with("author_mark", "a word b\n");
11755 d.anchor = Some(2);
11756 d.caret = 6;
11757 d.toggle(kind);
11758 assert_eq!(d.source, marked, "{kind:?}");
11759 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
11760 assert!(d.dirty, "{kind:?}");
11761 // The region stays selected, so the second press reverses it — the
11762 // property that separates authoring from a one-way trip.
11763 d.toggle(kind);
11764 assert_eq!(d.source, "a word b\n", "{kind:?}");
11765 }
11766 }
11767
11768 #[test]
11769 fn an_authored_highlight_reads_back_as_a_mark() {
11770 // The round trip the extension gate exists to protect: what the toggle
11771 // writes, the reparse must read back as a `mark` rather than as two
11772 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
11773 // rebuilt map rather than from the source text.
11774 let mut d = doc_with("mark_roundtrip", "a word b\n");
11775 d.view = View::Wysiwyg;
11776 d.build_visual(80);
11777 d.anchor = Some(2);
11778 d.caret = 6;
11779 d.toggle(InlineKind::Mark);
11780 assert_eq!(d.source, "a ==word== b\n");
11781 d.build_visual(80);
11782 let w = d
11783 .vmap
11784 .rows
11785 .iter()
11786 .flat_map(|r| r.glyphs.iter())
11787 .find(|g| g.ch == 'w')
11788 .expect("the highlighted word");
11789 assert_eq!(w.style.role, crate::Role::Mark(None));
11790 }
11791
11792 #[test]
11793 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
11794 // The three states of one gesture, in the order a palette is pressed:
11795 // an uncoloured highlight takes the prefix, a coloured one has it
11796 // replaced, and `None` takes it away with the space that was part of the
11797 // spelling.
11798 let mut d = doc_with("mark_colour", "a ==word== b\n");
11799 d.caret = d.source.find("word").unwrap();
11800 d.set_mark_color(Some(MarkColor::Red));
11801 assert_eq!(d.source, "a ==🔴 word== b\n");
11802 assert_eq!(d.status, None);
11803 assert!(d.dirty);
11804
11805 d.set_mark_color(Some(MarkColor::Blue));
11806 assert_eq!(d.source, "a ==🔵 word== b\n");
11807
11808 d.set_mark_color(None);
11809 assert_eq!(d.source, "a ==word== b\n");
11810 }
11811
11812 #[test]
11813 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
11814 // The prefix is written *before* the word, so an offset in the word has
11815 // to ride its width — a caret that stayed put would be a caret that
11816 // walked backwards through the text it was standing in.
11817 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
11818 let word = d.source.find("word").unwrap();
11819 d.caret = word + 2; // between `wo` and `rd`
11820 d.set_mark_color(Some(MarkColor::Red));
11821 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
11822
11823 // And back the other way when the prefix goes.
11824 d.set_mark_color(None);
11825 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
11826 }
11827
11828 #[test]
11829 fn the_colour_at_the_caret_is_what_the_palette_lights() {
11830 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
11831 d.caret = d.source.find("red").unwrap();
11832 assert!(d.caret_in_mark());
11833 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
11834
11835 d.caret = d.source.find("plain").unwrap();
11836 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
11837 assert_eq!(d.mark_color_at_caret(), None);
11838
11839 d.caret = d.source.find(" and ").unwrap() + 2;
11840 assert!(!d.caret_in_mark());
11841 assert_eq!(d.mark_color_at_caret(), None);
11842 }
11843
11844 #[test]
11845 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
11846 // The gesture colours a highlight that exists; it does not make one.
11847 // Two presses is the price of a coloured highlight from bare text, and
11848 // the reason is undo — one press that spliced twice would take two
11849 // presses to take back.
11850 let mut d = doc_with("mark_colour_none", "a word b\n");
11851 d.caret = d.source.find("word").unwrap();
11852 d.set_mark_color(Some(MarkColor::Red));
11853 assert_eq!(d.source, "a word b\n");
11854 assert!(d.status.is_some(), "it should say why");
11855 assert!(!d.dirty);
11856
11857 // Clearing where there is nothing to clear is the same refusal, not a
11858 // quiet success — the caret is in no highlight either way.
11859 d.status = None;
11860 d.set_mark_color(None);
11861 assert_eq!(d.source, "a word b\n");
11862 assert!(d.status.is_some());
11863 }
11864
11865 #[test]
11866 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
11867 // twig answers this one *successfully* with a `Change` describing some
11868 // earlier edit, so a caller that trusted the change would jump the caret
11869 // to wherever that was. Core answers it before asking.
11870 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
11871 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
11872 d.caret = d.source.find("word").unwrap();
11873 let (source, caret) = (d.source.clone(), d.caret);
11874 d.set_mark_color(None);
11875 assert_eq!(d.source, source);
11876 assert_eq!(
11877 d.caret, caret,
11878 "the caret must not ride a change that isn't one"
11879 );
11880 assert_eq!(d.status, None, "and it is not an error either");
11881 }
11882
11883 #[test]
11884 fn djot_spells_the_highlight_and_not_its_colour() {
11885 // The reason the palette is its own capability rather than the Highlight
11886 // button's: `{=word=}` is a highlight djot writes happily, and there is
11887 // no djot spelling for a colour on it.
11888 assert!(Capabilities::of(Format::Djot).mark);
11889 assert!(!Capabilities::of(Format::Djot).mark_color);
11890 assert!(Capabilities::of(Format::Markdown).mark_color);
11891
11892 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
11893 d.caret = d.source.find("word").unwrap();
11894 assert!(
11895 d.caret_in_mark(),
11896 "the caret is in a highlight all the same"
11897 );
11898 d.set_mark_color(Some(MarkColor::Red));
11899 assert_eq!(d.source, "a {=word=} b\n");
11900 assert!(
11901 d.status.as_deref().unwrap_or("").contains("djot"),
11902 "and the refusal names the document's format: {:?}",
11903 d.status
11904 );
11905 }
11906
11907 #[test]
11908 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
11909 // The round trip that matters for a palette: the bytes twig writes are
11910 // bytes its own reparse reads back as a colour, so the swatch that was
11911 // pressed is the swatch that lights afterwards.
11912 let mut d = doc_with("mark_colour_undo", "a word b\n");
11913 d.anchor = Some(2);
11914 d.caret = 6;
11915 d.toggle(InlineKind::Mark);
11916 d.caret = d.source.find("word").unwrap();
11917 d.set_mark_color(Some(MarkColor::Green));
11918 assert_eq!(d.source, "a ==🟢 word== b\n");
11919 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
11920
11921 // One splice, one step: the colour comes off and the highlight stays.
11922 d.undo();
11923 assert_eq!(d.source, "a ==word== b\n");
11924 d.undo();
11925 assert_eq!(d.source, "a word b\n");
11926 }
11927
11928 #[test]
11929 fn every_colour_leaf_names_is_one_twig_writes() {
11930 // The two enums are one vocabulary, and this is what says so: each of
11931 // leaf's colours writes an emoji twig's reparse reads back as *that*
11932 // colour, so `twig_mark_color`'s table cannot quietly pair red with
11933 // orange.
11934 for color in MarkColor::ALL {
11935 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
11936 d.caret = d.source.find("word").unwrap();
11937 d.set_mark_color(Some(color));
11938 assert_eq!(d.status, None, "{color:?}");
11939 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
11940 }
11941 }
11942
11943 #[test]
11944 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
11945 // The two presses a coloured highlight is made of, in the state the
11946 // first one leaves: `toggle` selects the whole `==word==` and puts the
11947 // caret one past the closing `==`, which is *not* in the mark. Asking at
11948 // the caret alone would refuse to colour the highlight just written —
11949 // the selection's start is what answers.
11950 let mut d = doc_with("mark_colour_fresh", "a word b\n");
11951 d.anchor = Some(2);
11952 d.caret = 6;
11953 d.toggle(InlineKind::Mark);
11954 assert_eq!(d.source, "a ==word== b\n");
11955 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
11956
11957 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
11958 d.set_mark_color(Some(MarkColor::Yellow));
11959 assert_eq!(d.source, "a ==🟡 word== b\n");
11960 assert_eq!(d.status, None);
11961 }
11962
11963 #[test]
11964 fn one_press_highlights_a_selection_and_colours_it() {
11965 // What a toolbar swatch means over a plain selection, and the undo it
11966 // has to have: one press, one step. Two steps would leave an uncoloured
11967 // highlight behind on the way back, which is a state the author never
11968 // asked for and never saw.
11969 let mut d = doc_with("highlight_one", "a word b\n");
11970 d.anchor = Some(2);
11971 d.caret = 6;
11972 d.highlight(Some(MarkColor::Purple));
11973 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
11974 assert_eq!(d.status, None);
11975
11976 d.undo();
11977 assert_eq!(d.source, "a word b\n", "one press, one undo");
11978 }
11979
11980 #[test]
11981 fn one_press_on_an_existing_highlight_only_recolours_it() {
11982 // The other half: inside a highlight there is nothing to make, so the
11983 // compound is the plain gesture and the text is untouched.
11984 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
11985 d.caret = d.source.find("word").unwrap();
11986 d.highlight(Some(MarkColor::Blue));
11987 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
11988 d.undo();
11989 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
11990 }
11991
11992 #[test]
11993 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
11994 // `None` means "no colour", and over bare text that is the Highlight
11995 // button's own job. The fold must not happen here — there is no second
11996 // splice, and folding would take the *previous* edit into this one.
11997 let mut d = doc_with("highlight_none", "a word b and more\n");
11998 d.caret = d.source.find("more").unwrap() + 4; // after "more"
11999 d.insert("!"); // an earlier edit for a wrong fold to swallow
12000 d.anchor = Some(2);
12001 d.caret = 6;
12002 d.highlight(None);
12003 assert_eq!(d.source, "a ==word== b and more!\n");
12004
12005 d.undo();
12006 assert_eq!(
12007 d.source, "a word b and more!\n",
12008 "only the highlight came off"
12009 );
12010 d.undo();
12011 assert_eq!(
12012 d.source, "a word b and more\n",
12013 "and the edit before it survived"
12014 );
12015 }
12016
12017 #[test]
12018 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
12019 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
12020 // a colour cannot be armed with it — so the compound declines rather
12021 // than leaving half a promise.
12022 let mut d = doc_with("highlight_bare", "a word b\n");
12023 d.caret = 4;
12024 d.highlight(Some(MarkColor::Red));
12025 assert_eq!(d.source, "a word b\n");
12026 assert!(d.pending_marks.is_empty(), "and nothing armed");
12027 assert!(d.status.is_some());
12028 }
12029
12030 #[test]
12031 fn a_read_only_document_takes_no_colour() {
12032 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
12033 d.caret = d.source.find("word").unwrap();
12034 d.set_read_only(true);
12035 d.set_mark_color(Some(MarkColor::Red));
12036 assert_eq!(d.source, "a ==word== b\n");
12037 }
12038
12039 #[test]
12040 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
12041 // The other door into `toggle`: no selection, so nothing reaches twig
12042 // until `insert` realises the armed mark. It is armed now — the guard
12043 // above asks `Doc::supports`, which asks with the extensions — and what
12044 // it writes is the same `==…==`.
12045 let mut d = doc_with("sticky_mark", "xy\n");
12046 d.caret = 1;
12047 d.toggle(InlineKind::Mark);
12048 assert!(d.pending_marks.contains(InlineKind::Mark));
12049 d.insert("Z");
12050 assert_eq!(d.source, "x==Z==y\n");
12051 }
12052
12053 #[test]
12054 fn html_documents_still_take_typed_text() {
12055 // The guard covers *markup* gestures and must not touch plain editing:
12056 // twig's splicer is language-neutral, and typing into an HTML document
12057 // is the thing that does work today.
12058 let mut d = html_doc("<p>Hello world</p>\n");
12059 d.caret = d.source.find("world").unwrap();
12060 d.insert("big ");
12061 assert_eq!(d.source, "<p>Hello big world</p>\n");
12062 assert!(d.dirty);
12063 d.backspace();
12064 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
12065 d.undo();
12066 d.undo();
12067 assert_eq!(d.source, "<p>Hello world</p>\n");
12068 }
12069
12070 #[test]
12071 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
12072 // `authorable` only separates "there is a door in" from "there is not",
12073 // and HTML is on the near side of that line — which is exactly why a
12074 // toolbar must not be built from it.
12075 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
12076 assert!(html.authorable());
12077 assert!(
12078 !Doc::from_source("<r>x</r>".into(), Format::Xml)
12079 .unwrap()
12080 .authorable()
12081 );
12082
12083 let caps = html.capabilities();
12084 assert!(caps.bold && caps.italic && caps.code && caps.mark);
12085 assert!(caps.thematic_break && caps.cell_line_break);
12086 // A heading is a tag pair twig rebuilds (3.4); a quote or a list
12087 // prefixes lines, which HTML has no spelling for.
12088 assert!(caps.heading);
12089 assert!(!caps.blockquote && !caps.bullet_list);
12090 assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
12091 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
12092 // twig's table editor would happily re-emit as `| a | b |`.
12093 assert!(!caps.table);
12094
12095 // The two lightweight formats spell everything leaf offers — and still
12096 // differ from each other, which is the other half of why one boolean
12097 // can't serve.
12098 for fmt in [Format::Markdown, Format::Djot] {
12099 let caps = Capabilities::of(fmt);
12100 assert!(
12101 caps.heading && caps.blockquote && caps.ordered_list,
12102 "{fmt:?}"
12103 );
12104 assert!(
12105 caps.task && caps.link && caps.image && caps.table,
12106 "{fmt:?}"
12107 );
12108 }
12109 // Both spell the highlight and the strikethrough: djot natively, and
12110 // Markdown because `Capabilities` asks with `parse_extensions` rather
12111 // than with twig's defaults — `==x==` is text under those, and a mark
12112 // under the `highlight` leaf always parses with.
12113 for fmt in [Format::Markdown, Format::Djot] {
12114 let caps = Capabilities::of(fmt);
12115 assert!(caps.mark && caps.strike, "{fmt:?}");
12116 }
12117 // What still separates them, now that the highlight doesn't: djot has
12118 // no in-cell break, and Markdown spells neither of the scripts.
12119 assert!(Capabilities::of(Format::Djot).superscript);
12120 assert!(!Capabilities::of(Format::Markdown).superscript);
12121 assert!(Capabilities::of(Format::Markdown).cell_line_break);
12122 assert!(!Capabilities::of(Format::Djot).cell_line_break);
12123
12124 // A parse-only format answers no to every one of them, so the coarse
12125 // predicate and the record agree there.
12126 let caps = Capabilities::of(Format::Xml);
12127 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
12128 }
12129
12130 #[test]
12131 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
12132 // The guard exists to name the *document's* format rather than twig's
12133 // internals, so the message has to survive being one leaf writes itself.
12134 // Checked against the gesture twig also refuses, since that is the pair
12135 // most at risk of drifting apart.
12136 let mut d = html_doc("<p>Hello</p>\n");
12137 d.caret = d.source.find("Hello").unwrap();
12138 d.set_code_language("zig");
12139 assert_eq!(
12140 d.status.as_deref(),
12141 Some("code language: not supported in html")
12142 );
12143 assert!(!d.dirty);
12144 }
12145
12146 #[test]
12147 fn table_set_alignment_respells_the_delimiter() {
12148 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
12149 d.caret = d.source.find('b').unwrap();
12150 d.table_set_alignment(Alignment::Right);
12151 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
12152 }
12153
12154 #[test]
12155 fn each_empty_table_cell_has_its_own_editable_home() {
12156 // Regression: an empty cell has no twig content_span, so both cells of a
12157 // `| | |` row collapsed onto the row's start (before the first `│`).
12158 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
12159 // tell the cells apart. Each empty cell must now have a distinct home
12160 // inside it.
12161 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
12162 let (c0, c1) = {
12163 let cells = &d.vmap.tables[0].grid[1].cells;
12164 (cells[0].start, cells[1].start)
12165 };
12166 assert!(
12167 c0 < c1,
12168 "the two empty cells have distinct homes: {c0} < {c1}"
12169 );
12170 d.caret = c0;
12171 d.insert("x");
12172 assert_eq!(
12173 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
12174 "typed inside the cell"
12175 );
12176 }
12177
12178 #[test]
12179 fn arrows_step_into_each_empty_table_cell() {
12180 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
12181 let (c0, c1) = {
12182 let cells = &d.vmap.tables[0].grid[1].cells;
12183 (cells[0].start, cells[1].start)
12184 };
12185 d.caret = d.source.find('b').unwrap(); // in the header's second cell
12186 let mut seen = std::collections::HashSet::new();
12187 for _ in 0..6 {
12188 d.move_right(false);
12189 seen.insert(d.caret);
12190 }
12191 assert!(
12192 seen.contains(&c0),
12193 "right arrow reaches the first empty cell"
12194 );
12195 assert!(
12196 seen.contains(&c1),
12197 "right arrow reaches the second empty cell"
12198 );
12199 }
12200
12201 #[test]
12202 fn table_op_off_a_table_is_a_no_op_with_a_status() {
12203 let mut d = doc_with("tbl_none", "just text\n");
12204 d.caret = 3;
12205 d.table_insert_row(true);
12206 assert_eq!(d.source, "just text\n", "nothing changed");
12207 assert!(d.status.is_some(), "a status explains why");
12208 assert!(!d.caret_in_table());
12209 }
12210
12211 #[test]
12212 fn enter_in_an_ordered_list_renumbers_the_following_items() {
12213 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
12214 // the renumber pass keeps them sequential, matching what the view draws.
12215 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
12216 d.caret = d.source.find('a').unwrap() + 1; // end of item a
12217 d.newline();
12218 d.insert("x");
12219 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
12220 }
12221
12222 #[test]
12223 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
12224 for view in [View::Source, View::Wysiwyg] {
12225 let mut d = doc_in(view, "outdent_noop", "hello\n");
12226 d.caret = 2;
12227 d.outdent();
12228 assert_eq!(d.source, "hello\n");
12229 assert!(!d.dirty, "a no-op is not a modification");
12230 d.undo();
12231 assert_eq!(
12232 d.status.as_deref(),
12233 Some("nothing to undo"),
12234 "spends no undo step"
12235 );
12236 assert_eq!(d.source, "hello\n");
12237 }
12238 }
12239
12240 #[test]
12241 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
12242 for view in [View::Source, View::Wysiwyg] {
12243 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
12244 d.anchor = Some(0);
12245 d.caret = 7; // through "two"
12246 d.indent();
12247 assert_eq!(
12248 d.source, " one\n\n two\n",
12249 "the blank line keeps no trailing pad"
12250 );
12251 // Selected, so a second Tab lands on the same lines rather than on
12252 // whatever the shifted offsets now cover.
12253 assert_eq!(d.selection(), Some((0, 12)));
12254 d.indent();
12255 assert_eq!(d.source, " one\n\n two\n");
12256 }
12257 }
12258
12259 #[test]
12260 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
12261 for view in [View::Source, View::Wysiwyg] {
12262 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
12263 d.anchor = Some(0);
12264 d.caret = 15;
12265 d.outdent();
12266 assert_eq!(d.source, "two\none\nnone\n");
12267 }
12268 }
12269
12270 #[test]
12271 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
12272 for view in [View::Source, View::Wysiwyg] {
12273 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
12274 d.anchor = Some(0);
12275 d.caret = 7;
12276 d.indent();
12277 assert_eq!(d.source, " one\n\n two\n");
12278 d.undo();
12279 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
12280 assert_eq!(
12281 d.selection(),
12282 Some((0, 7)),
12283 "with the selection it was aimed at"
12284 );
12285 d.redo();
12286 assert_eq!(d.source, " one\n\n two\n");
12287 assert_eq!(
12288 d.selection(),
12289 Some((0, 12)),
12290 "redo replays the caret the indent placed, not the one splice left"
12291 );
12292 }
12293 }
12294
12295 #[test]
12296 fn vertical_motion_keeps_the_column() {
12297 let mut d = doc_with("move", "abcd\nef\n");
12298 d.caret = 3; // "abc|d" on row 0, col 3
12299 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
12300 assert_eq!(d.caret, 7); // just after "ef"
12301 }
12302
12303 // ── goal column ──────────────────────────────────────────────────────────
12304
12305 #[test]
12306 fn vertical_motion_goal_column_survives_a_short_line() {
12307 // Regression: re-deriving the column from the clamped position on
12308 // every step permanently forgets it once a short line clamps it.
12309 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
12310 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
12311 assert_eq!(
12312 g("abcd|ef\nxy\nghijkl\n", |d| {
12313 d.move_down(false); // clamps to end of "xy"
12314 d.move_down(false); // restores col 4 on the long line
12315 }),
12316 "abcdef\nxy\nghij|kl\n"
12317 );
12318 }
12319
12320 #[test]
12321 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
12322 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
12323 assert_eq!(d.goal_col, None);
12324 d.caret = 4; // row 0, col 4
12325 d.move_down(false); // clamps into "xy"; goal stays the original col
12326 assert_eq!(d.goal_col, Some(4));
12327 assert_eq!(d.caret_pos(), (1, 2));
12328
12329 // A horizontal motion drops the goal column...
12330 d.move_left(false);
12331 assert_eq!(d.goal_col, None);
12332
12333 // ...so the next vertical motion picks up the *new* column (1), not
12334 // the stale one (4).
12335 d.move_down(false);
12336 assert_eq!(d.goal_col, Some(1));
12337 assert_eq!(d.caret_pos(), (2, 1));
12338 }
12339
12340 #[test]
12341 fn editing_clears_the_goal_column() {
12342 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
12343 d.caret = 4;
12344 d.move_down(false);
12345 assert_eq!(d.goal_col, Some(4));
12346 d.insert("Z");
12347 assert_eq!(d.goal_col, None);
12348 }
12349
12350 #[test]
12351 fn vertical_motion_on_an_empty_document_is_a_no_op() {
12352 let mut d = doc_with("empty_vert", "");
12353 d.move_down(false);
12354 assert_eq!(d.caret, 0);
12355 d.move_up(false);
12356 assert_eq!(d.caret, 0);
12357 }
12358
12359 // ── the document's edges ─────────────────────────────────────────────────
12360
12361 #[test]
12362 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
12363 // The reproduction, and the disagreement: Down on the last line ran to
12364 // the end of the document in the source view — by accident, an
12365 // out-of-range row clamping to the end of the string — and did nothing
12366 // whatever in the view leaf opens in. One rule now, in both.
12367 for (view, tag) in VIEWS {
12368 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
12369 d.caret = 1;
12370 d.move_down(false);
12371 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
12372 d.move_up(false);
12373 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
12374 }
12375 }
12376
12377 #[test]
12378 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
12379 // Down off the bottom is a motion like any other, so it latches a goal
12380 // column — and Up comes back to the column the caret left, not to the
12381 // one the document's end happened to be in.
12382 for (view, tag) in VIEWS {
12383 let gap = if view == View::Source { "\n" } else { "\n\n" };
12384 let src = format!("abcdef{gap}ghijkl");
12385 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
12386 d.caret = 2; // row 0, col 2
12387 d.move_down(false);
12388 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
12389 d.move_down(false);
12390 assert_eq!(
12391 d.caret,
12392 src.len(),
12393 "{tag}: Down off the bottom reaches the end"
12394 );
12395 d.move_up(false);
12396 assert_eq!(
12397 d.caret_pos().1,
12398 2,
12399 "{tag}: Up returns to the column Down left"
12400 );
12401 }
12402 }
12403
12404 #[test]
12405 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
12406 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
12407 // Up that did nothing still armed a goal column, and the next Down aimed
12408 // at a column the caret had never been in.
12409 for (view, tag) in VIEWS {
12410 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
12411 d.caret = 0;
12412 d.move_up(false);
12413 assert_eq!(d.caret, 0, "{tag}: already at the start");
12414 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
12415
12416 d.caret = d.source.len();
12417 d.move_down(false);
12418 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
12419 assert_eq!(
12420 d.goal_col, None,
12421 "{tag}: a no-op Down latched a goal column"
12422 );
12423 }
12424 }
12425
12426 // ── soft wrap ────────────────────────────────────────────────────────────
12427 // Every other test here builds the map at 80 columns, where no fixture is
12428 // long enough to fold. A wrap is where one offset belongs to two rows at
12429 // once, and it broke everything that asks the caret what row it is on.
12430
12431 /// The wrapped fixture these cases share, folded at 12 columns into
12432 /// `one two ` / `three four ` / `five six ` / `seven eight`.
12433 fn wrapped_doc(name: &str) -> Doc {
12434 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
12435 d.build_visual(12);
12436 d
12437 }
12438
12439 #[test]
12440 fn home_and_end_work_from_a_wrapped_row() {
12441 // The reproduction: offset 19 is the `f` of "five", the first character
12442 // of the third row — and also the offset the second row ends at. It
12443 // resolved to the *second* row, so End aimed at a place the caret was
12444 // already in and did nothing, while Home walked backwards onto a row the
12445 // caret had left.
12446 let mut d = wrapped_doc("wrap_home_end");
12447 d.caret = 19;
12448 assert_eq!(
12449 d.caret_pos(),
12450 (2, 0),
12451 "the wrap boundary opens the third row"
12452 );
12453 d.move_end(false);
12454 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
12455 d.move_home(false);
12456 assert_eq!(d.caret, 19, "Home left the row the caret was on");
12457 }
12458
12459 #[test]
12460 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
12461 // The row's end is the last offset that is only ever its own: the offset
12462 // past it opens the row below, and aiming there would send a second
12463 // press on to *that* row's end, and a third to the next — End walking
12464 // down the paragraph rather than sitting where it landed.
12465 let mut d = wrapped_doc("wrap_end_twice");
12466 d.caret = 12; // inside "three", on the second row
12467 d.move_end(false);
12468 assert_eq!(
12469 d.caret, 18,
12470 "the end of `three four`, before the space the wrap ate"
12471 );
12472 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
12473 d.move_end(false);
12474 assert_eq!(d.caret, 18, "a second End moved the caret");
12475 d.move_home(false);
12476 assert_eq!(d.caret, 8, "Home takes the row's own start");
12477 }
12478
12479 #[test]
12480 fn vertical_motion_crosses_a_soft_wrap() {
12481 // Down aimed at the row below's column 0, an offset that resolved *up*
12482 // to the row above's end — so it landed on the offset it already had and
12483 // the caret could never leave a paragraph's first row.
12484 let mut d = wrapped_doc("wrap_down");
12485 d.caret = 0;
12486 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
12487 d.move_down(false);
12488 assert_eq!(d.caret, want, "Down stalled");
12489 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
12490 }
12491 d.move_down(false);
12492 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
12493
12494 // ...and back up, one row per press. The goal column is the end of the
12495 // last row, past every other row's width, so each press clamps to the
12496 // row's own last offset rather than to the one that opens the next.
12497 let mut d = wrapped_doc("wrap_up");
12498 d.caret = 39;
12499 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
12500 d.move_up(false);
12501 assert_eq!(d.caret, want, "Up stalled");
12502 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
12503 }
12504 }
12505
12506 #[test]
12507 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
12508 // The kills take the same line Home and End do, so in WYSIWYG they take
12509 // the visual row — and a soft wrap has no newline in it to delete, so
12510 // nothing is joined by reaching the end of one.
12511 let mut d = wrapped_doc("wrap_kill");
12512 d.caret = 19; // the `f` of "five", opening the third row
12513 d.delete_to_line_end();
12514 // The space the wrap ate goes with the row it was drawn on: sparing it
12515 // would leave "four seven", two spaces where the row had been.
12516 assert_eq!(d.source, "one two three four seven eight");
12517
12518 // Backwards from the row's last caret position — which is *before* that
12519 // space, so this one survives, being on the far side of the caret.
12520 let mut d = wrapped_doc("wrap_kill_back");
12521 d.caret = 27;
12522 d.delete_to_line_start();
12523 assert_eq!(d.source, "one two three four seven eight");
12524 }
12525
12526 // ── document start / end ────────────────────────────────────────────────
12527
12528 #[test]
12529 fn move_doc_start_and_end_jump_to_the_edges() {
12530 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
12531 assert_eq!(
12532 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
12533 "|hello\nworld\n"
12534 );
12535 assert_eq!(
12536 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
12537 "hello\nworld\n|"
12538 );
12539 // Already at the edge: a no-op.
12540 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
12541 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
12542 }
12543
12544 #[test]
12545 fn move_doc_start_and_end_extend_the_selection() {
12546 assert_eq!(
12547 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
12548 .move_doc_end(true)),
12549 "hello wor[ld\n|]"
12550 );
12551 assert_eq!(
12552 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
12553 .move_doc_start(true)),
12554 "[|hello wor]ld\n"
12555 );
12556 }
12557
12558 #[test]
12559 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
12560 let mut d = doc_with("empty_edges", "");
12561 d.move_doc_end(false);
12562 assert_eq!(d.caret, 0);
12563 d.move_doc_start(false);
12564 assert_eq!(d.caret, 0);
12565 }
12566
12567 // ── arrow collapses an active selection ─────────────────────────────────
12568
12569 #[test]
12570 fn arrow_collapses_selection_to_its_near_edge() {
12571 let mut d = doc_with("collapse", "hello world\n");
12572
12573 // Forward selection (anchor before caret): Right -> end, Left -> start.
12574 d.anchor = Some(2);
12575 d.caret = 7;
12576 d.move_right(false);
12577 assert_eq!((d.caret, d.anchor), (7, None));
12578
12579 d.anchor = Some(2);
12580 d.caret = 7;
12581 d.move_left(false);
12582 assert_eq!((d.caret, d.anchor), (2, None));
12583
12584 // Backward selection (anchor after caret): edges are the same
12585 // regardless of which end the caret started on.
12586 d.anchor = Some(7);
12587 d.caret = 2;
12588 d.move_right(false);
12589 assert_eq!((d.caret, d.anchor), (7, None));
12590
12591 d.anchor = Some(7);
12592 d.caret = 2;
12593 d.move_left(false);
12594 assert_eq!((d.caret, d.anchor), (2, None));
12595 }
12596
12597 #[test]
12598 fn arrow_with_extend_keeps_growing_the_selection() {
12599 let mut d = doc_with("collapse_extend", "hello world\n");
12600 d.anchor = Some(2);
12601 d.caret = 7;
12602 d.move_right(true); // extend: no collapse, caret steps one further
12603 assert_eq!((d.caret, d.anchor), (8, Some(2)));
12604 }
12605
12606 #[test]
12607 fn arrow_without_a_selection_moves_one_character_as_before() {
12608 let mut d = doc_with("no_collapse", "hello\n");
12609 d.caret = 2;
12610 d.move_right(false);
12611 assert_eq!(d.caret, 3);
12612 d.move_left(false);
12613 assert_eq!(d.caret, 2);
12614 }
12615
12616 /// Press Right until it stops, collecting the offsets walked through. Every
12617 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
12618 /// two stops sharing one source offset can't be moved between, so the caret
12619 /// stalls on the first of them and the walk never reaches the rest.
12620 fn walk_right(d: &mut Doc) -> Vec<usize> {
12621 let mut seen = vec![d.caret];
12622 for _ in 0..2000 {
12623 let before = d.caret;
12624 d.move_right(false);
12625 if d.caret == before {
12626 break;
12627 }
12628 seen.push(d.caret);
12629 }
12630 seen
12631 }
12632
12633 #[test]
12634 fn the_caret_crosses_a_soft_break() {
12635 // A newline inside a paragraph is a `soft_break`, which twig gives no
12636 // span of its own — the space it renders as used to borrow the offset of
12637 // the character before it, and a caret can't move without changing
12638 // offset. Right must walk clean off the end of the first line.
12639 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
12640 d.caret = 0;
12641 let seen = walk_right(&mut d);
12642 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
12643 }
12644
12645 #[test]
12646 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
12647 // The paragraph holds one soft break. Folded (the default) it lays out as
12648 // a single reflowed row; Preserve re-lays it as a row per source line.
12649 // The setter must invalidate the cached map for the change to show, and
12650 // again on the way back — so a round trip returns to the folded layout.
12651 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
12652 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
12653 d.build_visual(80);
12654 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
12655
12656 d.set_line_flow(LineFlow::Preserve);
12657 d.build_visual(80);
12658 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
12659
12660 d.set_line_flow(LineFlow::Fold);
12661 d.build_visual(80);
12662 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
12663 }
12664
12665 #[test]
12666 fn the_caret_still_crosses_a_preserved_soft_break() {
12667 // Preserve renders the soft break as a row boundary rather than a space,
12668 // but the caret must still reach every offset — the break's own offset is
12669 // the first row's end stop, so Right walks clean off the end of line one
12670 // onto line two, exactly as it does when the break is folded.
12671 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
12672 d.set_line_flow(LineFlow::Preserve);
12673 d.build_visual(80);
12674 d.caret = 0;
12675 let seen = walk_right(&mut d);
12676 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
12677 }
12678
12679 #[test]
12680 fn the_caret_walks_a_code_block() {
12681 // Every glyph of a code block used to map to the block's start, so the
12682 // whole block was a single offset and the caret couldn't move inside it.
12683 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
12684 let mut d = wysiwyg_doc("code_walk", src);
12685 d.caret = 0;
12686 let seen = walk_right(&mut d);
12687 // The fences are markup: hidden, and no caret stop. The code between
12688 // them is reached a character at a time.
12689 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
12690 for off in code.clone() {
12691 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
12692 }
12693 assert!(seen.contains(&code.end), "no stop after the last line");
12694 }
12695
12696 #[test]
12697 fn the_caret_walks_an_indented_code_block() {
12698 // An indented block's text has the four-space indent stripped, so it
12699 // isn't a verbatim slice and its lines have to be re-found. The caret
12700 // lands on the code, never in the indent.
12701 let src = " indented\n code\n";
12702 let mut d = wysiwyg_doc("indent_code_walk", src);
12703 d.caret = 0;
12704 let seen = walk_right(&mut d);
12705 assert!(seen.contains(&src.find("indented").unwrap()));
12706 assert!(seen.contains(&src.find("code").unwrap()));
12707 assert!(
12708 !seen.contains(&0) || seen[0] == 0,
12709 "the caret starts where it was put"
12710 );
12711 // Nothing in the stripped indent is a stop.
12712 for off in [1, 2, 3] {
12713 assert!(!seen.contains(&off), "landed in the indent at {off}");
12714 }
12715 }
12716
12717 #[test]
12718 fn the_caret_leaves_a_tight_heading() {
12719 // "# H" with text directly under it: the heading row's end and the
12720 // separator row's end are the same offset. Right used to find the
12721 // separator's copy, set the caret to where it already was, and stop.
12722 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
12723 d.caret = 2; // the "H"
12724 let seen = walk_right(&mut d);
12725 assert!(
12726 seen.len() > 2,
12727 "Right stalled at the heading's end: {seen:?}"
12728 );
12729 assert!(
12730 seen.contains(&8),
12731 "never reached the end of \"text\": {seen:?}"
12732 );
12733 }
12734
12735 #[test]
12736 fn the_caret_skips_the_gap_between_two_paragraphs() {
12737 // The blank line between two paragraphs is the boundary itself. The
12738 // caret used to be able to sit on it, and typing there landed in the
12739 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
12740 // soft break, so the text visibly snapped back up.
12741 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
12742 d.caret = 1; // the end of "A"
12743 d.move_right(false);
12744 assert_eq!(d.caret, 3, "Right stopped in the gap");
12745 d.insert("x");
12746 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
12747 }
12748
12749 #[test]
12750 fn down_from_a_paragraph_lands_on_the_next_one() {
12751 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
12752 d.caret = 0;
12753 d.move_down(false);
12754 assert_eq!(d.caret, 3, "Down stopped in the gap");
12755 }
12756
12757 #[test]
12758 fn clicking_the_gap_lands_on_real_text() {
12759 // A click can still *reach* the gap — it's drawn, so it's clickable.
12760 // It has to resolve to somewhere the caret can be.
12761 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
12762 d.click(1, 0, false); // the gap row
12763 assert!(
12764 d.caret == 1 || d.caret == 3,
12765 "click left the caret in the gap at {}",
12766 d.caret
12767 );
12768 d.insert("x");
12769 // Either edge of the boundary is a fair place to land; inside it isn't.
12770 assert!(
12771 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
12772 "click in the gap typed into the boundary: {:?}",
12773 d.source
12774 );
12775 }
12776
12777 #[test]
12778 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
12779 // Enter inserts a paragraph break, which leaves a blank line spare on
12780 // either side of a new one. That middle line is a real empty paragraph:
12781 // the caret lands there, and typing makes a paragraph rather than
12782 // extending a neighbour.
12783 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
12784 d.caret = 1;
12785 d.newline();
12786 assert_eq!(d.source, "A\n\n\n\nB\n");
12787 d.build_visual(80);
12788 let (row, _) = d.caret_pos();
12789 assert!(
12790 d.vmap.row_is_navigable(row),
12791 "the caret landed on a gap row"
12792 );
12793 d.insert("x");
12794 assert_eq!(
12795 d.source, "A\n\nx\n\nB\n",
12796 "the new paragraph merged into a neighbour"
12797 );
12798 }
12799
12800 #[test]
12801 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
12802 let mut d = wysiwyg_doc("gap_eof", "A\n");
12803 d.caret = 1;
12804 d.newline();
12805 d.build_visual(80);
12806 let (row, _) = d.caret_pos();
12807 assert!(
12808 d.vmap.row_is_navigable(row),
12809 "the caret landed on a gap row"
12810 );
12811 d.insert("x");
12812 assert!(
12813 d.source.starts_with("A\n\n") && d.source.contains('x'),
12814 "typing at the end merged into A: {:?}",
12815 d.source
12816 );
12817 }
12818
12819 #[test]
12820 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
12821 // A paragraph broken over two source lines is one paragraph. Selecting
12822 // it must not stop at the newline inside it — that newline is markup the
12823 // rich-text view exists to hide.
12824 let src = "one two\nthree four\n\nnext\n";
12825 let mut d = wysiwyg_doc("triple_para", src);
12826 d.select_block_at(2);
12827 assert_eq!(
12828 d.selected_text(),
12829 Some("one two\nthree four"),
12830 "stopped at the soft break"
12831 );
12832 }
12833
12834 #[test]
12835 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
12836 // The reader scrolls down past the caret's row. Nothing moved the
12837 // caret, so the view must stay where it was put — the old code revealed
12838 // the caret every frame, which dragged the view straight back and made
12839 // the document unscrollable past the caret.
12840 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
12841 d.caret = 0;
12842 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
12843 d.scroll = 4; // the wheel
12844 d.follow_caret(0, 3, 9);
12845 assert_eq!(
12846 d.scroll, 4,
12847 "the wheel was overruled by a caret that never moved"
12848 );
12849 }
12850
12851 #[test]
12852 fn moving_the_caret_brings_the_view_back_to_it() {
12853 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
12854 d.caret = 0;
12855 d.follow_caret(0, 3, 9);
12856 d.scroll = 6; // scrolled away
12857 d.move_right(false); // ...and now the caret moves
12858 let (row, _) = d.caret_pos();
12859 d.follow_caret(row, 3, 9);
12860 assert!(
12861 d.scroll <= row && row < d.scroll + 3,
12862 "caret row {row} off screen at scroll {}",
12863 d.scroll
12864 );
12865 }
12866
12867 #[test]
12868 fn scrolling_stops_at_the_last_row() {
12869 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
12870 d.caret = 0;
12871 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
12872 d.scroll = 999; // the wheel, spun hard
12873 d.follow_caret(0, 3, 3);
12874 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
12875 }
12876
12877 #[test]
12878 fn every_cell_of_a_wide_table_is_reachable() {
12879 // A table whose cells are far wider than the surface: the columns are
12880 // cut to fit and the text wraps inside them, so no cell hangs off the
12881 // right edge where the caret can never go.
12882 let src = "| Ingredient | Notes |\n|---|---|\n\
12883 | flour milled coarse | sift it twice before folding it in |\n";
12884 let mut d = wysiwyg_doc("wide_table_walk", src);
12885 d.build_visual(30);
12886 d.caret = 0;
12887 let seen = walk_right(&mut d);
12888 for word in ["Ingredient", "Notes", "coarse", "folding"] {
12889 let at = src.find(word).unwrap();
12890 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
12891 }
12892 }
12893
12894 // ── view parity ──────────────────────────────────────────────────────────
12895 // `doc_with` pins the source view, so everything above tests a view users
12896 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
12897 // deletion golden cases through *both*, plus the WYSIWYG cases the two
12898 // can't share: where the source carries markup the rendered text is a
12899 // different string, and the views agreeing would itself be the bug.
12900
12901 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
12902
12903 /// Run `action` in both views on one `|`-marked fixture and assert they
12904 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
12905 /// source verbatim, so the two views are looking at the same text and any
12906 /// disagreement is one of them having lost the plot.
12907 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
12908 let (src, caret) = parse_caret(marked);
12909 let run = |view: View, tag: &str| {
12910 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
12911 d.caret = caret;
12912 action(&mut d);
12913 render_caret(&d)
12914 };
12915 let source = run(VIEWS[0].0, VIEWS[0].1);
12916 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
12917 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
12918 source
12919 }
12920
12921 #[test]
12922 fn word_motion_agrees_across_the_views_on_plain_prose() {
12923 let g = both_views;
12924 assert_eq!(
12925 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
12926 "hello |world"
12927 );
12928 assert_eq!(
12929 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
12930 "|hello world"
12931 );
12932 assert_eq!(
12933 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
12934 "hello| world"
12935 );
12936 assert_eq!(
12937 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
12938 "hello world|"
12939 );
12940 assert_eq!(
12941 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
12942 "foo|.bar"
12943 );
12944 assert_eq!(
12945 g("par_ext", "hello |world", |d| d.move_word_right(true)),
12946 "hello [world|]"
12947 );
12948 }
12949
12950 #[test]
12951 fn word_deletion_agrees_across_the_views_on_plain_prose() {
12952 let g = both_views;
12953 assert_eq!(
12954 g("par_db", "hello world|", |d| d.delete_word_back()),
12955 "hello |"
12956 );
12957 assert_eq!(
12958 g("par_df", "hello |world", |d| d.delete_word_forward()),
12959 "hello |"
12960 );
12961 assert_eq!(
12962 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
12963 "|bar baz"
12964 );
12965 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
12966 }
12967
12968 #[test]
12969 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
12970 let g = both_views;
12971 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
12972 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
12973 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
12974 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
12975 }
12976
12977 #[test]
12978 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
12979 // The reproduction: the stop table was built one stop per `char`, so
12980 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
12981 // source view, which steps by grapheme, can't reach and backspace can't
12982 // survive. The two views must land on the same offset.
12983 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
12984 for (view, tag) in VIEWS {
12985 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
12986 d.caret = 1;
12987 d.move_right(false);
12988 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
12989
12990 // ...and the edit that used to sever a joiner off the front of it.
12991 d.backspace();
12992 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
12993 assert_eq!(d.caret, 1);
12994 }
12995 }
12996
12997 #[test]
12998 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
12999 for (view, tag) in VIEWS {
13000 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
13001 d.caret = 0;
13002 d.move_right(false);
13003 assert_eq!(
13004 d.caret,
13005 "e\u{0301}".len(),
13006 "{tag} stopped on the combining mark"
13007 );
13008 }
13009 }
13010
13011 #[test]
13012 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
13013 // The general form: whatever route the caret takes through a document
13014 // full of clusters, it never lands between the codepoints of one — so no
13015 // motion-then-backspace sequence can leave a dangling joiner behind.
13016 use unicode_segmentation::UnicodeSegmentation;
13017
13018 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
13019 let mut d = wysiwyg_doc("cluster_walk", src);
13020 d.caret = 0;
13021 let boundaries: Vec<usize> = src
13022 .grapheme_indices(true)
13023 .map(|(i, _)| i)
13024 .chain(std::iter::once(src.len()))
13025 .collect();
13026 for off in walk_right(&mut d) {
13027 assert!(
13028 boundaries.contains(&off),
13029 "Right stopped at {off}, inside a grapheme cluster"
13030 );
13031 }
13032 }
13033
13034 #[test]
13035 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
13036 // The reproduction: ⌥→ from inside the opening `**` computed its
13037 // boundary over the raw source and landed on byte 8 — inside the
13038 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
13039 // "bold". The caret drew past the bold word and sat inside it.
13040 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
13041 d.caret = 2;
13042 d.move_word_right(false);
13043 assert!(
13044 d.vmap.is_stop(d.caret),
13045 "landed at {}, not a caret stop",
13046 d.caret
13047 );
13048 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
13049 // The rendered row is "a bold c": column 6 is the space just past "bold",
13050 // and now the caret is really there rather than only drawn there.
13051 assert_eq!(d.caret_pos(), (0, 6));
13052
13053 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
13054 d.move_word_left(false);
13055 assert_eq!(d.caret, 4);
13056 assert_eq!(d.caret_pos(), (0, 2));
13057 }
13058
13059 #[test]
13060 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
13061 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
13062 // inside the closing `**`, and left "a ** c\n" — delimiters with no
13063 // opener. Glyph space covers the word alone, which would leave
13064 // "a **** c": markup wrapped around nothing. The word and the styling
13065 // that was only ever the word's go together.
13066 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
13067 d.caret = 10;
13068 d.delete_word_back();
13069 assert_eq!(d.source, "a c\n");
13070 assert_eq!(d.caret, 2);
13071
13072 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
13073 d.caret = 4; // the "b"
13074 d.delete_word_forward();
13075 assert_eq!(d.source, "a c\n");
13076 }
13077
13078 #[test]
13079 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
13080 let src = "a ***bold*** c\n";
13081 let mut d = wysiwyg_doc("wys_word_del_nest", src);
13082 d.caret = src.find(" c").unwrap();
13083 d.delete_word_back();
13084 assert_eq!(
13085 d.source, "a c\n",
13086 "the emph inside the strong empties it too"
13087 );
13088
13089 let src = "a `code` c\n";
13090 let mut d = wysiwyg_doc("wys_word_del_code", src);
13091 d.caret = src.find(" c").unwrap();
13092 d.delete_word_back();
13093 assert_eq!(d.source, "a c\n");
13094 }
13095
13096 #[test]
13097 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
13098 // Only an *emptied* node goes. Take one word of two and the `**` still
13099 // has a job to do — over the word that's left, with the space the delete
13100 // pushed against the opening delimiter moved out in front of it, or the
13101 // run would be no run at all (`** words**` is literal asterisks — see
13102 // the mark-edge rule on `splice`).
13103 let src = "a **two words** c\n";
13104 let mut d = wysiwyg_doc("wys_word_del_partial", src);
13105 d.caret = src.find(" words").unwrap();
13106 d.delete_word_back();
13107 assert_eq!(d.source, "a **words** c\n");
13108 }
13109
13110 #[test]
13111 fn source_view_word_motion_still_walks_the_markup() {
13112 // The other half of the decision: in the source view the `**` are
13113 // characters like any other — they're on the screen, so word motion has
13114 // to stop at them and a word-delete has to leave them behind. Only
13115 // WYSIWYG hides them, so only WYSIWYG steps over them.
13116 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
13117 assert_eq!(
13118 g("src_word_motion", "a |**bold** c\n", |d| d
13119 .move_word_right(false)),
13120 "a **bold|** c\n"
13121 );
13122 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
13123 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
13124 assert_eq!(
13125 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
13126 "a **| c\n"
13127 );
13128 }
13129
13130 #[test]
13131 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
13132 // The single invariant both bugs violated: the caret draws and edits at
13133 // the same place only when it's on a stop. `debug_assert_on_a_stop`
13134 // makes the same claim in-place; this pins it from the outside, over a
13135 // document with every kind of thing the map has to be careful about.
13136 // At two widths: the wide one every other test builds at, where no
13137 // fixture folds, and one narrow enough that they all do. A soft wrap is
13138 // where an offset stops being on exactly one row, and testing only the
13139 // width that never wraps is how the caret came to be pinned at the first
13140 // one Down reached.
13141 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
13142 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
13143 // A table of named operations, which is what it looks like.
13144 #[allow(clippy::type_complexity)]
13145 let motions: [(&str, fn(&mut Doc)); 8] = [
13146 ("right", |d| d.move_right(false)),
13147 ("left", |d| d.move_left(false)),
13148 ("word_right", |d| d.move_word_right(false)),
13149 ("word_left", |d| d.move_word_left(false)),
13150 ("down", |d| d.move_down(false)),
13151 ("up", |d| d.move_up(false)),
13152 ("home", |d| d.move_home(false)),
13153 ("end", |d| d.move_end(false)),
13154 ];
13155 for width in [80, 12] {
13156 let mut d = wysiwyg_doc("stop_invariant", src);
13157 d.build_visual(width);
13158 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13159 assert!(stops.len() > 20, "fixture should have plenty of stops");
13160 for start in stops {
13161 for (name, motion) in &motions {
13162 d.caret = start;
13163 d.anchor = None;
13164 motion(&mut d);
13165 assert!(
13166 d.vmap.is_stop(d.caret),
13167 "{name} from {start} at width {width} landed at {} — not a caret stop",
13168 d.caret
13169 );
13170 }
13171 }
13172 }
13173 }
13174
13175 #[test]
13176 fn no_wysiwyg_motion_is_a_dead_end() {
13177 // Down held to the bottom of a document reaches the bottom, and Up held
13178 // to the top reaches the top — from anywhere, at a width that wraps. The
13179 // invariant above says a motion lands somewhere legal; this one says it
13180 // gets somewhere at all, which is what a caret pinned at a wrap boundary
13181 // was quietly failing to do while every assertion around it held.
13182 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
13183 - item one two three four five\n\nlast\n";
13184 for width in [80, 12] {
13185 let mut d = wysiwyg_doc("no_dead_end", src);
13186 d.build_visual(width);
13187 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13188 let (first, last) = (stops[0], stops[stops.len() - 1]);
13189 for &start in &stops {
13190 for (name, motion, want) in [
13191 (
13192 "down",
13193 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
13194 last,
13195 ),
13196 ("up", |d: &mut Doc| d.move_up(false), first),
13197 ] {
13198 d.caret = start;
13199 d.anchor = None;
13200 d.goal_col = None;
13201 // Every row, plus the presses the edges take, plus slack.
13202 for _ in 0..d.vmap.num_rows() + 4 {
13203 motion(&mut d);
13204 }
13205 assert_eq!(
13206 d.caret, want,
13207 "{name} held from {start} at width {width} never arrived"
13208 );
13209 }
13210 }
13211 }
13212 }
13213 // ── display columns ──────────────────────────────────────────────────────
13214 // A `col` is a terminal cell, not a character. The two are the same number
13215 // for the ASCII the fixtures above are written in, which is how they came
13216 // apart in the first place: `你` is one character drawn in two cells, so a
13217 // column counted in characters names a cell the text isn't in — one earlier
13218 // for every wide character to its left.
13219
13220 #[test]
13221 fn a_wide_character_is_two_columns_wide() {
13222 // The reproduction: `你` is one char and two cells, so the caret just
13223 // past it drew at column 1 — inside the character it had already left.
13224 for (view, tag) in VIEWS {
13225 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
13226 d.caret = "你".len();
13227 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
13228 d.caret = "你好".len();
13229 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
13230 }
13231 }
13232
13233 #[test]
13234 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
13235 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
13236 // two-cell — measuring six cells one at a time, but the character they
13237 // spell is drawn in two. Width belongs to the cluster, not the glyph,
13238 // and the frontends measure it the same way.
13239 let family = "👨👩👧";
13240 for (view, tag) in VIEWS {
13241 let src = format!("a{family}b\n");
13242 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
13243 d.caret = 1 + family.len();
13244 assert_eq!(
13245 d.caret_pos(),
13246 (0, 3),
13247 "{tag}: 'a' is one cell, the family two"
13248 );
13249 }
13250 }
13251
13252 #[test]
13253 fn both_cells_of_a_wide_character_mean_the_character() {
13254 // Clicking the far half of `好` is still clicking `好`: half a character
13255 // is not a place the caret can be, so it comes to rest at the
13256 // character's start — the column it would have been drawn at anyway.
13257 for (view, tag) in VIEWS {
13258 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
13259 for col in [2, 3] {
13260 d.caret = 0;
13261 d.click(0, col, false);
13262 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
13263 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
13264 }
13265 // Past the last cell is the line's end, as it is for ASCII.
13266 d.click(0, 9, false);
13267 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
13268 }
13269 }
13270
13271 #[test]
13272 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
13273 // The mapping is only a mapping if it inverts: the cell the caret is
13274 // drawn in has to be the cell that brings it back to the same offset.
13275 // Over a fixture where a character may be one cell or two, and one
13276 // codepoint or five.
13277 use unicode_segmentation::UnicodeSegmentation;
13278
13279 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
13280
13281 let mut d = doc_in(View::Source, "roundtrip_source", src);
13282 // Every offset the source view's caret can occupy: it steps by grapheme
13283 // cluster, so those are its boundaries.
13284 for (off, _) in src
13285 .grapheme_indices(true)
13286 .chain(std::iter::once((src.len(), "")))
13287 {
13288 d.caret = off;
13289 let (row, col) = d.caret_pos();
13290 d.click(row, col, false);
13291 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
13292 }
13293
13294 // And in WYSIWYG, where the offsets the caret can occupy are the map's
13295 // stops rather than every boundary.
13296 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
13297 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13298 assert!(stops.len() > 20, "fixture should have plenty of stops");
13299 for off in stops {
13300 d.caret = off;
13301 let (row, col) = d.caret_pos();
13302 d.click(row, col, false);
13303 assert_eq!(
13304 d.caret, off,
13305 "wysiwyg: {off} → ({row}, {col}) → {}",
13306 d.caret
13307 );
13308 }
13309 }
13310
13311 #[test]
13312 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
13313 // Down from under `世` lands under the glyph in that cell, not two
13314 // characters further along the line. The goal is a column, so a line of
13315 // wide characters and a line of ASCII line up the way they're drawn.
13316 //
13317 // The gap differs by view: a bare newline inside a paragraph is a soft
13318 // break, which WYSIWYG draws as a space on a single row. The views share
13319 // a grid only where the source's lines are the renderer's rows too.
13320 for (view, tag) in VIEWS {
13321 let gap = if view == View::Source { "\n" } else { "\n\n" };
13322 let src = format!("你好世{gap}abcdef\n");
13323 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
13324 d.caret = "你好".len();
13325 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
13326 d.move_down(false);
13327 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
13328 assert!(
13329 d.source[d.caret..].starts_with('e'),
13330 "{tag}: landed on the wrong glyph"
13331 );
13332 }
13333 }
13334
13335 #[test]
13336 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
13337 // Down from column 3 onto `你好`, whose characters start at columns 0
13338 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
13339 // between the cells of one character, so the caret rests on it — and on
13340 // its start, which is the only offset there that is a caret stop.
13341 for (view, tag) in VIEWS {
13342 let gap = if view == View::Source { "\n" } else { "\n\n" };
13343 let src = format!("abcdef{gap}你好\n");
13344 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
13345 let line = src.find('你').unwrap();
13346 d.caret = 3;
13347 d.move_down(false);
13348 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
13349 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
13350 }
13351 }
13352
13353 #[test]
13354 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
13355 // The column the cell's text is laid out in is measured in cells, so the
13356 // caret walking that text has to be too — the two agreeing is the whole
13357 // point of the grid staying square.
13358 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
13359 let at = d.source.find("你").unwrap();
13360 d.caret = at;
13361 let (row, col) = d.caret_pos();
13362 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
13363 // cells further along.
13364 assert_eq!(col, 2, "the cell's first character");
13365 d.move_right(false);
13366 assert_eq!(
13367 d.caret_pos(),
13368 (row, 4),
13369 "`好` is drawn past `你`'s two cells"
13370 );
13371 assert_eq!(d.caret, at + "你".len());
13372 }
13373
13374 // ── active inline marks ───────────────────────────────────────────────────
13375
13376 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
13377 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
13378 let (src, caret) = parse_caret(marked);
13379 let mut d = doc_in(view, name, &src);
13380 d.caret = caret;
13381 d.active_inline_marks().iter().collect()
13382 }
13383
13384 /// The marks over the selection `[start, end)`.
13385 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
13386 let mut d = doc_in(view, name, src);
13387 d.anchor = Some(start);
13388 d.caret = end;
13389 d.active_inline_marks().iter().collect()
13390 }
13391
13392 #[test]
13393 fn a_caret_in_a_mark_reports_it() {
13394 for (view, tag) in VIEWS {
13395 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
13396 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
13397 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
13398 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
13399 // Plain text under no mark lights nothing — the toolbar's resting state.
13400 assert_eq!(m("a| **bold** b"), [], "{tag}");
13401 assert!(m("plain t|ext").is_empty(), "{tag}");
13402 }
13403 }
13404
13405 #[test]
13406 fn nested_marks_all_report() {
13407 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
13408 // the ancestor chain is a chain, and every mark on it is in force.
13409 for (view, tag) in VIEWS {
13410 assert_eq!(
13411 marks(
13412 view,
13413 &format!("marks_nested_{tag}"),
13414 "**bold and *bo|th*** end"
13415 ),
13416 [InlineKind::Strong, InlineKind::Emph],
13417 "{tag}"
13418 );
13419 }
13420 }
13421
13422 #[test]
13423 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
13424 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
13425 // the first byte of its text and the byte after its last — both inside
13426 // the mark's span, both places typing lands inside the bold. The offset
13427 // past the closing delimiter is the next text, and reports nothing.
13428 let src = "a **bold** b";
13429 let inner_start = src.find("bold").unwrap(); // 4
13430 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
13431 for (view, tag) in VIEWS {
13432 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
13433 for off in [2, 3, inner_start, inner_end, 9] {
13434 d.caret = off;
13435 assert!(
13436 d.active_inline_marks().contains(InlineKind::Strong),
13437 "{tag}: offset {off} is inside the strong span"
13438 );
13439 }
13440 for off in [0, 1, 10, 11, 12] {
13441 d.caret = off;
13442 assert!(
13443 !d.active_inline_marks().contains(InlineKind::Strong),
13444 "{tag}: offset {off} is outside the strong run"
13445 );
13446 }
13447 }
13448 }
13449
13450 #[test]
13451 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
13452 // Regression: twig resolves an offset that is one node's end and the
13453 // next one's start to the node that *starts* there, so `**bold**|\n`
13454 // isn't bold. With nothing following there's no tie to break and the
13455 // chain still ended at the mark, which made a trailing `\n` — not the
13456 // text — decide whether the caret after a bold word reported bold. It's
13457 // the offset past the mark either way, and typing there is plain either
13458 // way. A blank document typed into is exactly this shape.
13459 for (view, tag) in VIEWS {
13460 let m = |name: String, marked| marks(view, &name, marked);
13461 assert_eq!(
13462 m(format!("marks_eob_{tag}"), "**bold**|"),
13463 [],
13464 "{tag}: no trailing newline"
13465 );
13466 assert_eq!(
13467 m(format!("marks_eol_{tag}"), "**bold**|\n"),
13468 [],
13469 "{tag}: with one"
13470 );
13471 // And the last offset that *is* in the mark still is.
13472 assert_eq!(
13473 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
13474 [InlineKind::Strong],
13475 "{tag}"
13476 );
13477 }
13478 }
13479
13480 #[test]
13481 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
13482 let src = "a **bold** b";
13483 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
13484 for (view, tag) in VIEWS {
13485 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
13486 // The whole bold word, and a slice of it.
13487 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
13488 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
13489 // Ending exactly at the closing delimiter's start is still all-bold:
13490 // an exclusive end sits *past* the last selected character, so the
13491 // question is asked of the character, not the boundary.
13492 assert_eq!(
13493 m(b, d_ + 2),
13494 [InlineKind::Strong],
13495 "{tag}: through the close"
13496 );
13497 // Half in, half out: Bold lit here would claim a press turns it off.
13498 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
13499 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
13500 }
13501 }
13502
13503 #[test]
13504 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
13505 // Both ends are bold, but the space between them isn't — two runs are two
13506 // nodes, which is exactly what the node id catches and a kind-only
13507 // comparison would not.
13508 let src = "**one** **two**";
13509 for (view, tag) in VIEWS {
13510 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
13511 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
13512 }
13513 }
13514
13515 #[test]
13516 fn marks_read_the_document_as_it_is_edited() {
13517 // The point of asking twig every frame instead of caching: the answer has
13518 // to follow the toggle that changed it.
13519 let mut d = wysiwyg_doc("marks_live", "one two\n");
13520 d.anchor = Some(0);
13521 d.caret = 3;
13522 assert!(d.active_inline_marks().is_empty(), "plain to start");
13523 d.toggle(InlineKind::Strong);
13524 assert_eq!(d.source, "**one** two\n");
13525 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
13526 assert!(d.active_inline_marks().contains(InlineKind::Strong));
13527 d.toggle(InlineKind::Strong);
13528 assert!(d.active_inline_marks().is_empty(), "and off again");
13529 }
13530
13531 #[test]
13532 fn a_link_is_not_an_inline_mark() {
13533 // `link`/`str` are inline nodes, but nothing on the inline toolbar
13534 // toggles them — a set with a "link mark" in it would have no button.
13535 for (view, tag) in VIEWS {
13536 assert_eq!(
13537 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
13538 [],
13539 "{tag}"
13540 );
13541 }
13542 }
13543
13544 // ── blank documents ───────────────────────────────────────────────────────
13545
13546 #[test]
13547 fn a_blank_document_is_untitled_empty_and_markdown() {
13548 let mut d = Doc::blank().unwrap();
13549 assert!(d.is_untitled());
13550 assert_eq!(d.path, PathBuf::new());
13551 assert_eq!(
13552 d.file_name(),
13553 "untitled",
13554 "the header has to show something"
13555 );
13556 assert_eq!(d.format_name(), "markdown");
13557 assert_eq!(d.source, "");
13558 assert!(!d.dirty, "nothing typed yet is nothing to lose");
13559 assert_eq!(d.disk_state(), DiskState::Untitled);
13560 // And it's a document you can be in: the default view renders it.
13561 d.build_visual(80);
13562 assert_eq!(d.caret, 0);
13563 }
13564
13565 #[test]
13566 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
13567 let mut d = Doc::blank().unwrap();
13568 d.insert("hello");
13569 assert!(d.dirty);
13570 d.save();
13571 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
13572 assert!(d.dirty, "it must not come away believing it saved");
13573 assert!(d.is_untitled(), "and it still has no file");
13574 }
13575
13576 #[test]
13577 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
13578 let p = temp_path("blank_save_as");
13579 let mut d = Doc::blank().unwrap();
13580 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
13581 // be kept literal (`\#`); this test is about save-as, not escaping (which
13582 // has its own test), so it types nothing that escaping would touch.
13583 d.insert("hi");
13584 d.save_as(p.clone());
13585 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
13586 assert!(!d.is_untitled());
13587 assert!(!d.dirty);
13588 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
13589 assert_eq!(
13590 d.disk_state(),
13591 DiskState::Unchanged,
13592 "the watermark is stamped"
13593 );
13594 // And ⌘S is a plain save from here on.
13595 d.insert("!");
13596 d.save();
13597 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
13598 let _ = std::fs::remove_file(&p);
13599 }
13600
13601 // ── a file that isn't there yet ───────────────────────────────────────────
13602
13603 /// A unique path in the temp dir with the given extension, guaranteed not to
13604 /// exist — what `leaf notes.md` is handed when the file has never been made.
13605 fn missing_path(name: &str, ext: &str) -> PathBuf {
13606 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13607 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13608 let mut p = std::env::temp_dir();
13609 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
13610 let _ = std::fs::remove_file(&p);
13611 p
13612 }
13613
13614 #[test]
13615 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
13616 let p = missing_path("named", "md");
13617 let mut d = Doc::open_or_create(p.clone()).unwrap();
13618
13619 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
13620 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
13621 assert!(
13622 !d.is_untitled(),
13623 "it has the name the user asked for — ^S must not detour to Save As"
13624 );
13625 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
13626 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
13627 assert!(!p.exists(), "and opening it wrote nothing");
13628 // And it's a document you can be in.
13629 d.build_visual(80);
13630 assert_eq!(d.caret, 0);
13631 }
13632
13633 #[test]
13634 fn a_new_file_is_created_by_its_first_save() {
13635 let p = missing_path("first_save", "md");
13636 let mut d = Doc::open_or_create(p.clone()).unwrap();
13637 d.insert("hello\n");
13638 assert!(d.dirty);
13639 d.save();
13640
13641 assert_eq!(
13642 std::fs::read_to_string(&p).unwrap(),
13643 "hello\n",
13644 "a plain ^S wrote it — no Save As, no name to invent"
13645 );
13646 assert!(!d.dirty);
13647 assert_eq!(d.disk_state(), DiskState::Unchanged);
13648 let _ = std::fs::remove_file(&p);
13649 }
13650
13651 #[test]
13652 fn a_new_file_takes_its_format_from_the_extension() {
13653 // The one thing `blank` can't do: with no name it has to assume Markdown,
13654 // and typing djot into a Markdown parse is the wrong buffer.
13655 let dj = missing_path("format", "dj");
13656 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
13657 let md = missing_path("format", "md");
13658 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
13659 }
13660
13661 #[test]
13662 fn a_new_file_reports_itself_missing_until_it_is_saved() {
13663 // Not `Untitled` — that's the answer for a document with no path, and it
13664 // would tell a frontend there is nothing a save could collide with. Here
13665 // there is a path, and the file simply isn't at it yet.
13666 let p = missing_path("disk_state", "md");
13667 let mut d = Doc::open_or_create(p.clone()).unwrap();
13668 assert_eq!(d.disk_state(), DiskState::Missing);
13669
13670 // Somebody else creates it while the buffer is open: that's an overwrite
13671 // the frontend has to be able to prompt about, exactly as for an opened
13672 // file. Their bytes, not ours, so `Changed`.
13673 std::fs::write(&p, "theirs\n").unwrap();
13674 assert_eq!(d.disk_state(), DiskState::Changed);
13675
13676 // Saving makes the file ours and re-stamps the watermark.
13677 d.insert("ours\n");
13678 d.save();
13679 assert_eq!(d.disk_state(), DiskState::Unchanged);
13680 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
13681 let _ = std::fs::remove_file(&p);
13682 }
13683
13684 #[test]
13685 fn open_or_create_still_opens_a_file_that_is_there() {
13686 let d = doc_with("open_or_create_existing", "body\n");
13687 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
13688 assert_eq!(reopened.source, "body\n");
13689 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
13690 }
13691
13692 #[test]
13693 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
13694 // A mistyped flag or a stray argument must not become a buffer promising
13695 // to save somewhere — the same refusal `open` gives a real file.
13696 let mut p = std::env::temp_dir();
13697 p.push("leaf_test_new_bad_ext.wat");
13698 assert!(Doc::open_or_create(p).is_err());
13699 let mut none = std::env::temp_dir();
13700 none.push("leaf_test_new_no_ext");
13701 assert!(Doc::open_or_create(none).is_err());
13702 }
13703
13704 #[test]
13705 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
13706 // Opening reads nothing, so there is nothing to fail on yet; the write is
13707 // where it fails, and it says so rather than claiming a save.
13708 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
13709 let mut d = Doc::open_or_create(p).unwrap();
13710 d.insert("x");
13711 d.save();
13712 assert!(
13713 d.status.as_deref().unwrap().starts_with("save failed:"),
13714 "got {:?}",
13715 d.status
13716 );
13717 assert!(d.dirty, "it must not come away believing it saved");
13718 }
13719
13720 // ── save as ───────────────────────────────────────────────────────────────
13721
13722 /// A unique path in the temp dir that no fixture wrote — a Save As target.
13723 fn temp_path(name: &str) -> PathBuf {
13724 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13725 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13726 let mut p = std::env::temp_dir();
13727 p.push(format!("leaf_test_target_{name}_{seq}.md"));
13728 let _ = std::fs::remove_file(&p);
13729 p
13730 }
13731
13732 #[test]
13733 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
13734 let mut d = doc_with("save_as_move", "original\n");
13735 let old = d.path.clone();
13736 let new = temp_path("save_as_move");
13737 d.insert("edited: ");
13738 d.save_as(new.clone());
13739
13740 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
13741 assert_eq!(
13742 std::fs::read_to_string(&old).unwrap(),
13743 "original\n",
13744 "Save As doesn't touch the file it came from"
13745 );
13746 assert_eq!(d.path, new, "the document moved");
13747 assert!(!d.dirty);
13748 assert_eq!(
13749 d.status.as_deref(),
13750 Some(&*format!("saved {}", d.file_name()))
13751 );
13752
13753 // Every later save follows it, which is the whole difference from a copy.
13754 d.caret = 0;
13755 d.insert("re-");
13756 d.save();
13757 assert_eq!(
13758 std::fs::read_to_string(&new).unwrap(),
13759 "re-edited: original\n"
13760 );
13761 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
13762 let _ = std::fs::remove_file(&new);
13763 }
13764
13765 #[test]
13766 fn save_as_overwrites_an_existing_target() {
13767 // The picker already asked; asking again down here is the same question
13768 // twice, and the second one has no way to be answered.
13769 let new = temp_path("save_as_over");
13770 std::fs::write(&new, "theirs\n").unwrap();
13771 let mut d = doc_with("save_as_over", "ours\n");
13772 d.save_as(new.clone());
13773 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
13774 let _ = std::fs::remove_file(&new);
13775 }
13776
13777 #[test]
13778 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
13779 let mut d = doc_with("save_as_fail", "body\n");
13780 let old = d.path.clone();
13781 d.insert("x");
13782 // A directory that doesn't exist: the write can't land.
13783 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
13784 d.save_as(bad);
13785
13786 assert_eq!(
13787 d.path, old,
13788 "the document must not move to a file that isn't there"
13789 );
13790 assert!(d.dirty, "and must not believe it saved");
13791 assert!(
13792 d.status.as_deref().unwrap().starts_with("save failed:"),
13793 "the same failure a plain save reports, got {:?}",
13794 d.status
13795 );
13796 // The original is still the document's file, and still saveable.
13797 d.save();
13798 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
13799 assert!(!d.dirty);
13800 }
13801
13802 #[test]
13803 fn save_as_renames_without_reparsing_the_format() {
13804 // `.dj` on the name doesn't make the buffer djot: it was parsed as
13805 // Markdown and still is, and saying otherwise would be a conversion the
13806 // user never asked for (and an undo history thrown away to do it).
13807 let mut d = doc_with("save_as_format", "**b**\n");
13808 let mut new = temp_path("save_as_format");
13809 new.set_extension("dj");
13810 d.save_as(new.clone());
13811 assert_eq!(d.format_name(), "markdown");
13812 let _ = std::fs::remove_file(&new);
13813 }
13814
13815 // ── external change / reload ──────────────────────────────────────────────
13816
13817 #[test]
13818 fn an_untouched_file_reports_unchanged() {
13819 let mut d = doc_with("disk_clean", "body\n");
13820 assert_eq!(d.disk_state(), DiskState::Unchanged);
13821 // Editing the buffer is not editing the file.
13822 d.insert("x");
13823 assert_eq!(d.disk_state(), DiskState::Unchanged);
13824 assert!(d.dirty);
13825 // Saving re-stamps the watermark rather than reporting our own bytes back.
13826 d.save();
13827 assert_eq!(d.disk_state(), DiskState::Unchanged);
13828 }
13829
13830 #[test]
13831 fn a_file_written_underneath_reports_changed() {
13832 let mut d = doc_with("disk_changed", "body\n");
13833 std::fs::write(&d.path, "someone else\n").unwrap();
13834 assert_eq!(d.disk_state(), DiskState::Changed);
13835 // Dirty *and* changed is the clobber: both halves are readable, and
13836 // leaf-core takes neither side.
13837 d.insert("x");
13838 assert!(d.dirty && d.disk_state() == DiskState::Changed);
13839 // Saving anyway is allowed — the frontend asked, or chose not to.
13840 d.save();
13841 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
13842 assert_eq!(d.disk_state(), DiskState::Unchanged);
13843 }
13844
13845 #[test]
13846 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
13847 // The hash is what makes this honest: the file was written (a fresh
13848 // mtime), and nothing about the document is stale.
13849 let d = doc_with("disk_same_bytes", "body\n");
13850 std::fs::write(&d.path, "body\n").unwrap();
13851 assert_eq!(d.disk_state(), DiskState::Unchanged);
13852 }
13853
13854 #[test]
13855 fn a_deleted_file_reports_missing() {
13856 let mut d = doc_with("disk_missing", "body\n");
13857 std::fs::remove_file(&d.path).unwrap();
13858 assert_eq!(d.disk_state(), DiskState::Missing);
13859 // A save recreates it, and the document is whole again.
13860 d.save();
13861 assert_eq!(d.disk_state(), DiskState::Unchanged);
13862 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
13863 }
13864
13865 #[test]
13866 fn reload_replaces_the_document_with_the_file() {
13867 for (view, tag) in VIEWS {
13868 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
13869 d.insert("edited ");
13870 assert!(d.dirty);
13871 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
13872 d.reload();
13873
13874 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
13875 assert!(!d.dirty, "{tag}: the file is what we have");
13876 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
13877 assert_eq!(
13878 d.status.as_deref(),
13879 Some(&*format!("reloaded {}", d.file_name()))
13880 );
13881 // The reloaded tree is live, not the old parse.
13882 d.caret = d.source.find("three").unwrap();
13883 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
13884 }
13885 }
13886
13887 #[test]
13888 fn reload_clamps_the_caret_and_drops_the_selection() {
13889 let mut d = doc_with("reload_caret", "a long first line\n");
13890 d.caret = 12;
13891 d.anchor = Some(4);
13892 std::fs::write(&d.path, "short\n").unwrap();
13893 d.reload();
13894 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
13895 assert_eq!(
13896 d.anchor, None,
13897 "a selection over bytes that changed is a lie"
13898 );
13899 assert!(d.selection().is_none());
13900
13901 // A caret the file still has room for stays put.
13902 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
13903 d.caret = 2;
13904 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
13905 d.reload();
13906 assert_eq!(d.caret, 2);
13907 }
13908
13909 /// A silent reload is something that happened *to* a reader — a formatter,
13910 /// a `git checkout` — so it has to be undoable like anything else that
13911 /// changes the document, and undoable as one step rather than as however
13912 /// many the file happens to differ by.
13913 #[test]
13914 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
13915 let mut d = doc_with("reload_undo", "body\n");
13916 d.insert("x");
13917 assert_eq!(d.source, "xbody\n");
13918 std::fs::write(&d.path, "replaced\n").unwrap();
13919 d.reload();
13920 assert_eq!(d.source, "replaced\n");
13921 assert!(!d.dirty, "a reload lands clean");
13922
13923 // One ^Z takes the whole swap off, and hands back the unsaved work it
13924 // replaced — which is unsaved again, because the file no longer says it.
13925 d.undo();
13926 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
13927 assert!(d.dirty, "and what it comes back to is unsaved");
13928 // …and the history under it is still there.
13929 d.undo();
13930 assert_eq!(
13931 d.source, "body\n",
13932 "the typing before the reload undoes too"
13933 );
13934 // Redo walks back up through the reload.
13935 d.redo();
13936 d.redo();
13937 assert_eq!(d.source, "replaced\n");
13938 }
13939
13940 /// A file rewritten with the bytes it already had is not an edit, so it
13941 /// must not leave an undo step behind for something nobody did.
13942 #[test]
13943 fn reloading_identical_bytes_pushes_no_undo_step() {
13944 let mut d = doc_with("reload_same", "body\n");
13945 d.insert("x");
13946 std::fs::write(&d.path, "xbody\n").unwrap();
13947 d.reload();
13948 assert_eq!(d.source, "xbody\n");
13949 assert!(!d.dirty, "the file now says what the buffer does");
13950 d.undo();
13951 assert_eq!(
13952 d.source, "body\n",
13953 "one step back is the typing, not a no-op"
13954 );
13955 }
13956
13957 #[test]
13958 fn a_reload_that_cant_read_leaves_the_document_alone() {
13959 let mut d = doc_with("reload_gone", "body\n");
13960 d.insert("x");
13961 std::fs::remove_file(&d.path).unwrap();
13962 d.reload();
13963 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
13964 assert!(d.dirty);
13965 assert!(
13966 d.status.as_deref().unwrap().starts_with("reload failed:"),
13967 "{:?}",
13968 d.status
13969 );
13970
13971 // And an untitled document has nothing to reload from.
13972 let mut d = Doc::blank().unwrap();
13973 d.insert("typed");
13974 d.reload();
13975 assert_eq!(d.source, "typed");
13976 assert_eq!(d.status.as_deref(), Some("no file to reload"));
13977 }
13978
13979 #[test]
13980 fn a_read_only_document_refuses_every_door() {
13981 let mut d = doc_with("readonly", "one two three\n");
13982 d.insert("x");
13983 assert!(d.dirty, "writable first, so the undo step exists");
13984 d.set_read_only(true);
13985 let before = d.source.clone();
13986 d.insert("y");
13987 d.backspace();
13988 d.undo();
13989 d.redo();
13990 assert_eq!(d.source, before, "no door moved a byte");
13991 d.set_read_only(false);
13992 d.undo();
13993 assert_ne!(d.source, before, "off again, the same doors work");
13994 }
13995
13996 /// The doors that go to twig's own verbs rather than through the splice.
13997 /// Typed text in the rendered view under the default markup mode is the
13998 /// everyday one — it is what a keystroke in leaf-web or the Apple views
13999 /// becomes — and it walked straight past the gate.
14000 #[test]
14001 fn a_read_only_document_refuses_the_doors_around_the_splice() {
14002 let mut d = wysiwyg_doc(
14003 "readonly-doors",
14004 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
14005 );
14006 d.set_markup_mode(MarkupMode::None);
14007 d.set_read_only(true);
14008 let before = d.source.clone();
14009 d.place_caret(3, false);
14010 d.insert("y");
14011 d.insert_link("https://example.com");
14012 d.insert_image("a.png", "alt");
14013 d.insert_thematic_break();
14014 d.insert_footnote();
14015 d.place_caret(0, false);
14016 d.place_caret(3, true);
14017 d.toggle(InlineKind::Strong);
14018 d.toggle_heading(2);
14019 d.set_block(BlockKind::Paragraph);
14020 d.toggle_list(false);
14021 d.toggle_blockquote();
14022 d.toggle_task_item();
14023 d.newline();
14024 d.indent();
14025 d.set_code_language("rust");
14026 let in_cell = d.source.find("| c").unwrap() + 2;
14027 d.place_caret(in_cell, false);
14028 assert!(d.caret_in_table(), "the caret is in the grid");
14029 assert!(!d.cell_line_break(), "the cell break reports the refusal");
14030 assert_eq!(d.source, before, "no door moved a byte");
14031 assert!(!d.dirty, "nothing to save");
14032 d.set_read_only(false);
14033 d.place_caret(3, false);
14034 d.insert("y");
14035 assert_ne!(d.source, before, "off again, the same doors work");
14036 }
14037
14038 #[test]
14039 fn a_selection_quote_carries_its_context_on_char_boundaries() {
14040 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
14041 let start = d.source.find("exact").unwrap();
14042 d.place_caret(start, false);
14043 d.place_caret(start + "exact".len(), true);
14044 let q = d.selection_quote(3).unwrap();
14045 assert_eq!(q.exact, "exact");
14046 assert_eq!(
14047 q.prefix, "你好 ",
14048 "chars, not bytes — the multibyte pair counts as two"
14049 );
14050 assert_eq!(q.suffix, " 世界");
14051 assert_eq!(&d.source[q.start..q.end], "exact");
14052 // At the edges the context clips rather than erring.
14053 d.place_caret(0, false);
14054 d.place_caret(6, true);
14055 let q = d.selection_quote(40).unwrap();
14056 assert_eq!(q.prefix, "");
14057 assert_eq!(q.exact, "before");
14058 // No selection is no quote.
14059 d.place_caret(0, false);
14060 assert!(d.selection_quote(3).is_none());
14061 }
14062
14063 #[test]
14064 fn highlights_are_kept_sorted_and_answer_point_queries() {
14065 let mut d = doc_with("hl", "one two three\n");
14066 d.set_highlights(vec![
14067 Highlight {
14068 start: 8,
14069 end: 13,
14070 id: "b".into(),
14071 color: None,
14072 marker: None,
14073 },
14074 Highlight {
14075 start: 0,
14076 end: 3,
14077 id: "a".into(),
14078 color: Some("#ffe066".into()),
14079 marker: None,
14080 },
14081 Highlight {
14082 start: 5,
14083 end: 5,
14084 id: "empty".into(),
14085 color: None,
14086 marker: None,
14087 },
14088 ]);
14089 assert_eq!(
14090 d.highlights()
14091 .iter()
14092 .map(|h| h.id.as_str())
14093 .collect::<Vec<_>>(),
14094 ["a", "b"],
14095 "sorted by start, the empty range dropped"
14096 );
14097 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
14098 assert_eq!(d.highlight_at(3), None, "end is exclusive");
14099 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
14100 d.set_highlights(Vec::new());
14101 assert!(d.highlights().is_empty(), "a replace is a replace");
14102 }
14103
14104 /// `Highlight::covering` and the cursor over it are what both painters ask
14105 /// per glyph, so they have to answer the same as the scan they replaced —
14106 /// including in the gaps, which is where most glyphs are.
14107 #[test]
14108 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
14109 let hl = |start: usize, end: usize, id: &str| Highlight {
14110 start,
14111 end,
14112 id: id.into(),
14113 color: None,
14114 marker: None,
14115 };
14116 // Disjoint, as search hits are: in a range, in a gap, and past the end.
14117 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
14118 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
14119 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
14120 assert_eq!(
14121 Highlight::covering(&hits, 105),
14122 None,
14123 "a gap covers nothing"
14124 );
14125 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
14126 assert_eq!(Highlight::covering(&hits, 9_999), None);
14127 assert_eq!(Highlight::covering(&[], 0), None);
14128
14129 // Nested: first by start, so a hit inside an annotation still resolves
14130 // to the annotation — and the range that stops short doesn't mask it.
14131 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
14132 assert_eq!(
14133 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
14134 Some("outer")
14135 );
14136 assert_eq!(
14137 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
14138 Some("outer")
14139 );
14140 }
14141
14142 /// The cursor is an optimisation, so the only thing worth asserting is that
14143 /// it is not also a change of answer — at every offset, over a list with a
14144 /// nest in it, walked forwards and then backwards.
14145 #[test]
14146 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
14147 let hl = |start: usize, end: usize, id: &str| Highlight {
14148 start,
14149 end,
14150 id: id.into(),
14151 color: None,
14152 marker: None,
14153 };
14154 let mut list = vec![
14155 hl(0, 20, "outer"),
14156 hl(5, 10, "inner"),
14157 hl(30, 33, "hit"),
14158 hl(40, 43, "hit"),
14159 ];
14160 list.sort_by_key(|h| (h.start, h.end));
14161
14162 let mut cursor = HighlightCursor::new(&list);
14163 for offset in 0..50 {
14164 assert_eq!(
14165 cursor.at(offset).map(|h| h.id.as_str()),
14166 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
14167 "cursor disagrees at {offset}"
14168 );
14169 }
14170 // Backwards: the cursor re-seats rather than answering from where it
14171 // had got to, so a painter that revisits a row is still told the truth.
14172 for offset in (0..50).rev() {
14173 assert_eq!(
14174 cursor.at(offset).map(|h| h.id.as_str()),
14175 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
14176 "cursor disagrees walking back at {offset}"
14177 );
14178 }
14179 }
14180}