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::{Align, FontFace, FontSize, LineHeight, MarkColor, TextColor};
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, and since 3.5 a quote, a list, a code block, a link and an image
786/// printed as fresh nodes; it spells no task box (a form control there) and
787/// no footnote, and its `<table>` is one twig reads but will not write. So
788/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
789/// table buttons do not, and no one flag can say that. Markdown and djot
790/// differ from each other too:
791/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
792#[derive(Clone, Copy, Debug, Eq, PartialEq)]
793pub struct Capabilities {
794 /// ⌘B — `InlineKind::Strong`.
795 pub bold: bool,
796 /// ⌘I — `InlineKind::Emph`.
797 pub italic: bool,
798 /// Inline code — `InlineKind::Verbatim`.
799 pub code: bool,
800 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
801 /// under the `highlight` extension [`parse_extensions`] turns on: the
802 /// button writes `==text==`, which is what the reparse reads back.
803 pub mark: bool,
804 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
805 pub underline: bool,
806 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
807 /// out of the box, since twig parses it out of the box.
808 pub strike: bool,
809 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
810 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
811 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
812 /// so a toolbar offering the swatches wherever the button lights would offer
813 /// them in a document that cannot write one. Pair with
814 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
815 /// a highlight to colour as much as a format that spells one.
816 pub mark_color: bool,
817 pub superscript: bool,
818 pub subscript: bool,
819 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
820 pub heading: bool,
821 pub blockquote: bool,
822 pub bullet_list: bool,
823 pub ordered_list: bool,
824 /// The checkbox controls: giving an item a box, and ticking one.
825 pub task: bool,
826 pub link: bool,
827 /// Covers [`Doc::insert_media`] too — see the note there on why the three
828 /// media kinds stand or fall together.
829 pub image: bool,
830 /// The horizontal-rule button. HTML spells this one (`<hr>`).
831 pub thematic_break: bool,
832 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
833 /// the pair; HTML has no footnote of its own, so the button goes away rather
834 /// than writing brackets that would render as brackets.
835 pub footnote: bool,
836 /// Setting a fenced block's language — a control only ever offered with the
837 /// caret already in a fence.
838 pub code_language: bool,
839 /// The grid controls: insert/delete/move a row or column, set a column's
840 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
841 /// question — an HTML `<table>` holds the caret and still can't be edited.
842 pub table: bool,
843 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
844 /// idiomatic in-cell break.
845 pub cell_line_break: bool,
846 /// The alignment control — [`Doc::set_alignment`], twig's
847 /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
848 /// block's attributes, Markdown under the `html_elements`
849 /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
850 /// through its `[…]` line.
851 pub alignment: bool,
852 /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
853 /// [`alignment`](Self::alignment) and so the same answer, and its own flag
854 /// because a toolbar dims controls one at a time and the pair may yet
855 /// diverge.
856 pub line_spacing: bool,
857 /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
858 /// over a selection. **Narrower than the block pair**: AsciiDoc's
859 /// `[#id.role]#text#` keeps an id and a role and has no slot for a
860 /// `data-` key, so twig refuses the span there and this is `false` while
861 /// [`alignment`](Self::alignment) is `true`. The block-level form of the
862 /// same property — the caret in a paragraph, no selection — goes through
863 /// `SetBlockAttrs` and still works, which is why the flag describes the
864 /// control rather than the caret.
865 pub font_size: bool,
866 /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
867 /// [`font_size`](Self::font_size) is.
868 pub font_family: bool,
869 /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
870 /// and not to be confused with [`mark_color`](Self::mark_color): that is a
871 /// highlight's background and rides the `mark` node twig already owns,
872 /// this is a run's foreground and rides an attributed span.
873 pub text_color: bool,
874 /// The page-break button — [`Doc::insert_page_break`], twig's
875 /// `Gesture::InsertDirective`. Markdown under the `directives` extension
876 /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
877 /// it as an empty `::: page-break` fence.
878 ///
879 /// **Those two and no others**, though twig spells the gesture in HTML and
880 /// AsciiDoc as well — see [`Capabilities::of`].
881 pub page_break: bool,
882}
883
884impl Capabilities {
885 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
886 /// twig computes each from a static table — but a frontend that wants to
887 /// hold them can.
888 ///
889 /// The extensions are not a parameter because they are not a choice a
890 /// caller makes: every leaf document is parsed with [`parse_extensions`],
891 /// so the format is the whole of what varies.
892 pub fn of(format: Format) -> Self {
893 let exts = parse_extensions();
894 let supports = |g| format.supports_with(exts, g);
895 let inline = |k| supports(Gesture::ToggleInline(k));
896 let container = |k| supports(Gesture::ToggleBlockContainer(k));
897 Self {
898 bold: inline(InlineKind::Strong),
899 italic: inline(InlineKind::Emph),
900 code: inline(InlineKind::Verbatim),
901 mark: inline(InlineKind::Mark),
902 underline: inline(InlineKind::Insert),
903 strike: inline(InlineKind::Delete),
904 mark_color: supports(Gesture::SetMarkColor),
905 superscript: inline(InlineKind::Superscript),
906 subscript: inline(InlineKind::Subscript),
907 heading: supports(Gesture::SetBlock),
908 blockquote: container(BlockContainerKind::BlockQuote),
909 bullet_list: container(BlockContainerKind::BulletList),
910 ordered_list: container(BlockContainerKind::OrderedList),
911 // Both halves of the checkbox story, and leaf offers no control that
912 // needs only one: the item gesture mints the box, the checked one
913 // ticks it, and a format spelling a `task_marker` spells both.
914 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
915 link: supports(Gesture::InsertLink),
916 image: supports(Gesture::InsertImage),
917 thematic_break: supports(Gesture::InsertThematicBreak),
918 footnote: supports(Gesture::InsertFootnote),
919 code_language: supports(Gesture::SetCodeLanguage),
920 table: spells_pipe_tables(format),
921 cell_line_break: supports(Gesture::InsertLineBreak),
922 // The presentation vocabulary, one gesture per level: the two
923 // line-level properties are a block's attributes and the three
924 // run-level ones a span's. They are asked separately because the
925 // formats answer differently — AsciiDoc spells the block and not
926 // the span — and a toolbar that dimmed all five together would dim
927 // three controls that work.
928 alignment: supports(Gesture::SetBlockAttrs),
929 line_spacing: supports(Gesture::SetBlockAttrs),
930 font_size: supports(Gesture::WrapRangeAttrs),
931 font_family: supports(Gesture::WrapRangeAttrs),
932 text_color: supports(Gesture::WrapRangeAttrs),
933 // Narrower than the gesture, on purpose. Twig spells
934 // `InsertDirective` in HTML and AsciiDoc too, and spells it
935 // *differently* there — `<page-break></page-break>` and `<<<` —
936 // and the walker reads only the two spellings above. An HTML page
937 // break draws as nothing at all (no row, no caret home) and an
938 // AsciiDoc one as an empty unlabelled row, so the button would
939 // write a break the author cannot see and cannot get back to.
940 // The proposal claims Markdown and djot, and this is that claim.
941 // Widening it is the walker's work, not this line's — see
942 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
943 page_break: supports(Gesture::InsertDirective)
944 && matches!(format, Format::Markdown | Format::Djot),
945 }
946 }
947}
948
949/// The source of [`Doc::identity`], one per document ever built.
950static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
951
952impl Doc {
953 #[cfg(feature = "fs")]
954 pub fn open(path: PathBuf) -> Result<Self> {
955 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
956 Self::from_disk_bytes(path, bytes)
957 }
958
959 /// An empty document *named* `path`, for a file that isn't there yet — what
960 /// every other terminal editor gives you when you name a file that doesn't
961 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
962 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
963 /// the header shows the name the user asked for.
964 ///
965 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
966 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
967 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
968 /// parse is still an error: a mistyped flag or a stray argument should say
969 /// so, not open a buffer promising to save somewhere.
970 ///
971 /// The watermark is the hash of *no bytes*, not `None`, and that is the
972 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
973 /// answering [`DiskState::Untitled`] for a document that has a path and
974 /// intends to write to it. Hashing `""` instead makes the answers the true
975 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
976 /// recreates it, which is exactly what this is for), and
977 /// [`DiskState::Changed`] if somebody creates it underneath us between
978 /// launch and save, so the frontend's overwrite prompt guards a new file as
979 /// it guards an opened one.
980 ///
981 /// Nothing is written here. A buffer that is never typed into never touches
982 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
983 /// open — the write is where that fails, and it says so then.
984 #[cfg(feature = "fs")]
985 pub fn create(path: PathBuf) -> Result<Self> {
986 Self::from_disk_bytes(path, Vec::new())
987 }
988
989 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
990 /// the call a CLI frontend wants for its path argument.
991 ///
992 /// The decision is made from the failed read itself rather than a `exists()`
993 /// check first, so there is no window between the two for the file to appear
994 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
995 /// a directory in the way is still an error, because pretending those are
996 /// "no file yet" would offer to save over something leaf couldn't read.
997 #[cfg(feature = "fs")]
998 pub fn open_or_create(path: PathBuf) -> Result<Self> {
999 match std::fs::read(&path) {
1000 Ok(bytes) => Self::from_disk_bytes(path, bytes),
1001 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1002 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1003 }
1004 }
1005
1006 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1007 /// stand in for) the file at `path`, parsed as the format its extension
1008 /// names. Keeping the two on one path is what makes a new file's document
1009 /// identical in every respect to an opened one but its contents.
1010 #[cfg(feature = "fs")]
1011 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1012 let format = detect_format(&path)?;
1013 let editor = new_editor(&bytes, format)?;
1014 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1015 let disk_hash = Some(hash_bytes(source.as_bytes()));
1016 // Store the document's *absolute* path. A relative one (`leaf README.md`)
1017 // has an empty parent, so a frontend can't resolve a relative image
1018 // destination (``) against the document's directory and the
1019 // picture silently falls back to its text placeholder. `absolute` is
1020 // purely lexical — it prefixes the current directory and normalizes, but
1021 // reads nothing and resolves no symlinks — so `file_name` and save are
1022 // unchanged; it only gives `path.parent()` something to join against.
1023 let path = std::path::absolute(&path).unwrap_or(path);
1024 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1025 }
1026
1027 /// Build a document from an in-memory string, the format named explicitly —
1028 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1029 /// path and sniffs the format from its extension). A wasm or FFI host, which
1030 /// has no path to read, uses this: it hands over bytes it fetched however it
1031 /// could, and later persists [`Doc::source`] however it can (a browser
1032 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1033 ///
1034 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1035 /// true) exactly like a [`Doc::blank`] that has been given content.
1036 pub fn from_source(source: String, format: Format) -> Result<Self> {
1037 let editor = new_editor(source.as_bytes(), format)?;
1038 Ok(Doc::from_parts(
1039 editor,
1040 format,
1041 PathBuf::new(),
1042 source,
1043 None,
1044 ))
1045 }
1046
1047 /// An untitled, empty document — the `+` button and a `leaf` launched with
1048 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1049 ///
1050 /// It is Markdown, because a format has to be chosen before a name exists to
1051 /// read one from: `detect_format` reads the extension and an untitled
1052 /// document has neither. Markdown is what leaf's own files are, what its
1053 /// block markers are already written for (`insert_block_prefix`), and the
1054 /// extension a Save As will overwhelmingly pick — a wrong guess here would
1055 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1056 /// *doesn't* revisit this: see [`Doc::save_as`].
1057 pub fn blank() -> Result<Self> {
1058 let format = Format::Markdown;
1059 let editor = new_editor(b"", format)?;
1060 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1061 // field two frontends already read; making it an `Option` to say this
1062 // would break both). `is_untitled` is the question to ask, not the
1063 // representation to copy.
1064 Ok(Doc::from_parts(
1065 editor,
1066 format,
1067 PathBuf::new(),
1068 String::new(),
1069 None,
1070 ))
1071 }
1072
1073 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1074 /// apart in the ones neither of them has an opinion about.
1075 // `identity` is taken from a counter rather than from the `Doc`'s address,
1076 // which moves — a session that holds one is moved into and out of
1077 // containers freely, and an identity that changed with it would defeat the
1078 // one comparison it exists for.
1079 fn from_parts(
1080 editor: Editor,
1081 format: Format,
1082 path: PathBuf,
1083 source: String,
1084 disk_hash: Option<u64>,
1085 ) -> Self {
1086 Doc {
1087 editor,
1088 format,
1089 path,
1090 disk_hash,
1091 clean_source: source.clone(),
1092 source,
1093 caret: 0,
1094 anchor: None,
1095 dirty: false,
1096 status: None,
1097 read_only: false,
1098 highlights: Vec::new(),
1099 // leaf opens in the rich-text (WYSIWYG) view by default — the
1100 // markup-resolved surface is leaf's differentiator. Frontends can
1101 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1102 // toggles at runtime.
1103 view: View::Wysiwyg,
1104 // `None` by default — the clean surface Diaryx ships, with typed
1105 // syntax kept literal; a markup-fluent frontend can climb the
1106 // ladder to `Shortcuts` or `Full`.
1107 markup_mode: MarkupMode::default(),
1108 // Fold by default — flowing prose that reflows to the viewport, the
1109 // behaviour every frontend had before this preference existed.
1110 line_flow: LineFlow::default(),
1111 last_edit_kind: None,
1112 pending_marks: InlineMarks::empty(),
1113 pending_at: None,
1114 goal_col: None,
1115 vmap: VisualMap::default(),
1116 smap: SourceMap::default(),
1117 // No map yet — the first `build_source` always builds.
1118 smap_key: None,
1119 revision: 0,
1120 undo_steps: 0,
1121 redo_steps: 0,
1122 // No map yet — the first `build_visual` always builds.
1123 vmap_key: None,
1124 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1125 block_cache: wysiwyg::BlockCache::default(),
1126 media_rows: HashMap::new(),
1127 scroll: 0,
1128 body_origin: (0, 0),
1129 body_width: 0,
1130 body_height: 0,
1131 drawn_caret: None,
1132 }
1133 }
1134
1135 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1136 /// has never been saved. The question a ⌘S handler asks to know it should
1137 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1138 /// header asks to know the name it shows is a placeholder.
1139 pub fn is_untitled(&self) -> bool {
1140 self.path.as_os_str().is_empty()
1141 }
1142
1143 pub fn toggle_view(&mut self) {
1144 self.view = match self.view {
1145 View::Source => View::Wysiwyg,
1146 View::Wysiwyg => View::Source,
1147 };
1148 self.scroll = 0;
1149 self.status = None;
1150 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1151 // lift it to the first rendered offset.
1152 self.clamp_caret();
1153 }
1154
1155 /// The current markup-exposure preference (see [`MarkupMode`]).
1156 pub fn markup_mode(&self) -> MarkupMode {
1157 self.markup_mode
1158 }
1159
1160 /// Set the markup-exposure preference. Both of its axes take effect at
1161 /// once: the editing one on the next [`insert`](Self::insert), and the
1162 /// rendering one on the next build — which is why this drops the cached
1163 /// visual map and the per-block render cache, exactly as
1164 /// [`set_line_flow`](Self::set_line_flow) does.
1165 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1166 if self.markup_mode == mode {
1167 return;
1168 }
1169 self.markup_mode = mode;
1170 // Neither cache is keyed on the mode, and moving between `Full` and the
1171 // hidden modes changes every row the caret's line renders to — so
1172 // invalidate both explicitly.
1173 self.vmap_key = None;
1174 self.block_cache = wysiwyg::BlockCache::default();
1175 }
1176
1177 /// The source byte range of the line the caret sits on, when that line
1178 /// should render its raw delimiters — `None` in every mode and view that
1179 /// hides them, which is what the builder reads as "reveal nothing".
1180 ///
1181 /// A *source* line (newline to newline), not a visual row: a wrapped
1182 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1183 /// line across several rows, and revealing half a delimiter pair because the
1184 /// other half wrapped would be worse than revealing neither. The range
1185 /// excludes the terminating newline and is empty-but-present on a blank
1186 /// line, which reveals nothing but still keys the caches correctly.
1187 ///
1188 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1189 /// there is nothing there to reveal.
1190 pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1191 if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1192 return None;
1193 }
1194 Some(source_line_range(&self.source, self.caret))
1195 }
1196
1197 /// The current soft-break flow preference (see [`LineFlow`]).
1198 pub fn line_flow(&self) -> LineFlow {
1199 self.line_flow
1200 }
1201
1202 /// Set the soft-break flow preference. The mode changes how every block lays
1203 /// out, so a change drops the cached visual map and the per-block render
1204 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1205 ///
1206 /// [`build_visual`]: Self::build_visual
1207 pub fn set_line_flow(&mut self, mode: LineFlow) {
1208 if self.line_flow == mode {
1209 return;
1210 }
1211 self.line_flow = mode;
1212 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1213 // so invalidate them explicitly, or the next build would reuse rows laid
1214 // out under the old flow.
1215 self.vmap_key = None;
1216 self.block_cache = wysiwyg::BlockCache::default();
1217 }
1218
1219 pub fn view_name(&self) -> &'static str {
1220 match self.view {
1221 View::Source => "source",
1222 View::Wysiwyg => "wysiwyg",
1223 }
1224 }
1225
1226 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1227 /// (called by the renderer each frame it's in the WYSIWYG view).
1228 /// Build the WYSIWYG map, wrapped at `width` display columns.
1229 ///
1230 /// Cheap to call every frame, which is what both frontends do: the map is a
1231 /// pure function of the document and the wrap width, so a call that would
1232 /// rebuild the same map returns the one already built. Only an edit (or a
1233 /// resize) pays.
1234 ///
1235 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1236 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1237 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1238 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1239 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1240 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1241 pub fn build_visual(&mut self, width: usize) {
1242 self.build_map(Some(width));
1243 }
1244
1245 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1246 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1247 /// than a fixed character column.
1248 pub fn build_visual_unwrapped(&mut self) {
1249 self.build_map(None);
1250 }
1251
1252 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1253 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1254 /// styling for [`View::Wysiwyg`].
1255 ///
1256 /// A frontend calls this before painting raw source. One that doesn't gets
1257 /// an empty map and paints unstyled text, so this is additive: nothing
1258 /// breaks by not calling it.
1259 ///
1260 /// Built at most once per revision, and the revision is the whole key — the
1261 /// map has no width and no caret in it, so it survives every resize, every
1262 /// motion, and every selection change.
1263 ///
1264 /// The builds it does do cost a whole-arena marshal, which is precisely what
1265 /// the WYSIWYG path works to avoid, so this has no incremental path where
1266 /// that one has two. From `cargo run --release -p leaf-core --example
1267 /// bench`, per keystroke, against the WYSIWYG build the source view is
1268 /// *not* doing:
1269 ///
1270 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1271 /// |------:|-------:|--------:|----------------:|-------------------:|
1272 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1273 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1274 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1275 ///
1276 /// Linear, two thirds of it the marshal, and the build itself five to seven
1277 /// times cheaper than the one it stands in for at every size. Comfortable
1278 /// well past any document a person edits in a terminal — a megabyte is where
1279 /// it would want [`Editor::dirty_range`] and the same splice treatment
1280 /// `build_spliced` gives the other map. The door is open; nothing has needed
1281 /// it yet.
1282 pub fn build_source(&mut self) {
1283 if self.smap_key == Some(self.revision) {
1284 return;
1285 }
1286 let nodes = self.nodes();
1287 self.smap = source::build(&nodes, &self.source);
1288 self.smap_key = Some(self.revision);
1289 }
1290
1291 /// Tell the model how many visual rows each block image should reserve, keyed
1292 /// by the image's destination. A terminal frontend calls this once it has
1293 /// decoded and measured its pictures — core does no image I/O, so this is the
1294 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1295 /// placeholder out that tall (the label row plus blank filler rows the
1296 /// frontend paints the raster over). A destination left out of the map falls
1297 /// back to the bare one-row placeholder, which is also what a frontend that
1298 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1299 /// by never calling this.
1300 ///
1301 /// Cheap to call every frame with the same map: only a *change* invalidates
1302 /// the built map (and the block-row cache, since a height isn't part of a
1303 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1304 /// no-op, so a frontend can just hand over its current measurements each frame.
1305 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1306 if self.media_rows == rows {
1307 return;
1308 }
1309 self.media_rows = rows;
1310 // A height lives outside the block's source bytes, so the content-keyed
1311 // block cache would hand back the old-height rows on a hit. Drop it (and
1312 // the splice layout it carries) so the next build re-renders every block
1313 // at the new heights, and force that build by clearing the map key.
1314 self.block_cache = wysiwyg::BlockCache::default();
1315 self.vmap_key = None;
1316 }
1317
1318 /// The revision the document's text is at — bumped by every edit, undo,
1319 /// redo, and reload, and by nothing else. A frontend caches against this to
1320 /// tell a repaint that needs new work from one that doesn't.
1321 ///
1322 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1323 /// lands on the same text two revisions later. Work is only ever rebuilt
1324 /// needlessly, never wrongly reused.
1325 pub fn revision(&self) -> u64 {
1326 self.revision
1327 }
1328
1329 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1330 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1331 /// unbuilt map before the first one.
1332 ///
1333 /// This is *not* [`revision`](Self::revision). The revision says where the
1334 /// text is; this says where the map is, and the two part company the moment
1335 /// an edit lands, until something rebuilds. A frontend that keeps its own
1336 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1337 /// under an oversized heading — compares this against the value it held when
1338 /// it took the copy, and learns whether `vmap` is still the map it stashed
1339 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1340 /// map would paint a stale document; restoring nothing hands core's
1341 /// incremental rebuild a map it never built.
1342 ///
1343 /// "Somebody else" includes another document. The key names the `Doc`
1344 /// as well as the build, so a frontend that draws two documents through
1345 /// one stash — a host with several buffers, or one that opens the next
1346 /// document where the last one stood — never has the copy it took of one
1347 /// accepted by the other, however alike their builds are.
1348 pub fn visual_key(&self) -> VisualKey {
1349 VisualKey(self.identity, self.vmap_key.clone())
1350 }
1351
1352 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1353 /// runs on every call: the caret moves without the document changing, and
1354 /// keeping it on a legal stop is this function's job either way.
1355 fn build_map(&mut self, wrap: Option<usize>) {
1356 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1357 // as well as the text, so the line joins the key: moving within a line
1358 // still reuses the map, and crossing into another one rebuilds it. In
1359 // every other mode `reveal_line` is `None` and the key is what it was,
1360 // so caret motion goes on costing nothing.
1361 let reveal = self.reveal_line();
1362 let key = (self.revision, wrap, reveal.clone());
1363 if self.vmap_key.as_ref() != Some(&key) {
1364 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1365 // A subtree is pulled only for the block(s) that actually changed, so
1366 // the FFI marshal shrinks from O(document) to O(edited block).
1367 let top = self.top_blocks();
1368
1369 // Fast path: when twig reports a dirty byte range, try to patch the
1370 // previous map in place — a single-block edit moves the prefix,
1371 // shifts the suffix, and re-renders only one block. `build_spliced`
1372 // returns `None` (and we fall back to the always-correct full rebuild)
1373 // whenever the edit reshaped the block structure, hit a table, or
1374 // there's no previous map to patch.
1375 // Preserve soft breaks as written when the flow preference asks for
1376 // it — the builder renders each as its own visual row instead of
1377 // folding it into the reflowed paragraph.
1378 let preserve_soft = self.line_flow == LineFlow::Preserve;
1379 let spliced = match self.editor.dirty_range() {
1380 Some(dirty) => {
1381 let prev = std::mem::take(&mut self.vmap);
1382 let source = &self.source;
1383 let cache = &mut self.block_cache;
1384 let media_rows = &self.media_rows;
1385 let editor = &mut self.editor;
1386 wysiwyg::build_spliced(
1387 prev,
1388 source,
1389 wrap,
1390 preserve_soft,
1391 &top,
1392 dirty,
1393 media_rows,
1394 reveal.clone(),
1395 cache,
1396 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1397 )
1398 }
1399 None => None,
1400 };
1401 self.vmap = spliced.unwrap_or_else(|| {
1402 let source = &self.source;
1403 let cache = &mut self.block_cache;
1404 let media_rows = &self.media_rows;
1405 let editor = &mut self.editor;
1406 wysiwyg::build_cached(
1407 &top,
1408 source,
1409 wrap,
1410 preserve_soft,
1411 media_rows,
1412 reveal,
1413 cache,
1414 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1415 )
1416 });
1417 // Acknowledge the dirty range so the next edit's range starts fresh.
1418 self.editor.clear_dirty();
1419 self.vmap_key = Some(key);
1420 }
1421 self.clamp_caret();
1422 }
1423
1424 fn nodes(&mut self) -> Vec<FlatNode> {
1425 self.editor.nodes().unwrap_or_default()
1426 }
1427
1428 /// The document's top-level blocks for the incremental render. See
1429 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1430 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1431 wysiwyg::top_blocks(&mut self.editor)
1432 }
1433
1434 pub fn format_name(&self) -> &'static str {
1435 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1436 // required. It also covers `Asciidoc`, which twig parses but cannot
1437 // serialize — leaf never opens a document in it (see `Doc::open`).
1438 match self.format {
1439 Format::Djot => "djot",
1440 Format::Markdown => "markdown",
1441 Format::Xml => "xml",
1442 Format::Html => "html",
1443 _ => "unknown",
1444 }
1445 }
1446
1447 /// Whether this document's format offers *any* door in — `false` only for a
1448 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1449 /// a frontend may as well open the file read-only.
1450 ///
1451 /// This is a much weaker claim than the name suggests, and driving per-button
1452 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1453 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1454 /// while a heading, a quote, a list, a task box, a link and a code fence all
1455 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1456 /// [`supports`](Self::supports) — per control.
1457 pub fn authorable(&self) -> bool {
1458 self.format.is_authorable()
1459 }
1460
1461 /// Whether this document can spell `gesture`, which is twig's own answer
1462 /// rather than a copy of it: `Format::supports_with` reads the same
1463 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1464 /// the very [`parse_extensions`] this document's editor reparses with — so
1465 /// what the toolbar offers and what the splice will accept are one table.
1466 ///
1467 /// It is a fact about the *document*, not about the caret. `true` does not
1468 /// promise the gesture succeeds where it is standing — a link over a table
1469 /// border still fails — only that it will not fail with
1470 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1471 /// will work here".
1472 pub fn supports(&self, gesture: Gesture) -> bool {
1473 self.format.supports_with(parse_extensions(), gesture)
1474 }
1475
1476 /// Every control's enabled state in one read — what a toolbar builds itself
1477 /// from when a document opens or its format changes. See [`Capabilities`].
1478 pub fn capabilities(&self) -> Capabilities {
1479 Capabilities::of(self.format)
1480 }
1481
1482 /// Refuse a gesture this document's format cannot spell, saying so in the
1483 /// status line. `true` means the caller must return without calling twig.
1484 ///
1485 /// Most of these refusals duplicate one twig would make anyway, and they are
1486 /// made here regardless because a message naming the *document's* format
1487 /// reads better than one naming twig's internals. Two of them are not
1488 /// duplicates and are the reason this is a guard rather than an error
1489 /// translation:
1490 ///
1491 /// - The table family (see [`table_op`](Self::table_op)) consults no
1492 /// `Syntax` table, so twig does not refuse it at all.
1493 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1494 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1495 /// could not keep.
1496 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1497 self.refuse_unless(what, self.supports(gesture))
1498 }
1499
1500 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1501 /// answers itself — today only [`spells_pipe_tables`].
1502 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1503 if supported {
1504 return false;
1505 }
1506 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1507 true
1508 }
1509
1510 /// The name to show for this document. An untitled one has no file to name
1511 /// it, and both frontends put this straight on screen — an empty path
1512 /// renders as an empty header, so it says so instead.
1513 pub fn file_name(&self) -> String {
1514 if self.is_untitled() {
1515 return "untitled".into();
1516 }
1517 self.path
1518 .file_name()
1519 .map(|s| s.to_string_lossy().into_owned())
1520 .unwrap_or_else(|| self.path.display().to_string())
1521 }
1522
1523 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1524 /// caret and anchor coincide (an empty selection is no selection).
1525 pub fn selection(&self) -> Option<(usize, usize)> {
1526 self.anchor
1527 .map(|a| (a.min(self.caret), a.max(self.caret)))
1528 .filter(|(s, e)| s != e)
1529 }
1530
1531 /// The selected text, or `None` when there's no selection — the source
1532 /// slice a copy/cut hands to the system clipboard.
1533 pub fn selected_text(&self) -> Option<&str> {
1534 self.selection().map(|(s, e)| &self.source[s..e])
1535 }
1536
1537 /// The selection as a quote with a little of what surrounds it — the shape
1538 /// a host that cites, annotates, or searches for a passage wants, cut from
1539 /// the **source** rather than from anything rendered, so the quote is
1540 /// findable in the document again by plain string search.
1541 ///
1542 /// `context` is a count of characters (not bytes) on each side, clipped at
1543 /// the document's edges; the slices land on char boundaries by
1544 /// construction. `None` when nothing is selected.
1545 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1546 let (start, end) = self.selection()?;
1547 let mut before = start;
1548 for _ in 0..context {
1549 match self.source[..before].chars().next_back() {
1550 Some(c) => before -= c.len_utf8(),
1551 None => break,
1552 }
1553 }
1554 let mut after = end;
1555 for _ in 0..context {
1556 match self.source[after..].chars().next() {
1557 Some(c) => after += c.len_utf8(),
1558 None => break,
1559 }
1560 }
1561 Some(Quote {
1562 exact: self.source[start..end].to_string(),
1563 prefix: self.source[before..start].to_string(),
1564 suffix: self.source[end..after].to_string(),
1565 start,
1566 end,
1567 })
1568 }
1569
1570 /// Whether the document refuses to change — see the field.
1571 pub fn read_only(&self) -> bool {
1572 self.read_only
1573 }
1574
1575 /// Turn the read-only gate on or off. A frontend preference like
1576 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1577 /// itself changes, only what may be done to it from here on.
1578 pub fn set_read_only(&mut self, on: bool) {
1579 self.read_only = on;
1580 }
1581
1582 /// The host-painted ranges, sorted by start — see [`Highlight`].
1583 pub fn highlights(&self) -> &[Highlight] {
1584 &self.highlights
1585 }
1586
1587 /// Replace the host-painted ranges wholesale. The whole set each time,
1588 /// rather than add/remove verbs: the host owns the list (it derives it
1589 /// from its own state — annotations, search hits), and a replace can
1590 /// never leave the two disagreeing about what should be on screen.
1591 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1592 highlights.retain(|h| h.start < h.end);
1593 highlights.sort_by_key(|h| (h.start, h.end));
1594 self.highlights = highlights;
1595 }
1596
1597 /// The highlight covering source `offset`, if one does — first by start
1598 /// when several overlap, which makes overlapping washes resolvable rather
1599 /// than undefined. What a frontend asks when the reader activates a spot.
1600 ///
1601 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1602 /// asking the same question per glyph, against a slice they were handed
1603 /// rather than against a `Doc`, and one answer for both is what keeps a
1604 /// wash and an activation agreeing about which range a spot is in.
1605 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1606 Highlight::covering(&self.highlights, offset)
1607 }
1608
1609 /// The AST breadcrumb at the caret (root → deepest), e.g.
1610 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1611 pub fn breadcrumb(&mut self) -> String {
1612 match self.editor.ancestors_at(self.caret) {
1613 Ok(chain) => chain
1614 .iter()
1615 .map(|m| m.kind.as_str())
1616 .collect::<Vec<_>>()
1617 .join(" › "),
1618 Err(_) => String::new(),
1619 }
1620 }
1621
1622 // ── editing ──────────────────────────────────────────────────────────────
1623
1624 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1625 /// after it. The public form of the internal splice — a pixel frontend that
1626 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1627 /// edits through this, the same twig `edit_range` the caret ops use.
1628 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1629 self.splice(start, end, text, EditKind::Other);
1630 }
1631
1632 /// Insert typed `text` at the caret, replacing the selection if there is one.
1633 /// A single typed character coalesces with the run of typing before it; a
1634 /// newline or a multi-character insert is its own undo step.
1635 ///
1636 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1637 pub fn insert(&mut self, text: &str) {
1638 // The read-only gate, up front: the paths below reach twig by several
1639 // verbs, not all of them through the splice — see the field.
1640 if self.read_only {
1641 return;
1642 }
1643 // Typing against a block picture would dissolve it, and typing past a
1644 // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1645 // the text a paragraph first, so what the caret was standing beside
1646 // stays what it was.
1647 self.open_paragraph_at_block_edge(text);
1648 // Armed sticky marks (⌘b with no selection) turn the next typed text
1649 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1650 // the exception: it takes no mark of its own and keeps the delta armed
1651 // for the character behind it — see `insert_space_with_marks`.
1652 let pending = self.pending_here();
1653 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1654 if text.trim().is_empty() {
1655 self.insert_space_with_marks(self.caret, text, pending);
1656 } else {
1657 self.insert_with_marks(self.caret, text, pending);
1658 }
1659 return;
1660 }
1661 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1662 // anything that would open markup, so a Diaryx user never mints
1663 // formatting by keyboard (it comes from commands instead). The other two
1664 // rungs of the ladder author markup from what you type, which is the
1665 // whole difference between them and this one. Only in the rendered view
1666 // (source view is for typing raw markup) and only where the format has a
1667 // literal spelling at all: escaping is a backslash before a byte from the
1668 // format's own alphabet, and a format with no such alphabet (HTML escapes
1669 // with entities, XML spells nothing) would have `\&` written into it,
1670 // which is two literal characters and not an escape. Marks (⌘b) still
1671 // format — that path returned above; and leaf's own structural inserts go
1672 // through `insert_raw`, never here, so a list marker or quote gutter is
1673 // written as the markup it is.
1674 if !self.markup_mode.authors()
1675 && self.view == View::Wysiwyg
1676 && !text.is_empty()
1677 && self.supports(Gesture::InsertLiteral)
1678 {
1679 self.insert_literal_typed(text);
1680 return;
1681 }
1682 self.insert_raw(text);
1683 }
1684
1685 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1686 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1687 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1688 /// markup by design and must not be escaped.
1689 fn insert_raw(&mut self, text: &str) {
1690 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1691 self.splice(s, e, text, typed_edit_kind(text));
1692 }
1693
1694 /// Open a paragraph for text about to be inserted at one of a block media's
1695 /// two caret stops, or at a table's trailing stop, and leave the caret
1696 /// standing in it.
1697 ///
1698 /// A block image is a paragraph whose entire content is the picture, and the
1699 /// caret's only homes on it are in front of it and just past it (see
1700 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1701 /// *that* paragraph — and a paragraph holding anything besides the image is
1702 /// no longer a block image but a line of text with an inline one in it. The
1703 /// frontend that was painting a photo there paints a text run instead; the
1704 /// picture is still in the file, and nothing said a word. Those two offsets
1705 /// are also exactly where a click on the picture lands, so the whole accident
1706 /// is one tap and one keystroke.
1707 ///
1708 /// So the break goes in first and the text lands in the new empty paragraph —
1709 /// what pressing Return before typing would have done, which is a habit no
1710 /// one should have to learn from losing a photo. A no-op everywhere else, and
1711 /// over a selection (which is replaced, not joined into).
1712 ///
1713 /// A picture inside a quote or a list leaves its container, because `\n\n`
1714 /// ends the block. The alternative is worse: the `\n> ` / next-item
1715 /// continuation [`newline`](Self::newline) writes stays in the same
1716 /// *paragraph*, which is the thing being prevented.
1717 ///
1718 /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1719 /// accident from the other side of a different block: the stop sits at the
1720 /// end of the table's last source line, and a line glued under a table is
1721 /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1722 /// break goes in there too, and the text lands under the table.
1723 ///
1724 /// Only in the rendered view. Source view is for typing raw markup, where
1725 /// putting a character against an image is exactly what it looks like.
1726 fn open_paragraph_at_block_edge(&mut self, text: &str) {
1727 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1728 return;
1729 }
1730 if self.selection().is_some() {
1731 return;
1732 }
1733 // The map may be a revision behind (nothing has drawn since the last
1734 // edit), and this asks it about offsets — a stale answer would splice a
1735 // break into the wrong place. Free when it is already current, which it
1736 // is whenever a frontend drew a frame between keystrokes.
1737 self.rebuild_map();
1738 let at = self.caret;
1739 let side = match self.vmap.block_media_stop(at) {
1740 Some((side, _)) => side,
1741 None if self.vmap.table_end_stop(at) => MediaStop::After,
1742 None => return,
1743 };
1744 if !self.splice(at, at, "\n\n", EditKind::Other) {
1745 return;
1746 }
1747 // The break is part of the keystroke, not an edit of its own: leave the
1748 // run marked as typing so the character about to arrive folds into it and
1749 // one undo puts the document back the way it was found. (A paste, or a
1750 // multi-character insert, is `EditKind::Other` and stays its own step —
1751 // as it would have been anywhere else in the document.)
1752 self.last_edit_kind = Some(EditKind::Insert);
1753 if side == MediaStop::Before {
1754 // The break went in above the picture and the caret rode to the end
1755 // of it — which is still hard against the picture. Step back onto the
1756 // blank line it opened, so the text lands above rather than in front.
1757 self.caret = at;
1758 }
1759 }
1760
1761 /// A delete key pressed at one of a block picture's two caret stops, handled
1762 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1763 /// the key was consumed.
1764 ///
1765 /// The caret rests in front of a block image and just past it, never inside
1766 /// its markup — which the rendered view doesn't show. So the byte a delete
1767 /// key nominally takes there is one the writer cannot see, and taking it
1768 /// leaves the picture as broken markup rather than as anything anyone asked
1769 /// for: Backspace at the stop past `` removes the closing paren, and
1770 /// a photo becomes the literal text `
1773 /// prevents from the typing side, and it cost this repository's own test vault
1774 /// a photo before it was found.
1775 ///
1776 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1777 /// when it is behind the caret, Delete when it is in front — which is what
1778 /// every editor does with an embed, and one undo away. The key aimed *away*
1779 /// from it would otherwise delete the paragraph break and merge a neighbour
1780 /// into the picture's own paragraph, which dissolves it just as surely; it
1781 /// steps the caret over the boundary instead and leaves the
1782 /// next press to delete in the block it has reached — the same "first press
1783 /// steps out of the atom, second press deletes" every delete key here gets,
1784 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1785 /// above, and reaches it on the second press rather than taking the break and
1786 /// the picture with it on the first).
1787 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1788 // The map answers about offsets, so it has to be this revision's — see
1789 // the same call in `open_paragraph_at_block_edge`.
1790 self.rebuild_map();
1791 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1792 return false;
1793 };
1794 let aimed_at_it = side
1795 == if forward {
1796 MediaStop::Before
1797 } else {
1798 MediaStop::After
1799 };
1800 if !aimed_at_it {
1801 let over = if forward {
1802 self.vmap.stop_after(self.caret)
1803 } else {
1804 self.vmap.stop_before(self.caret)
1805 };
1806 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1807 self.caret = off;
1808 self.anchor = None;
1809 self.goal_col = None;
1810 }
1811 return true;
1812 }
1813 // Take the break that held the picture apart from its neighbour with it,
1814 // so the delete doesn't leave a blank paragraph standing where the
1815 // picture was. The last arm is a picture that is the whole document.
1816 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1817 (span.start - 2, span.end)
1818 } else if self.source[span.end..].starts_with("\n\n") {
1819 (span.start, span.end + 2)
1820 } else {
1821 (span.start, span.end)
1822 };
1823 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1824 true
1825 }
1826
1827 /// The Hidden-mode typing path: replace any selection, then insert `text`
1828 /// escaped so it stays literal. When it replaces a selection the two edits
1829 /// fold into one undo step, so an overwrite undoes atomically (and restores
1830 /// the selection) exactly as a plain one does.
1831 fn insert_literal_typed(&mut self, text: &str) {
1832 let kind = typed_edit_kind(text);
1833 match self.selection() {
1834 Some((s, e)) => {
1835 if !self.splice(s, e, "", EditKind::Other) {
1836 return;
1837 }
1838 // Typing over a whole marked run takes its delimiters with it
1839 // (the empty content couldn't hold them — see
1840 // `repair_mark_edges`) and leaves its marks armed at the caret.
1841 // The text taking the run's place inherits them, exactly as it
1842 // would have by landing inside a run that survived.
1843 let pending = self.pending_here();
1844 if !pending.is_empty() && !text.trim().is_empty() {
1845 self.insert_with_marks(self.caret, text, pending);
1846 return;
1847 }
1848 self.insert_literal_at(self.caret, text, kind, true);
1849 }
1850 None => {
1851 self.insert_literal_at(self.caret, text, kind, false);
1852 }
1853 }
1854 }
1855
1856 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1857 /// at a collapsed caret, but only while the caret still stands where they
1858 /// were armed and nothing is selected. Empty otherwise, so a stale delta
1859 /// never styles text it wasn't meant for.
1860 fn pending_here(&self) -> InlineMarks {
1861 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1862 self.pending_marks
1863 } else {
1864 InlineMarks::empty()
1865 }
1866 }
1867
1868 /// Drop the armed sticky marks — any caret motion, selection, or edit does
1869 /// this, so "start bold here" only ever applies at the exact spot it was
1870 /// asked for.
1871 fn clear_pending(&mut self) {
1872 self.pending_marks = InlineMarks::empty();
1873 self.pending_at = None;
1874 }
1875
1876 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1877 /// force is wrapped around the freshly typed text; a mark the caret already
1878 /// stands inside is *shed* — the text is inserted past the run's end so it
1879 /// lands unmarked ("type normally again"). The caret comes to rest inside any
1880 /// added runs, so continued typing inherits the marks with no re-wrapping,
1881 /// and the delta is cleared: the marks now live in the document, not here.
1882 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1883 let base = self.mark_spans_at(at);
1884 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1885 // Nothing to shed, and a run of exactly these marks standing just behind
1886 // the caret: carry on writing *that* run rather than opening a second
1887 // one beside it.
1888 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1889 return;
1890 }
1891 // Shed the marks we're turning off: step the insertion point past the
1892 // end of each run the caret sits in, so the new text falls outside it.
1893 let mut ins_at = at;
1894 for (kind, span) in &base {
1895 if marks.contains(*kind) {
1896 ins_at = ins_at.max(span.end);
1897 }
1898 }
1899 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1900 return;
1901 }
1902 // The plain splice inserted exactly `text` at `ins_at`; that byte range
1903 // is the content every added mark wraps.
1904 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1905 for kind in marks.iter() {
1906 if !base_set.contains(kind) {
1907 let (ncs, nce) = self.wrap_span(cs, ce, kind);
1908 cs = ncs;
1909 ce = nce;
1910 }
1911 }
1912 self.caret = ce.min(self.source.len());
1913 self.anchor = None;
1914 self.last_edit_kind = None;
1915 // Realised: the marks are in the document now, and the caret sits inside
1916 // them, so there is no delta left to carry. Arm nothing, but remember the
1917 // spot so a *further* toggle before typing starts a clean delta here.
1918 self.pending_marks = InlineMarks::empty();
1919 self.pending_at = Some(self.caret);
1920 self.clamp_caret();
1921 self.record_caret();
1922 }
1923
1924 /// Carry on the marked run just behind `at` — moving its closing delimiters
1925 /// out past the new text — instead of opening a second run of the same marks
1926 /// beside it. Returns whether it did.
1927 ///
1928 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1929 /// A space typed after a bold word steps the caret out of the run, because
1930 /// `**bold **` is not bold; the next character has to step back *in*, or the
1931 /// writer who typed one bold phrase is left with `**bold** **and**` — two
1932 /// runs that read the same to a reader but spell the file in a way nobody
1933 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1934 /// words it isn't marking), and the marks behind it must be exactly the ones
1935 /// armed — a run of *some* other kind is a neighbour, not this phrase.
1936 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1937 if text.is_empty() || text.trim() != text {
1938 return false;
1939 }
1940 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1941 // Walk in through the delimiters stacked at that point, innermost last:
1942 // `***both*** ` closes two runs with one `***`, and rejoining means
1943 // getting behind all of them.
1944 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1945 while let Some((kind, content_end)) = self
1946 .editor
1947 .ancestors_at(prev_boundary(&self.source, cut))
1948 .unwrap_or_default()
1949 .into_iter()
1950 .filter(|m| m.span.end == cut)
1951 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1952 {
1953 if content_end >= cut {
1954 break; // a mark with no closing delimiter to step behind
1955 }
1956 kinds.insert(kind);
1957 cut = content_end;
1958 }
1959 if cut == gap_at || kinds != marks {
1960 return false;
1961 }
1962 // Re-spell the tail: the gap, then the new text, then the delimiters that
1963 // used to close in front of them — read out of the document rather than
1964 // written from a table, so whatever twig spells them with is what moves.
1965 let tail = format!(
1966 "{}{text}{}",
1967 &self.source[gap_at..at],
1968 &self.source[cut..gap_at]
1969 );
1970 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1971 return false;
1972 }
1973 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1974 self.anchor = None;
1975 self.last_edit_kind = None;
1976 self.pending_marks = InlineMarks::empty();
1977 self.pending_at = Some(self.caret);
1978 self.clamp_caret();
1979 self.record_caret();
1980 true
1981 }
1982
1983 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1984 /// never itself wrapped: a mark around a space draws nothing a reader can
1985 /// see, and in Markdown and Djot it draws its own delimiters instead
1986 /// (`** **`). So the space goes in unmarked — outside any run the armed
1987 /// marks are shedding — and the marks stay armed for the character after it,
1988 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1989 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1990 let base = self.mark_spans_at(at);
1991 // What the *next* character carries: the armed delta resolved against the
1992 // marks in force here, which the space must not quietly drop.
1993 let want = base
1994 .iter()
1995 .map(|(k, _)| *k)
1996 .collect::<InlineMarks>()
1997 .xor(marks);
1998 let mut ins_at = at;
1999 for (kind, span) in &base {
2000 if marks.contains(*kind) {
2001 ins_at = ins_at.max(span.end);
2002 }
2003 }
2004 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2005 return;
2006 }
2007 self.rearm(want);
2008 self.record_caret();
2009 }
2010
2011 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2012 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2013 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2014 /// added split evenly around the content — half the growth on each side.
2015 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2016 // The read-only gate — this door reaches twig without the splice.
2017 if self.read_only {
2018 return (s, e);
2019 }
2020 match self.editor.toggle_inline(s, e, kind) {
2021 Ok(change) => {
2022 self.last_edit_kind = None;
2023 self.refresh();
2024 self.dirty = self.source != self.clean_source;
2025 let added = (change.new.end - change.new.start).saturating_sub(e - s);
2026 let half = added / 2;
2027 (change.new.start + half, change.new.end - half)
2028 }
2029 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2030 // rather than lose the keystroke.
2031 Err(e2) => {
2032 self.status = Some(format!("{kind:?}: {e2}"));
2033 (s, e)
2034 }
2035 }
2036 }
2037
2038 /// The safe offset to splice a block-level break at, given a caret that may
2039 /// sit exactly between an inline mark's content and its own closing
2040 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2041 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2042 /// with nothing following it on the line: the closing `**` renders no
2043 /// glyph of its own, so the caret's "end of line" offset lands right
2044 /// before it). Splicing a paragraph/list/quote break at `off` itself would
2045 /// sever the delimiter from its content, stranding it alone on the new
2046 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2047 /// nested marks closing at the same point (`**_x_**`) all clear together.
2048 /// A no-op everywhere else — mid-run, or past real trailing content, no
2049 /// mark's `content_span` ends exactly at `off`.
2050 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2051 let off = off.min(self.source.len());
2052 let runs = self.run_span_ids();
2053 self.editor
2054 .ancestors_at(off)
2055 .unwrap_or_default()
2056 .into_iter()
2057 .filter(|m| hides_delims(m, &runs))
2058 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2059 .map(|m| m.span.end)
2060 .max()
2061 .unwrap_or(off)
2062 }
2063
2064 /// The offset a *delete* aimed at the character before `off` should stop at,
2065 /// when `off` is the start of a run's text and the bytes behind it are that
2066 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2067 /// byte behind the caret at the start of a bold word is not a character the
2068 /// writer can see, let alone one they aimed Backspace at: taking it leaves
2069 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2070 /// delete steps over the whole delimiter to the visible character in front of
2071 /// it instead. Walks out to the *outermost* mark opening there, so
2072 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2073 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2074 let off = off.min(self.source.len());
2075 let runs = self.run_span_ids();
2076 self.editor
2077 .ancestors_at(off)
2078 .unwrap_or_default()
2079 .into_iter()
2080 .filter(|m| hides_delims(m, &runs))
2081 .filter(|m| {
2082 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2083 })
2084 .map(|m| m.span.start)
2085 .min()
2086 .unwrap_or(off)
2087 }
2088
2089 /// `off` moved *inside* the run whose closing delimiters end there — the
2090 /// other offset the rich view draws in the same place, since a `**` renders
2091 /// no glyph of its own. `**bold**` has a caret home on each side of its
2092 /// closing delimiter, one column apart on screen and eight bytes and a whole
2093 /// run apart in the file, and a plain ← lands on the outer one whenever a
2094 /// space follows the phrase. The inner one is what the writer is pointing at
2095 /// there: the end of their bold word. Walks in through every mark closing at
2096 /// that point, innermost last, so `***both***` lands inside both. A no-op
2097 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2098 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2099 let mut off = off.min(self.source.len());
2100 let runs = self.run_span_ids();
2101 loop {
2102 let inner = self
2103 .editor
2104 .ancestors_at(prev_boundary(&self.source, off))
2105 .unwrap_or_default()
2106 .into_iter()
2107 .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2108 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2109 .filter(|&end| end < off)
2110 .max();
2111 match inner {
2112 Some(end) => off = end,
2113 None => return off,
2114 }
2115 }
2116 }
2117
2118 /// The mirror at the opening edge: `off` moved inside the run whose
2119 /// delimiters *start* there, onto the first character of its text. See
2120 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2121 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2122 let mut off = off.min(self.source.len());
2123 let runs = self.run_span_ids();
2124 loop {
2125 let inner = self
2126 .editor
2127 .ancestors_at(off)
2128 .unwrap_or_default()
2129 .into_iter()
2130 .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2131 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2132 .filter(|&start| start > off)
2133 .min();
2134 match inner {
2135 Some(start) => off = start,
2136 None => return off,
2137 }
2138 }
2139 }
2140
2141 /// The ids of the document's attributed run spans — the inline
2142 /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2143 /// which sees an ancestor chain and so only a kind. Read once per gesture,
2144 /// not once per step of a walk.
2145 fn run_span_ids(&mut self) -> Vec<NodeId> {
2146 self.nodes()
2147 .iter()
2148 .filter(|n| wysiwyg::is_run_span(n))
2149 .map(|n| n.id)
2150 .collect()
2151 }
2152
2153 /// The attributed span whose text is exactly `content` — the whole of
2154 /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2155 /// and sits between the tags of `<span …></span>` — as the whole range
2156 /// spelling the span: the node's span, widened to its attribute block
2157 /// where the format writes that outside the node, as djot's
2158 /// `[i]{data-size="large"}` does. `None` for any other range, including
2159 /// part of a span's text.
2160 ///
2161 /// An empty span has an interior of no bytes, or no known interior at
2162 /// all: twig gives Markdown's `<span …></span>` the first and djot's
2163 /// `[]{…}` the second, and the chain already says the offset is inside.
2164 fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2165 let runs = self.run_span_ids();
2166 let m = self
2167 .editor
2168 .ancestors_at(content.start)
2169 .unwrap_or_default()
2170 .into_iter()
2171 .filter(|m| runs.contains(&NodeId(m.node_id)))
2172 .find(|m| match &m.content_span {
2173 Some(c) => *c == content,
2174 None => content.is_empty(),
2175 })?;
2176 let mut range = m.span;
2177 if let Some(attrs) = self
2178 .editor
2179 .document()
2180 .ok()
2181 .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2182 {
2183 range.start = range.start.min(attrs.start);
2184 range.end = range.end.max(attrs.end);
2185 }
2186 Some(range)
2187 }
2188
2189 /// The attributed block whose whole text is exactly `content` — the `T`
2190 /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2191 /// `{.center}\nT` — as the range a delete that takes that text takes with
2192 /// it: the whole `<div>` when the block is all the div holds, or the
2193 /// `{…}` line down to the end of the text. The block version of
2194 /// [`run_span_of_content`](Self::run_span_of_content), for the same
2195 /// reason: a paragraph with no text is no block, so the div would stand
2196 /// around nothing and the `{…}` line above nothing, and a from-scratch
2197 /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2198 /// `None` for a block with more text, a div holding more, a heading (an
2199 /// empty `# ` is still a heading), and a format whose attributes are the
2200 /// block's own tag (HTML's `<p class="center"></p>` is still a
2201 /// paragraph).
2202 fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2203 if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2204 return None;
2205 }
2206 let nodes = self.nodes();
2207 let block = nodes
2208 .iter()
2209 .filter(|n| n.kind == Kind::Para)
2210 .find(|n| n.content_span.as_ref() == Some(&content))?;
2211 match self.format {
2212 Format::Djot => {
2213 let attrs = self
2214 .editor
2215 .document()
2216 .ok()
2217 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2218 (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2219 }
2220 _ => {
2221 let div = block
2222 .parent
2223 .and_then(|p| nodes.iter().find(|n| n.id == p))
2224 .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2225 let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2226 alone.then(|| div.span.clone())
2227 }
2228 }
2229 }
2230
2231 /// The inline mark kinds whose span covers `off`, each with that span — the
2232 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2233 /// ids instead. Used to shed a mark by stepping past the end of its run.
2234 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2235 let off = off.min(self.source.len());
2236 self.editor
2237 .ancestors_at(off)
2238 .unwrap_or_default()
2239 .into_iter()
2240 .filter(|m| off < m.span.end)
2241 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2242 .collect()
2243 }
2244
2245 /// Insert clipboard `text` at the caret, replacing the selection if there is
2246 /// one — always its own undo step, whatever its length.
2247 ///
2248 /// Provenance is the whole point, and only the caller has it. `insert` reads
2249 /// a lone character as a keystroke and folds it into the run around it,
2250 /// which is right for typing and wrong for a one-character paste: that paste
2251 /// would vanish mid-run on an undo it was never part of, and the characters
2252 /// the user actually typed would go with it. Length can't tell the two
2253 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2254 /// caller comes through is what says which happened.
2255 pub fn paste(&mut self, text: &str) {
2256 // Pasting against a block picture or a table's end joins the block
2257 // exactly as typing does, and for the same reason — see
2258 // `open_paragraph_at_block_edge`.
2259 self.open_paragraph_at_block_edge(text);
2260 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2261 self.splice(s, e, text, EditKind::Other);
2262 }
2263
2264 /// Replace `[start, end)` with `text` as one step of an IME composition —
2265 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2266 /// folds into a single undo.
2267 ///
2268 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2269 /// dozen calls here, each replacing the last one's provisional bytes, and an
2270 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2271 /// unspools backwards through kana — the intermediate states were never text
2272 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2273 /// look like any other edit), so the door the caller comes through is what
2274 /// says so, exactly as it is for [`paste`](Self::paste) versus
2275 /// [`insert`](Self::insert).
2276 ///
2277 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2278 /// composition folds into this one.
2279 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2280 self.splice(start, end, text, EditKind::Compose);
2281 }
2282
2283 /// Close the open composition run, so the next one is its own undo step.
2284 /// Call when the IME commits or withdraws a composition.
2285 ///
2286 /// Only clears a *composition* run: a frontend that reports an end it never
2287 /// began (some IMEs unmark unprompted) would otherwise split the run of
2288 /// typing around it into two undo steps for no reason the user can see.
2289 pub fn end_composition(&mut self) {
2290 if self.last_edit_kind == Some(EditKind::Compose) {
2291 self.last_edit_kind = None;
2292 }
2293 }
2294
2295 // ── the clipboard's rich flavor ──────────────────────────────────────────
2296
2297 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2298 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2299 /// nothing is selected, or when the selection doesn't render (the caller
2300 /// still has [`selected_text`](Self::selected_text), which is what to publish
2301 /// as `text/plain` either way).
2302 ///
2303 /// **The fragment is a source substring, and that is the honest limit here.**
2304 /// It's parsed standalone, so a selection whose meaning depends on its
2305 /// surroundings converts as what it literally says rather than what it looks
2306 /// like on screen: half a list item is a paragraph, a row torn out of a table
2307 /// is the text of a row, the `**` of a bold run selected without its closing
2308 /// `**` is two asterisks. Every one of those still *renders* — there's no
2309 /// error to report — it just renders as the fragment and not as the document.
2310 /// Widening the range to whole blocks would publish text the user didn't
2311 /// select, which is a worse lie than a fragment being a fragment; the plain
2312 /// flavor has the same substring, so the two flavors at least agree.
2313 pub fn selection_html(&mut self) -> Option<String> {
2314 let (start, end) = self.selection()?;
2315 let inline = self.selection_is_inline(start, end);
2316 let html = html::render_fragment(&self.source[start..end], self.format)?;
2317 Some(match inline {
2318 true => html::strip_sole_paragraph(html),
2319 false => html,
2320 })
2321 }
2322
2323 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2324 /// format first. Its own undo step, like any [`paste`](Self::paste).
2325 ///
2326 /// Returns whether it landed. `false` means the HTML didn't convert to
2327 /// anything worth pasting — the caller should fall back to the plain flavor
2328 /// rather than treat it as an error. The `html` module has the full list of
2329 /// what that covers: a table twig won't build, markup it doesn't recognise,
2330 /// an empty result.
2331 pub fn paste_html(&mut self, html: &str) -> bool {
2332 match html::parse_fragment(html, self.format) {
2333 Some(source) => {
2334 self.paste(&source);
2335 true
2336 }
2337 None => false,
2338 }
2339 }
2340
2341 /// Does the selection live *inside* a single top-level block?
2342 ///
2343 /// The question [`selection_html`](Self::selection_html) needs and the
2344 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2345 /// whether the user selected one word of a sentence or a whole paragraph, and
2346 /// only the document knows which. Selecting a word and pasting into Docs
2347 /// should extend the line you paste into; selecting the paragraph should make
2348 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2349 /// is an artifact of standalone parsing), and one that covers a whole block —
2350 /// or spans two — keeps its structure.
2351 ///
2352 /// Reads the block from twig rather than guessing from the bytes:
2353 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2354 /// block containing an offset, and two ends inside the same one cannot have
2355 /// crossed a block boundary.
2356 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2357 // The last *character*, not `end - 1`: the selection's end is exclusive
2358 // and may sit mid-codepoint's-worth of bytes past the last char.
2359 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2360 return false;
2361 };
2362 let (Some(head), Some(tail)) =
2363 (self.top_block_span(start), self.top_block_span(start + off))
2364 else {
2365 return false;
2366 };
2367 head == tail && !(start <= head.start && end >= head.end)
2368 }
2369
2370 /// The byte span of the top-level block containing `offset`, or `None` at an
2371 /// offset that belongs to no block (the blank line between two of them).
2372 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2373 self.editor
2374 .ancestors_at(offset)
2375 .ok()?
2376 .get(1)
2377 .map(|m| m.span.clone())
2378 }
2379
2380 // ── indentation ──────────────────────────────────────────────────────────
2381
2382 /// One indent level.
2383 ///
2384 /// Two spaces, not the four both frontends type for Tab today, because in a
2385 /// markdown document four columns isn't a width — it's a *meaning*. Four
2386 /// spaces at the head of a line is markdown's indented-code-block marker, so
2387 /// one Tab on a paragraph would reparse it into code and style it as such;
2388 /// two cannot, and the line stays the prose it was. Two is also exactly
2389 /// where a `- ` bullet's content starts, so an indented line lands under its
2390 /// parent item's text instead of beside it — the column a list-aware indent
2391 /// has to hit anyway, which keeps this width from being relitigated later.
2392 const INDENT: &'static str = " ";
2393
2394 /// Indent the selected lines — or the caret's line, with no selection — by
2395 /// one level (Tab).
2396 pub fn indent(&mut self) {
2397 self.reindent(true);
2398 // Nesting changes an ordered list's numbering (the nested item restarts,
2399 // its old siblings resume) — keep the source markers in step.
2400 self.renumber_here();
2401 // Nesting an empty `-` item under a text line reparses that text as a
2402 // setext heading; swap the dash for a `*` before it can (a no-op unless
2403 // the collapse actually happened).
2404 self.avoid_setext_collapse();
2405 }
2406
2407 /// Take one indent level back off the selected lines, or the caret's line
2408 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2409 ///
2410 /// A line with *less* than a full level gives back what it has rather than
2411 /// refusing: outdent's job is to walk a line left, and real documents — hand
2412 /// written, or reflowed by some other editor — are full of indentation that
2413 /// was never a clean multiple of anything. Refusing there would strand the
2414 /// line at a depth Shift+Tab couldn't undo.
2415 pub fn outdent(&mut self) {
2416 self.reindent(false);
2417 self.renumber_here();
2418 }
2419
2420 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2421 ///
2422 /// One splice across the whole line range, never one per line: a Tab is one
2423 /// thing the user did, so it has to be one undo step and one reparse. Per
2424 /// line, twig would reparse the document once per line and leave a stack of
2425 /// steps that Shift+⌘Z walks back one line at a time.
2426 fn reindent(&mut self, add: bool) {
2427 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2428 let start = source_line_range(&self.source, sel_start).start;
2429 let end = source_line_range(&self.source, sel_end).end;
2430 let region = self.source[start..end].to_string();
2431 let lines: Vec<&str> = region.split('\n').collect();
2432 // A blank line has no text to move, and padding it would leave nothing
2433 // but trailing whitespace — but Tab on a blank line *is* a request for
2434 // indentation to type into, so the skip only applies where the op has
2435 // other lines to do real work on.
2436 let skip_blank = add && lines.len() > 1;
2437
2438 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2439 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2440 let mut line_off = start;
2441 for (i, full) in lines.iter().enumerate() {
2442 if i > 0 {
2443 out.push('\n');
2444 }
2445 // A list item moves by having its whole leading prefix *replaced*,
2446 // never by having spaces pushed in front of the line. twig spells
2447 // both prefixes, so the quote markers, the parent's indent and an
2448 // ordered marker's extra column all come out right without leaf
2449 // measuring any of them — and a line that only looks like an item
2450 // (a Djot continuation) reports no marker and is left to the plain
2451 // path, where a Tab is just a Tab.
2452 let marker = self.list_marker_on_line(line_off);
2453 let own = marker
2454 .as_ref()
2455 .map(|m| m.marker_start - m.line_start)
2456 .unwrap_or(0);
2457 let delta = if add {
2458 if skip_blank && full.trim().is_empty() {
2459 out.push_str(full);
2460 0
2461 } else if marker.is_some() && self.first_item_of_list(line_off) {
2462 // The first item of a list has no preceding sibling to nest
2463 // under, so a Tab here can't spell a sub-list — twig would
2464 // reparse the shoved-over marker as the same list, only
2465 // indented, which Shift+Tab then can't cleanly undo. Leave the
2466 // item where it is, the way every list editor refuses to
2467 // over-indent a list's first line.
2468 out.push_str(full);
2469 0
2470 } else if marker.is_some() {
2471 // Nesting means standing where a *continuation* of this line
2472 // would stand: past the parent's marker, inside its content
2473 // column. That is `continuation_prefix`, less a checkbox.
2474 let new = self.nesting_prefix_at(line_off);
2475 let delta = new.len() as isize - own as isize;
2476 out.push_str(&new);
2477 out.push_str(&full[own..]);
2478 delta
2479 } else {
2480 out.push_str(Self::INDENT);
2481 out.push_str(full);
2482 Self::INDENT.len() as isize
2483 }
2484 } else if marker.is_some() {
2485 // Unnesting is the mirror: stand where the parent item's own
2486 // line starts, which drops exactly the level it contributed.
2487 let new = self.outdent_prefix_at(line_off);
2488 let delta = new.len() as isize - own as isize;
2489 out.push_str(&new);
2490 out.push_str(&full[own..]);
2491 delta
2492 } else {
2493 // A plain line gives back the ordinary step.
2494 let strip = outdent_width(full, Self::INDENT.len());
2495 out.push_str(&full[strip..]);
2496 -(strip as isize)
2497 };
2498 deltas.push(delta);
2499 line_off += full.len() + 1;
2500 }
2501 // Nothing to give back. Returning before the splice keeps an outdent at
2502 // column zero from spending an undo step on a document it never changed.
2503 if deltas.iter().all(|d| *d == 0) {
2504 return;
2505 }
2506
2507 // Every line's text keeps its offset *within the line*, so the caret is
2508 // remapped by its column, not by its byte offset — which the prefixes on
2509 // the lines above it have already invalidated.
2510 let remap = |off: usize| -> usize {
2511 let (mut old_ls, mut new_ls) = (start, start);
2512 for (line, delta) in lines.iter().zip(&deltas) {
2513 let old_le = old_ls + line.len();
2514 let new_len = (line.len() as isize + delta) as usize;
2515 if off <= old_le {
2516 let col = (off - old_ls) as isize;
2517 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2518 }
2519 old_ls = old_le + 1;
2520 new_ls += new_len + 1;
2521 }
2522 start + out.len()
2523 };
2524 let placed = match self.selection() {
2525 // Keep the rewritten region selected, the way a container toggle
2526 // keeps its own: it leaves a second Tab aimed at the same lines
2527 // rather than at whatever the shifted offsets now happen to cover.
2528 Some(_) => (start + out.len(), Some(start)),
2529 None => (remap(self.caret), None),
2530 };
2531
2532 // A rolled-back splice leaves the old source in place, where every offset
2533 // computed above addresses text that was never written.
2534 if !self.splice(start, end, &out, EditKind::Other) {
2535 return;
2536 }
2537 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2538 // rewrite is the last line's end — nowhere the caret was. Place it, then
2539 // re-record the caret so this is the state redo restores, not the one
2540 // `splice` left behind from the `Change`.
2541 self.caret = placed.0.min(self.source.len());
2542 self.anchor = placed.1;
2543 self.clamp_caret();
2544 self.record_caret();
2545 }
2546
2547 /// The Enter key.
2548 ///
2549 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2550 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2551 /// caret is in decides what actually gets written.
2552 ///
2553 /// - paragraph → twig's [`Editor::split_block`], which parts the
2554 /// block at the caret and reopens its container
2555 /// - list item → likewise: the next item, its indent, quote
2556 /// prefix and `[ ]` box all reproduced by twig —
2557 /// except an *empty* item, which exits the list
2558 /// - block quote → likewise: a new paragraph inside the quote
2559 /// - heading → a new *paragraph*, not another heading
2560 /// - code block → a literal newline (stay in the block)
2561 /// - blank line → a literal newline (one Backspace undoes it)
2562 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2563 /// visible line
2564 ///
2565 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2566 /// and it is better at it: it drops the whitespace the caret was sitting in
2567 /// front of instead of stranding it at the head of the second half, and it
2568 /// knows continuations leaf's marker scan never covered — a checklist item
2569 /// continues as an *unchecked* checklist item rather than a plain bullet.
2570 ///
2571 /// The exceptions above are exceptions because `split_block` is either wrong
2572 /// there or refuses: parting a fence yields two fences with the code split
2573 /// between them, parting a heading yields a second heading where every editor
2574 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2575 /// table all report an error rather than a split.
2576 pub fn newline(&mut self) {
2577 if self.view == View::Source {
2578 self.insert_raw("\n");
2579 return;
2580 }
2581 // Enter over a selection replaces it with a paragraph break.
2582 if let Some((s, e)) = self.selection() {
2583 self.splice(s, e, "\n\n", EditKind::Other);
2584 return;
2585 }
2586 // A caret resting exactly between an inline mark's content and its own
2587 // closing delimiter (`**bold**` with nothing after it on the line —
2588 // the WYSIWYG caret's natural end-of-line position) must not splice a
2589 // block break there: every path below eventually does via
2590 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2591 // delimiter would strand it alone on the new line.
2592 self.caret = self.skip_trailing_close_delims(self.caret);
2593 // The block the caret is in. `block_offset_for_caret` nudges off a line
2594 // end (where the caret sits at the doc level); on a bare line (e.g. an
2595 // empty list item) fall back to the caret so the enclosing list/quote is
2596 // still visible in the ancestors.
2597 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2598 let kinds: Vec<Kind> = self
2599 .editor
2600 .ancestors_at(off)
2601 .map(|c| c.into_iter().map(|m| m.kind).collect())
2602 .unwrap_or_default();
2603 let has = |k: Kind| kinds.contains(&k);
2604
2605 if has(Kind::CodeBlock) {
2606 self.insert_raw("\n");
2607 return;
2608 }
2609 // An *empty* list item exits the list — the standard double-Enter — which
2610 // `split_block` reports as an error rather than a split (there is no
2611 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2612 // the AST gate — it answers from the tree, so a `- ` that reads as a
2613 // marker byte-for-byte but opens no item (a setext underline, a Djot
2614 // continuation line) never reaches here.
2615 if let Some(marker) = self.list_marker_on_line(self.caret)
2616 && self.item_is_empty(&marker)
2617 {
2618 self.exit_list(&marker);
2619 return;
2620 }
2621 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2622 // not another full paragraph break — so it moves down one line and one
2623 // Backspace undoes it, not two. (`split_block` errors here too.)
2624 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2625 let line_end = self.source[self.caret..]
2626 .find('\n')
2627 .map_or(self.source.len(), |i| self.caret + i);
2628 if self.source[line_start..line_end].trim().is_empty() {
2629 self.insert_raw("\n");
2630 return;
2631 }
2632 // In `Preserve` flow a soft break is a *visible* line the author means to
2633 // make, so Enter writes a single `\n` and typing continues the same
2634 // paragraph on the next line — the behaviour of an ordinary text editor.
2635 // A second Enter then lands on the blank line above and takes the
2636 // empty-line branch, so double-Enter still promotes to a full paragraph
2637 // break; and Backspace, which deletes a lone `\n` over a soft break,
2638 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2639 // render as an invisible space, so Enter keeps making the paragraph break
2640 // that actually shows.
2641 //
2642 // Only in running prose. A list or a quote has a continuation of its own
2643 // to write, and a `\n` there is not a soft line but a lost container.
2644 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2645 if self.line_flow == LineFlow::Preserve && !in_container {
2646 self.insert_raw("\n");
2647 return;
2648 }
2649 // A heading gets a *paragraph*, never a second heading: Enter at the end
2650 // of a title is how every editor is asked for the body under it, and
2651 // `split_block` would repeat the `#` instead. Whitespace at the split
2652 // point goes with the break rather than opening the new paragraph, which
2653 // is what `split_block` does everywhere else.
2654 if has(Kind::Heading) {
2655 let mut end = self.caret;
2656 while self.source.as_bytes().get(end) == Some(&b' ') {
2657 end += 1;
2658 }
2659 self.splice(self.caret, end, "\n\n", EditKind::Other);
2660 return;
2661 }
2662 self.split_block_here();
2663 }
2664
2665 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2666 /// the caret in the second half.
2667 ///
2668 /// twig reopens whatever the first half was inside of — the bullet with its
2669 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2670 /// reason this replaced the markup leaf used to spell from the line's bytes.
2671 /// It renumbers nothing, though: a new item mid-list is written with its
2672 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2673 /// behind it, folded into the same undo step.
2674 ///
2675 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2676 /// shape still moves the caret down rather than swallowing the keystroke.
2677 fn split_block_here(&mut self) {
2678 // The read-only gate — this door reaches twig without the splice.
2679 if self.read_only {
2680 return;
2681 }
2682 match self.editor.split_block(self.caret) {
2683 Ok(change) => {
2684 self.last_edit_kind = None;
2685 self.refresh();
2686 self.anchor = None;
2687 self.caret = change.new.end;
2688 self.dirty = self.source != self.clean_source;
2689 self.status = None;
2690 self.clamp_caret();
2691 self.record_caret();
2692 // Aimed at the new block's *start*: the caret twig leaves is one
2693 // past the marker it wrote, where there is no list in reach.
2694 self.renumber_at(change.new.start);
2695 }
2696 Err(_) => self.insert_raw("\n\n"),
2697 }
2698 }
2699
2700 /// Whether the item on the marker's line carries no content — the shape
2701 /// double-Enter reads as "I'm done with this list."
2702 fn item_is_empty(&self, line: &ListMarker) -> bool {
2703 let content_start = line.content_start().min(self.source.len());
2704 let line_end = self.source[self.caret..]
2705 .find('\n')
2706 .map(|i| self.caret + i)
2707 .unwrap_or(self.source.len());
2708 self.source[content_start..line_end.max(content_start)]
2709 .trim()
2710 .is_empty()
2711 }
2712
2713 /// Leave the list: replace the empty item's marker with a blank line, so the
2714 /// caret lands in a fresh paragraph below it.
2715 ///
2716 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2717 /// quote), and the caret's new line keeps the `> ` it was already behind —
2718 /// leaving the list without also leaving the quote.
2719 fn exit_list(&mut self, line: &ListMarker) {
2720 let prefix = self.quote_prefix_at(line.marker_start);
2721 let blank = prefix.trim_end();
2722 self.splice(
2723 line.line_start,
2724 self.caret,
2725 &format!("{blank}\n{prefix}"),
2726 EditKind::Other,
2727 );
2728 }
2729
2730 /// What a line continuing the containers at `off` has to open with — the
2731 /// quote markers reproduced, each enclosing item's marker as its width in
2732 /// spaces. Also the column a nested item's marker stands in, which is what
2733 /// makes it Tab's answer.
2734 fn continuation_prefix_at(&mut self, off: usize) -> String {
2735 self.editor
2736 .document()
2737 .and_then(|mut d| d.continuation_prefix(off))
2738 .map(|p| p.text)
2739 .unwrap_or_default()
2740 }
2741
2742 /// The column a *nested list* may open at inside the item at `off` — which
2743 /// is not always where the item's own text continues.
2744 ///
2745 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2746 /// it is markup a rich view hides, and the item's own wrapped text does
2747 /// stand past it. But a nested list may only open at the *list* marker's
2748 /// column, and four columns further in is an indented continuation of the
2749 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2750 /// So the box's own width goes back.
2751 ///
2752 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2753 /// reports the list marker's column apart from the box; `checked` is what
2754 /// says a box is there at all, so only its width is being measured here.
2755 fn nesting_prefix_at(&mut self, off: usize) -> String {
2756 let cont = self.continuation_prefix_at(off);
2757 let Some(item) = self.innermost_list_item(off) else {
2758 return cont;
2759 };
2760 if item.checked.is_none() {
2761 return cont;
2762 }
2763 let box_width = item
2764 .marker_span
2765 .and_then(|m| self.source.get(m))
2766 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2767 .unwrap_or(0);
2768 // The trailing columns are the ones the item's own marker contributed,
2769 // so trimming from the end leaves any quote prefix standing.
2770 cont[..cont.len().saturating_sub(box_width)].to_string()
2771 }
2772
2773 /// Where the line of the item *containing* the item at `off` begins — the
2774 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2775 /// parent contributed. The quote prefix alone for a top-level item, which
2776 /// has no level left to give.
2777 fn outdent_prefix_at(&mut self, off: usize) -> String {
2778 let items: Vec<usize> = self
2779 .editor
2780 .document()
2781 .and_then(|mut d| d.ancestors_at_caret(off))
2782 .map(|c| {
2783 c.into_iter()
2784 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2785 .map(|m| m.span.start)
2786 .collect()
2787 })
2788 .unwrap_or_default();
2789 // The second-innermost item is the parent; its own line's indent is the
2790 // target. `list_marker_on_line` gives that line's prefix directly.
2791 let parent = items.len().checked_sub(2).map(|i| items[i]);
2792 match parent.and_then(|p| self.list_marker_on_line(p)) {
2793 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2794 None => self.quote_prefix_at(off),
2795 }
2796 }
2797
2798 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2799 /// inside one, `"> > "` inside two.
2800 ///
2801 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2802 /// the `>` and the space after it are twig's spelling rather than leaf's.
2803 /// The whole line prefix can't answer this: it also carries the indent of
2804 /// whatever the quote holds, which a blank separator line must *not* repeat.
2805 fn quote_prefix_at(&mut self, off: usize) -> String {
2806 let Ok(chain) = self
2807 .editor
2808 .document()
2809 .and_then(|mut d| d.ancestors_at_caret(off))
2810 else {
2811 return String::new();
2812 };
2813 let quotes: Vec<usize> = chain
2814 .iter()
2815 .filter(|m| m.kind == Kind::BlockQuote)
2816 .map(|m| m.node_id as usize)
2817 .collect();
2818 let Ok(nodes) = self.editor.nodes() else {
2819 return String::new();
2820 };
2821 quotes
2822 .iter()
2823 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2824 .filter_map(|s| self.source.get(s))
2825 .collect()
2826 }
2827
2828 /// Whether the item at `off` sits inside another one — the test Backspace
2829 /// uses to choose between outdenting and dropping the marker.
2830 ///
2831 /// Counted from the AST rather than from the line's leading whitespace,
2832 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2833 fn item_is_nested(&mut self, off: usize) -> bool {
2834 self.editor
2835 .document()
2836 .and_then(|mut d| d.ancestors_at_caret(off))
2837 .map(|c| {
2838 c.into_iter()
2839 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2840 .count()
2841 > 1
2842 })
2843 .unwrap_or(false)
2844 }
2845
2846 /// The innermost list item containing `probe`, under twig's **caret**
2847 /// containment rule — a block's end is inside it.
2848 ///
2849 /// Half-open containment can't answer this. An empty item's span is exactly
2850 /// its marker, so the caret sitting after `- ` is one past the end and the
2851 /// item it is plainly in tests as out of reach; that is the shape
2852 /// double-Enter has to recognise to leave the list.
2853 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2854 let chain = self
2855 .editor
2856 .document()
2857 .and_then(|mut d| d.ancestors_at_caret(probe))
2858 .ok()?;
2859 let id = chain
2860 .iter()
2861 .rev()
2862 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2863 .node_id as usize;
2864 self.editor.nodes().ok()?.get(id).cloned()
2865 }
2866
2867 /// The list marker opening `off`'s line, per twig — `None` when that line
2868 /// opens no list item.
2869 ///
2870 /// [`Document::line_prefix`] is the whole hidden run from the line start:
2871 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
2872 /// and it is `None` on a *continuation* line, which opens nothing. That last
2873 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
2874 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2875 /// a paragraph and ` - b` is literal text — identical bytes, and only the
2876 /// parser knows which document it is looking at.
2877 ///
2878 /// The item's own marker is separated out via its
2879 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2880 /// the containers around it contribute and what the item does.
2881 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2882 let off = off.min(self.source.len());
2883 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2884 // The prefix belongs to a list only when an item's marker closes it —
2885 // a heading's `# ` or a bare quote's `> ` is a prefix too.
2886 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2887 let marker = item.marker_span.clone()?;
2888 if marker.end != prefix.end {
2889 return None;
2890 }
2891 Some(ListMarker {
2892 line_start: prefix.start,
2893 marker_start: marker.start,
2894 text: self.source.get(prefix)?.to_string(),
2895 })
2896 }
2897
2898 /// Whether the list item on `line_start`'s line is the **first item** of its
2899 /// list — the one Tab must not nest, because nesting needs a preceding
2900 /// sibling to become the new parent and a first item has none. `false` for a
2901 /// line that isn't a list item, and for an item with a sibling above it (the
2902 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2903 /// the same in a setext underline that opens no list at all.
2904 fn first_item_of_list(&mut self, line_start: usize) -> bool {
2905 let Some(marker) = self.list_marker_on_line(line_start) else {
2906 return false;
2907 };
2908 // Probe just inside the marker, where the item's own node is in reach —
2909 // the marker offset itself can resolve to the enclosing list, not the
2910 // `list_item`, whose span starts at the marker.
2911 let probe = marker.content_start().min(self.source.len());
2912 let Some(item) = self.innermost_list_item(probe) else {
2913 return false;
2914 };
2915 let Ok(nodes) = self.editor.nodes() else {
2916 return false;
2917 };
2918 match item.parent {
2919 // First when the parent list opens with this very item.
2920 Some(pid) => nodes
2921 .get(pid.0 as usize)
2922 .is_some_and(|p| p.first_child == Some(item.id)),
2923 // A parentless item is trivially the first (and only) one.
2924 None => true,
2925 }
2926 }
2927
2928 pub fn backspace(&mut self) {
2929 if let Some((s, e)) = self.selection() {
2930 self.splice(s, e, "", EditKind::Other);
2931 return;
2932 }
2933 // WYSIWYG: Backspace at the very start of a list item's content is a
2934 // structural key, not a character delete — it walks the "un-indent, then
2935 // un-list" ladder every list editor gives that keystroke (outdent a
2936 // nested item, strip a top-level one's marker to a paragraph). In source
2937 // view the `- ` is visible text the user is deleting a byte of, so it
2938 // keeps its literal meaning there, like Enter does.
2939 if self.view != View::Source && self.backspace_list_start() {
2940 return;
2941 }
2942 // WYSIWYG: and the same at the start of a heading's content — the `# `
2943 // there is markup the rich view hides, not text the user typed.
2944 if self.view != View::Source && self.backspace_heading_start() {
2945 return;
2946 }
2947 // WYSIWYG: and at the start of a block whose presentation is spelled
2948 // as hidden markup before it — djot's `{.center}` line, Markdown's
2949 // `<div class="center">` — Backspace takes that markup, the way it
2950 // takes a heading's `#`, rather than a byte out of it.
2951 if self.view != View::Source && self.backspace_attributed_block_start() {
2952 return;
2953 }
2954 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2955 // the markup apart under a caret that cannot see it — see
2956 // `delete_around_block_media`.
2957 if self.view != View::Source && self.delete_around_block_media(false) {
2958 return;
2959 }
2960 // WYSIWYG: Backspace at a table's trailing stop steps back into its last
2961 // cell rather than taking the byte behind the caret — the row's closing
2962 // `|`, which the rich view never drew, so the key would have looked like
2963 // it did nothing. The stop before is the last cell's end.
2964 if self.view != View::Source && self.backspace_at_table_end() {
2965 return;
2966 }
2967 // WYSIWYG: at the start of a block's content, the byte behind the caret
2968 // is a block boundary, and Backspace over one is a join — twig's, so
2969 // that what a join is in each format is not this file's to know. After
2970 // the picture and table cases, which are block starts with their own
2971 // answers.
2972 if self.view != View::Source && self.backspace_joins_block() {
2973 return;
2974 }
2975 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2976 // stop, not a single newline. On a line with no text of its own, the byte
2977 // before the caret is a `\n` that spells part of a block boundary — the gap
2978 // between two blocks, drawn but never a caret home. Removing just it strands
2979 // the caret in that gap and leaves an odd blank line the eye reads as one
2980 // separator but the caret can't land on: the "extra newline" left behind
2981 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2982 // Deleting to the previous stop instead collapses the whole break at once,
2983 // landing the caret at the end of the block above. Two blank lines in a row
2984 // are one stop apart, so this still removes exactly one — the lone-Enter /
2985 // lone-Backspace symmetry the empty-line case is built on is untouched.
2986 if self.view != View::Source
2987 && self.caret > self.caret_floor()
2988 && self.caret_on_blank_line()
2989 && let Some(stop) = self.vmap.stop_before(self.caret)
2990 {
2991 let stop = stop.max(self.caret_floor());
2992 if stop < self.caret {
2993 if self.source[stop..self.caret].trim().is_empty() {
2994 self.splice(stop, self.caret, "", EditKind::Delete);
2995 } else {
2996 // Hidden markup stands between the stop and the caret — a
2997 // `</div>`, a comment, a link reference definition — and
2998 // collapsing to the stop would delete it. Take the blank
2999 // line alone, with the newline that opened it, and land
3000 // the caret where the collapse would have.
3001 self.delete_blank_line_to(stop);
3002 }
3003 return;
3004 }
3005 }
3006 if self.caret > self.caret_floor() {
3007 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3008 // takes the whole tag — a single-byte step would leave a broken `<br`
3009 // showing in the cell. Rich view only (source view edits the literal).
3010 if self.view != View::Source
3011 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3012 {
3013 let start = start.max(self.caret_floor());
3014 if start < end {
3015 self.splice(start, end, "", EditKind::Delete);
3016 return;
3017 }
3018 }
3019 // Aim the delete at the character the writer can *see* behind the
3020 // caret, never at a delimiter the rich view drew nothing for. Two
3021 // steps, and either can apply: from the far side of a run's closing
3022 // `**` step back into the run (the caret is drawn at the end of its
3023 // word), and at the start of a run's text step out past its opening
3024 // `**` to the character in front of it, leaving the run standing.
3025 // Without them a plain Backspace unspells the phrase it is editing
3026 // and leaves a literal asterisk on screen.
3027 let end = if self.view == View::Source {
3028 self.caret
3029 } else {
3030 let inside = self.step_inside_close_delims(self.caret);
3031 // An attributed span with no text — `<span …></span>` as the
3032 // file was written — is hidden markup around nothing, and a
3033 // byte-step here would take its `>`. Backspace takes the span
3034 // whole, with the character before it: the character the key
3035 // looks aimed at, since the span draws nothing.
3036 if let Some(span) = self.run_span_of_content(inside..inside) {
3037 let from = if self.source[..span.start].ends_with('\n') {
3038 span.start
3039 } else {
3040 prev_boundary(&self.source, span.start)
3041 };
3042 let from = from.max(self.caret_floor());
3043 self.splice(from, span.end, "", EditKind::Delete);
3044 return;
3045 }
3046 self.skip_leading_open_delims(inside)
3047 .max(self.caret_floor())
3048 };
3049 // Never delete back across the floor — that would eat hidden
3050 // frontmatter the WYSIWYG caret can't even see.
3051 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3052 // Take a hidden escape backslash with the char it escapes: the rich
3053 // view draws `\*` as a single `*`, so Backspace over it must delete
3054 // both bytes, never strand the `\` as a lone visible backslash (the
3055 // mirror of the Hidden-mode typing that wrote the escape). Source view
3056 // shows the `\`, so there it is an ordinary character.
3057 if self.view != View::Source
3058 && prev > self.caret_floor()
3059 && self.is_hidden_escape(prev - 1)
3060 {
3061 prev -= 1;
3062 }
3063 // The delete that takes the last of a span's text takes the span
3064 // with it, in the same edit: `<span …>i</span>` losing its `i`
3065 // would leave an empty span the map has no stop inside, so the
3066 // caret would draw at the next stop — a line away — until a
3067 // further key removed the span. Landing on the span's start is
3068 // where the letter was.
3069 if self.view != View::Source
3070 && let Some(span) = self.run_span_of_content(prev..end)
3071 {
3072 self.splice(span.start, span.end, "", EditKind::Delete);
3073 return;
3074 }
3075 // And the same for a block: the letter that was all of a centred
3076 // paragraph's text goes with the `<div>` around it, or the `{…}`
3077 // line above it, leaving a plain blank line where the letter was.
3078 // A paragraph with no text is no block, so the markup would stand
3079 // around nothing, the map would give it no caret home, and the
3080 // next key would take the tag apart.
3081 if self.view != View::Source
3082 && let Some(block) = self.attributed_block_of_content(prev..end)
3083 {
3084 self.splice(block.start, block.end, "", EditKind::Delete);
3085 return;
3086 }
3087 if prev < end {
3088 self.splice(prev, end, "", EditKind::Delete);
3089 }
3090 }
3091 }
3092
3093 /// Remove the blank line the caret is on — its own newline and the one
3094 /// that ended the line before it — and put the caret on `stop`, the caret
3095 /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3096 /// blank line that hidden markup separates from the block above: the
3097 /// navigable blank row after a `</div>` is always one of at least three
3098 /// newlines under the tag (the drawn separators either side of it), so
3099 /// taking two leaves the blank line the tag needs under it.
3100 fn delete_blank_line_to(&mut self, stop: usize) {
3101 let caret = self.caret;
3102 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3103 let line_end = self.source[caret..]
3104 .find('\n')
3105 .map_or(self.source.len(), |i| caret + i);
3106 let from = line_start.saturating_sub(1).max(stop);
3107 let to = (line_end + 1).min(self.source.len());
3108 self.splice(from, to, "", EditKind::Delete);
3109 self.caret = stop;
3110 self.anchor = None;
3111 self.goal_col = None;
3112 self.record_caret();
3113 }
3114
3115 /// Move the caret to the stop before it and consume the key — what
3116 /// Backspace does where the byte behind the caret is hidden markup it
3117 /// has no structural answer for, rather than take that markup apart.
3118 fn step_back_to_stop(&mut self) {
3119 // The map answers about offsets, so it has to be this revision's — see
3120 // `open_paragraph_at_block_edge`.
3121 self.rebuild_map();
3122 if let Some(off) = self
3123 .vmap
3124 .stop_before(self.caret)
3125 .filter(|&o| o >= self.caret_floor())
3126 {
3127 self.caret = off;
3128 self.anchor = None;
3129 self.goal_col = None;
3130 }
3131 }
3132
3133 /// Backspace's presentation behaviour: with the caret exactly at the start
3134 /// of a block's content, and that block's attributes spelled as hidden
3135 /// markup before it, strip the attributes. The peer of
3136 /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3137 /// reasoning: the `{.center}` line above a djot block and the
3138 /// `<div class="center">` around a Markdown one are what the byte behind
3139 /// the caret belongs to, and the rich view draws neither. The ordinary
3140 /// delete took the newline out of `{.center}\nhello` and left
3141 /// `{.center}hello` — the attribute line fused onto the text as prose —
3142 /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3143 ///
3144 /// The whole attribute set goes, the way the whole `#` marker does — the
3145 /// press is over the line that spells it, not over one key of it — and
3146 /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3147 /// djot line and unwraps the Markdown div. Where the block is the first of
3148 /// several in a div, twig has no sole child to unwrap and answers with a
3149 /// no-op, so the caret steps back to the stop before instead, as it does
3150 /// at a table's end. A later child of the div has an ordinary paragraph
3151 /// above it and is not this rule's.
3152 ///
3153 /// Returns whether it acted; `false` leaves Backspace its character delete.
3154 fn backspace_attributed_block_start(&mut self) -> bool {
3155 if !matches!(self.format, Format::Markdown | Format::Djot) {
3156 return false;
3157 }
3158 let caret = self.caret;
3159 let nodes = self.nodes();
3160 let Some(block) = nodes
3161 .iter()
3162 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3163 .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3164 else {
3165 return false;
3166 };
3167 match self.format {
3168 Format::Djot => {
3169 // twig records where the `{…}` block was written, so this is
3170 // the parser's own answer and not a scan for a `{` above the
3171 // block; `None` (a synthesized or merged set) is not a line
3172 // the caret is standing after.
3173 let spelled = self
3174 .editor
3175 .document()
3176 .ok()
3177 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3178 .is_some_and(|s| s.end <= caret);
3179 if !spelled {
3180 return false;
3181 }
3182 }
3183 _ => {
3184 let Some(div) = block
3185 .parent
3186 .and_then(|p| nodes.iter().find(|n| n.id == p))
3187 .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3188 else {
3189 return false;
3190 };
3191 let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3192 if kids.clone().any(|k| k.span.start < block.span.start) {
3193 return false;
3194 }
3195 if kids.nth(1).is_some() {
3196 self.step_back_to_stop();
3197 return true;
3198 }
3199 }
3200 }
3201 self.write_block_attrs("block attributes", Vec::new());
3202 true
3203 }
3204
3205 /// Backspace at the start of a block's content: join the block into the
3206 /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3207 /// the split Enter makes, spelled the format's way. Two paragraphs join
3208 /// on a soft break; a paragraph under a marker heading joins onto the
3209 /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3210 /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3211 /// taken as one; a quote's or an item's continuation prefix is written.
3212 /// The joined text takes the block above's presentation and containers,
3213 /// which is the rule every editor with a centred paragraph follows.
3214 ///
3215 /// Leaf used to join by deleting the one newline behind the caret, which
3216 /// is the right bytes for two Markdown paragraphs and nothing else: under
3217 /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3218 /// after a div it took the newline under the hidden `</div>`, which drew
3219 /// nothing different and took the tag apart on the next press. What a
3220 /// join is in each format is twig's to know, and now it does.
3221 ///
3222 /// Where twig refuses — the block above is a code block, a table or a
3223 /// rule with no text to join into, or the caret's block would have to
3224 /// leave a div that holds more after it — the caret steps back to the
3225 /// stop before instead, as it does at a table's end: the key moves the
3226 /// caret and takes no markup apart. Where nothing precedes the block, or
3227 /// the format cannot join at all, Backspace keeps its character delete.
3228 ///
3229 /// Returns whether it acted.
3230 fn backspace_joins_block(&mut self) -> bool {
3231 let caret = self.caret;
3232 if caret <= self.caret_floor() {
3233 return false;
3234 }
3235 let Some(text) = self.text_block_opening_at(caret) else {
3236 return false;
3237 };
3238 match self.join_blocks(caret) {
3239 Ok(change) => {
3240 // The caret keeps its place at the start of the text it stood
3241 // on, wherever the join put that text — after a soft break,
3242 // a space, or a quote's prefix. Found by the bytes, as
3243 // `block_content_in` finds a re-spelled block.
3244 let region = &self.source[change.new.clone()];
3245 let at = region
3246 .find(&text)
3247 .map_or(change.new.start, |i| change.new.start + i);
3248 self.land_after_join(at);
3249 true
3250 }
3251 Err(twig::Error::NotEditable) => {
3252 self.step_back_to_stop();
3253 true
3254 }
3255 Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3256 Err(e) => {
3257 self.status = Some(format!("join: {e}"));
3258 true
3259 }
3260 }
3261 }
3262
3263 /// Delete at the end of a block's content: join the block after it into
3264 /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3265 /// mirror, and the same twig gesture aimed at the next block. The caret
3266 /// stays where it was, which is where the joined text now begins after
3267 /// the separator. Where twig refuses, the caret steps forward to the next
3268 /// stop instead; where no block follows, Delete keeps its character
3269 /// delete.
3270 fn delete_forward_joins_block(&mut self) -> bool {
3271 let caret = self.caret;
3272 let at_end = self
3273 .nodes()
3274 .iter()
3275 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3276 .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3277 if !at_end {
3278 return false;
3279 }
3280 // The next stop, across a line end, in a block: what Delete at a
3281 // block's end points at. On the same line it is a hidden delimiter's
3282 // far side, which the ordinary delete handles; on a blank line it is
3283 // the empty paragraph the byte delete has always closed.
3284 self.rebuild_map();
3285 let Some(stop) = self.vmap.stop_after(caret) else {
3286 return false;
3287 };
3288 if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3289 return false;
3290 }
3291 match self.join_blocks(stop) {
3292 Ok(change) => {
3293 self.land_after_join(change.old.start);
3294 true
3295 }
3296 Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3297 self.caret = stop;
3298 self.anchor = None;
3299 self.goal_col = None;
3300 true
3301 }
3302 Err(twig::Error::UnsupportedFormat) => false,
3303 Err(e) => {
3304 self.status = Some(format!("join: {e}"));
3305 true
3306 }
3307 }
3308 }
3309
3310 /// The content bytes of the paragraph or heading whose content opens
3311 /// exactly at `off` — the block a Backspace there is at the start of.
3312 fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3313 self.nodes()
3314 .into_iter()
3315 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3316 .find_map(|n| {
3317 let c = n.content_span?;
3318 (c.start == off).then(|| self.source[c].to_string())
3319 })
3320 }
3321
3322 /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3323 /// plumbing every structural gesture has; the caret is the caller's to
3324 /// place from the change, via [`land_after_join`](Self::land_after_join).
3325 fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3326 if self.read_only {
3327 return Err(twig::Error::NotEditable);
3328 }
3329 self.record_caret();
3330 let change = self.editor.join_blocks(offset)?;
3331 self.last_edit_kind = None; // structural edit is its own undo step
3332 self.refresh();
3333 Ok(change)
3334 }
3335
3336 /// Finish a join: the caret at `at`, no selection, the map this
3337 /// revision's before the clamp — see `write_block_attrs` for why.
3338 fn land_after_join(&mut self, at: usize) {
3339 self.caret = at;
3340 self.anchor = None;
3341 self.goal_col = None;
3342 self.dirty = self.source != self.clean_source;
3343 self.status = None;
3344 self.rebuild_map();
3345 self.clamp_caret();
3346 self.record_caret();
3347 }
3348
3349 /// Backspace at a table's trailing stop: move onto the stop before it (the
3350 /// last cell's end) and consume the key. `false` anywhere else. See
3351 /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3352 /// not one to delete.
3353 fn backspace_at_table_end(&mut self) -> bool {
3354 // The map answers about offsets, so it has to be this revision's — see
3355 // `open_paragraph_at_block_edge`.
3356 self.rebuild_map();
3357 if !self.vmap.table_end_stop(self.caret) {
3358 return false;
3359 }
3360 if let Some(off) = self
3361 .vmap
3362 .stop_before(self.caret)
3363 .filter(|&o| o >= self.caret_floor())
3364 {
3365 self.caret = off;
3366 self.anchor = None;
3367 self.goal_col = None;
3368 }
3369 true
3370 }
3371
3372 /// Whether the caret's own source line holds nothing but whitespace — an
3373 /// empty paragraph, or the blank line a block boundary is spelled with. The
3374 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3375 /// no text of its own, so the newline before the caret belongs to the gap
3376 /// between blocks rather than to any word the caret is editing.
3377 fn caret_on_blank_line(&self) -> bool {
3378 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3379 let line_end = self.source[self.caret..]
3380 .find('\n')
3381 .map_or(self.source.len(), |i| self.caret + i);
3382 self.source[line_start..line_end].trim().is_empty()
3383 }
3384
3385 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3386 /// `edge` side — the byte range to delete whole. A table row is one source
3387 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3388 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3389 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3390 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3391 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3392 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
3393 /// apart — no ancestor walk needed. Rich view only; source view shows the
3394 /// literal tag and deletes it a byte at a time.
3395 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3396 let caret = self.caret;
3397 let nodes = self.nodes();
3398 let src = self.source.as_bytes();
3399 nodes
3400 .iter()
3401 .find(|n| {
3402 n.kind == Kind::HardBreak
3403 && n.span.start < n.span.end
3404 && src.get(n.span.start) == Some(&b'<')
3405 && match edge {
3406 BreakEdge::Backward => n.span.end == caret,
3407 BreakEdge::Forward => n.span.start == caret,
3408 }
3409 })
3410 .map(|n| (n.span.start, n.span.end))
3411 }
3412
3413 /// Whether the source byte at `off` is a backslash twig consumed as an escape
3414 /// (hidden in the rich view), as against a literal backslash (drawn). A
3415 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3416 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3417 /// round-trip needed.
3418 fn is_hidden_escape(&self, off: usize) -> bool {
3419 let b = self.source.as_bytes();
3420 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3421 }
3422
3423 /// Backspace's list behaviour: when the caret sits exactly at the start of a
3424 /// list item's content (right after its marker), outdent the item if it's
3425 /// nested, else strip the marker so it becomes a paragraph. Returns whether
3426 /// it acted — `false` leaves Backspace its ordinary character delete.
3427 fn backspace_list_start(&mut self) -> bool {
3428 let Some(marker) = self.list_marker_on_line(self.caret) else {
3429 return false;
3430 };
3431 // Only right after the marker. That the line opens a real item is
3432 // already settled: `list_marker_on_line` answers from the tree.
3433 if self.caret != marker.content_start() {
3434 return false;
3435 }
3436 if self.item_is_nested(marker.marker_start) {
3437 // Nested: give back one level, keeping the marker and carrying the
3438 // caret with it.
3439 self.outdent();
3440 } else {
3441 // Top level: drop the marker, leaving a paragraph, then renumber the
3442 // siblings the removed item was counted among. Only the marker goes —
3443 // a quote prefix in front of it still has a quote to hold up.
3444 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3445 self.renumber_here();
3446 }
3447 true
3448 }
3449
3450 /// Backspace's heading behaviour: with the caret exactly at the start of an
3451 /// ATX heading's content — right after the `#` marker the rich view hides —
3452 /// strip the marker so the line becomes a paragraph. The peer of
3453 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3454 /// reasoning: hidden block markup is structure, so the keystroke over it is
3455 /// structural.
3456 ///
3457 /// Without this the ordinary delete takes the space out of `# Title` and
3458 /// leaves `#Title`, which is no longer a heading at all — the hash the view
3459 /// had been hiding surfaces as literal text the user has to delete a second
3460 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3461 /// other end) goes with the marker for the same reason.
3462 ///
3463 /// Returns whether it acted; `false` leaves Backspace its character delete.
3464 fn backspace_heading_start(&mut self) -> bool {
3465 let caret = self.caret;
3466 // The heading whose content opens exactly at the caret. A bare `#` has no
3467 // content span at all — its content starts (and ends) where the line does.
3468 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3469 let (start, end) = match &n.content_span {
3470 Some(c) => (c.start, c.end),
3471 None => (n.span.end, n.span.end),
3472 };
3473 (n.kind == Kind::Heading && start == caret)
3474 .then(|| (n.span.clone(), end, n.marker_span.clone()))
3475 }) else {
3476 return false;
3477 };
3478 // twig reports the marker's own extent, so there is nothing to walk back
3479 // over and no `#` in this file. A setext heading has no marker — its
3480 // content opens the line — so it falls through to the ordinary delete,
3481 // as does anything else sitting at a content start.
3482 // `m.end == caret` is what excludes a setext heading, whose marker is the
3483 // underline *after* the content rather than a prefix before it.
3484 let Some(marker) = marker.filter(|m| m.end == caret) else {
3485 return false;
3486 };
3487 let start = marker.start;
3488 // A closing `#` sequence is hidden too, so it can't be left behind. Only
3489 // when the tail really is one: trailing spaces alone are nothing to strip.
3490 let tail = &self.source[content_end..span.end];
3491 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3492 let kept = self.source[caret..content_end].to_string();
3493 self.splice(start, span.end, &kept, EditKind::Other);
3494 // The splice leaves the caret past the text it re-wrote; the caret
3495 // belongs where the content now starts, which is where it already was.
3496 self.caret = start;
3497 self.record_caret();
3498 } else {
3499 self.splice(start, caret, "", EditKind::Other);
3500 }
3501 true
3502 }
3503
3504 pub fn delete_forward(&mut self) {
3505 if let Some((s, e)) = self.selection() {
3506 self.splice(s, e, "", EditKind::Other);
3507 } else if self.caret < self.source.len() {
3508 // The mirror of Backspace's: forward-delete in front of a picture
3509 // would eat the `!` off its markup and leave a link where a photo was.
3510 if self.view != View::Source && self.delete_around_block_media(true) {
3511 return;
3512 }
3513 // And of Backspace's join: at the end of a block's content, Delete
3514 // joins the next block into this one.
3515 if self.view != View::Source && self.delete_forward_joins_block() {
3516 return;
3517 }
3518 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3519 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3520 // strands a broken `<br` in the cell.
3521 if self.view != View::Source
3522 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3523 {
3524 self.splice(start, end, "", EditKind::Delete);
3525 return;
3526 }
3527 // The mirror of Backspace's two steps: from in front of a run's
3528 // opening `**` step into it, onto the first letter of its text, and
3529 // at the end of a run's text step out past its closing `**` to the
3530 // character beyond. Either way Delete takes the character it looks
3531 // like it is pointing at, and never a delimiter drawn as nothing.
3532 // The caret then settles back inside the run it was standing in —
3533 // see `settle_inside_close_delims`.
3534 let from = if self.view == View::Source {
3535 self.caret
3536 } else {
3537 let inside = self.step_inside_open_delims(self.caret);
3538 // The mirror of Backspace's empty-span rule: an attributed
3539 // span with no text goes whole, with the character after it.
3540 if let Some(span) = self.run_span_of_content(inside..inside) {
3541 let to = if self.source[span.end..].starts_with('\n') {
3542 span.end
3543 } else {
3544 next_boundary(&self.source, span.end)
3545 };
3546 self.splice(span.start, to, "", EditKind::Delete);
3547 return;
3548 }
3549 self.skip_trailing_close_delims(inside)
3550 };
3551 let next = next_boundary(&self.source, from);
3552 // And of its emptying rules: the span goes with its last letter,
3553 // and so does the block's div or `{…}` line.
3554 if self.view != View::Source
3555 && let Some(span) = self.run_span_of_content(from..next)
3556 {
3557 self.splice(span.start, span.end, "", EditKind::Delete);
3558 return;
3559 }
3560 if self.view != View::Source
3561 && let Some(block) = self.attributed_block_of_content(from..next)
3562 {
3563 self.splice(block.start, block.end, "", EditKind::Delete);
3564 return;
3565 }
3566 if from < next {
3567 self.splice(from, next, "", EditKind::Delete);
3568 }
3569 }
3570 }
3571
3572 /// Delete from the caret back to the start of the previous word (⌥⌫ /
3573 /// Ctrl+⌫). Deletes the selection instead when one is active.
3574 pub fn delete_word_back(&mut self) {
3575 if let Some((s, e)) = self.selection() {
3576 self.splice(s, e, "", EditKind::Other);
3577 } else {
3578 // A word back from just past a picture is a word *of its markup*, and
3579 // a word back from in front of one runs through the paragraph break
3580 // into the prose above — dissolving the picture either way. See
3581 // `delete_around_block_media`.
3582 if self.view != View::Source && self.delete_around_block_media(false) {
3583 return;
3584 }
3585 let start = self.word_left_from(self.caret).max(self.caret_floor());
3586 if start < self.caret {
3587 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3588 self.splice(s, e, "", EditKind::Delete);
3589 }
3590 }
3591 }
3592
3593 /// Delete from the caret forward to the end of the next word (⌥⌦ /
3594 /// Ctrl+Del). Deletes the selection instead when one is active.
3595 pub fn delete_word_forward(&mut self) {
3596 if let Some((s, e)) = self.selection() {
3597 self.splice(s, e, "", EditKind::Other);
3598 } else {
3599 // The mirror: a word forward from in front of a picture is its markup.
3600 if self.view != View::Source && self.delete_around_block_media(true) {
3601 return;
3602 }
3603 let end = self.word_right_from(self.caret);
3604 if end > self.caret {
3605 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3606 self.splice(s, e, "", EditKind::Delete);
3607 }
3608 }
3609 }
3610
3611 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3612 /// selection instead when one is active, as every other delete here does.
3613 ///
3614 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3615 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3616 /// stops at the first character and this takes the indentation with it, the
3617 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3618 /// leave an indent behind that nothing can then ask to delete, where a caret
3619 /// left at column 0 is one press of Home away from either.
3620 pub fn delete_to_line_start(&mut self) {
3621 if let Some((s, e)) = self.selection() {
3622 self.splice(s, e, "", EditKind::Other);
3623 return;
3624 }
3625 // Never back across the floor: hidden frontmatter isn't on this line, or
3626 // on any line the WYSIWYG caret can see.
3627 let (start, _) = self.line_span();
3628 let start = start.max(self.caret_floor());
3629 if start < self.caret {
3630 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3631 self.splice(s, e, "", EditKind::Delete);
3632 }
3633 }
3634
3635 /// Kill from the caret to the end of its line (^K). Deletes the selection
3636 /// instead when one is active.
3637 ///
3638 /// At the end of the line it does nothing, rather than pulling the line
3639 /// below up into this one. Joining has no meaning to give it in both views
3640 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3641 /// there is nothing there to delete, while the newline a *source* line ends
3642 /// with is only half of the blank line that separates two paragraphs —
3643 /// deleting one leaves a soft break, which is not the join it looks like.
3644 /// The views agreeing is worth more than emacs' second press, and Delete is
3645 /// already the key that joins.
3646 pub fn delete_to_line_end(&mut self) {
3647 if let Some((s, e)) = self.selection() {
3648 self.splice(s, e, "", EditKind::Other);
3649 return;
3650 }
3651 let (_, end) = self.line_span();
3652 if end > self.caret {
3653 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3654 self.splice(s, e, "", EditKind::Delete);
3655 }
3656 }
3657
3658 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3659 ///
3660 /// A glyph-space range covers what the user can see, which for `**bold**` is
3661 /// the word and never the delimiters around it — so deleting the word on its
3662 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3663 /// word, and the styling was the word's; the two go together. Only the
3664 /// node's delimiters are taken, and those are hidden here anyway, so nothing
3665 /// visible outside the range is lost.
3666 ///
3667 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3668 /// the strong, and only then is the strong empty too.
3669 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3670 if self.view == View::Source {
3671 return (start, end);
3672 }
3673 let nodes = self.nodes();
3674 let (mut s, mut e) = (start, end);
3675 loop {
3676 let mut grew = false;
3677 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3678 let Some(text) = inline_content_span(n, &self.source) else {
3679 continue;
3680 };
3681 // Some of its text survives, so the node still has a job.
3682 if text.start < s || text.end > e {
3683 continue;
3684 }
3685 if n.span.start < s || n.span.end > e {
3686 s = s.min(n.span.start);
3687 e = e.max(n.span.end);
3688 grew = true;
3689 }
3690 }
3691 if !grew {
3692 return (s, e);
3693 }
3694 }
3695 }
3696
3697 /// One splice of document text, keeping the **mark-edge rule**: an inline
3698 /// mark's content never begins or ends with whitespace. In Markdown and Djot
3699 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3700 /// is four literal asterisks around a word, and a rich view drawing the
3701 /// document faithfully has no choice but to show them. That is correct
3702 /// rendering of what the file says, and nobody typing a space after a bold
3703 /// word meant to say it.
3704 ///
3705 /// So the space goes *outside* the run instead — `**bold** ` — which is the
3706 /// same document to a reader and a live one to a parser. The caret follows it
3707 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3708 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3709 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3710 ///
3711 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3712 /// through here, so the rule holds however the whitespace arrives at the
3713 /// edge. The repair is decided *after* the plain edit, by asking whether the
3714 /// mark actually died: a code span's backticks aren't whitespace-sensitive
3715 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3716 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3717 let fix = self.mark_edge_fix(start, end, text);
3718 if !self.splice_exact(start, end, text, kind) {
3719 return false;
3720 }
3721 if let Some(fix) = fix {
3722 self.repair_mark_edges(fix);
3723 }
3724 if text.is_empty() && end > start {
3725 self.settle_inside_close_delims();
3726 }
3727 true
3728 }
3729
3730 /// After a delete, take a caret left standing past a run's closing delimiters
3731 /// back inside the run.
3732 ///
3733 /// A delete leaves the caret where the deleted bytes began, and when those
3734 /// bytes were the last thing after a marked phrase — the space the mark-edge
3735 /// rule pushed out of `**bold** `, say — that spot is the far side of the
3736 /// closing `**`. The rich view has nothing to draw there: the delimiters are
3737 /// hidden, so the caret shows at the end of the word either way, and the two
3738 /// offsets are one place on screen with two different meanings. Typing at the
3739 /// outer one lands past the run, so the writer who backspaced a space out of
3740 /// their bold phrase watches the next character come out plain, and the
3741 /// toolbar button go dark, with the caret never appearing to move.
3742 ///
3743 /// The end of the run's text is the caret's home there — a delete that took
3744 /// away everything after a phrase leaves the caret at the end of that phrase,
3745 /// which is inside it — so it settles onto that
3746 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3747 /// walk, through every mark closing at the point): the word stays bold, the
3748 /// button stays lit, and the next character carries on the phrase.
3749 ///
3750 /// Rich view only, and only where a mark really closes at the caret — mid-run
3751 /// or in plain prose no span ends there and the caret stays put. The opening
3752 /// edge is left alone on purpose: a caret in front of a run inherits from the
3753 /// text on its left, which is the plain text outside.
3754 fn settle_inside_close_delims(&mut self) {
3755 if self.view != View::Wysiwyg {
3756 return;
3757 }
3758 let at = self.step_inside_close_delims(self.caret);
3759 if at != self.caret {
3760 self.caret = at;
3761 self.clear_pending();
3762 self.record_caret();
3763 }
3764 }
3765
3766 /// The splice exactly as asked, with no mark-edge repair — for the callers
3767 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3768 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3769 /// the bytes they inserted.
3770 ///
3771 /// One `edit_range` through twig, then re-anchor the caret from the returned
3772 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3773 /// Markdown/Djot) leaves the document untouched and reports.
3774 ///
3775 /// Returns whether the edit landed — for a caller that has offsets of its
3776 /// own to place afterwards, which a rolled-back splice would leave pointing
3777 /// into text that never came to exist.
3778 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3779 // The read-only gate, for every edit at once — see the field.
3780 if self.read_only {
3781 return false;
3782 }
3783 // twig records an undo step for every edit; when this one continues a
3784 // run of the same kind (typing, deleting), tell twig to fold it into the
3785 // step before it so the whole run undoes at once.
3786 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3787 // Hand twig the pre-edit caret before the splice, so the undo step it
3788 // retires carries where the caret was standing.
3789 self.record_caret();
3790 match self.editor.edit_range(start, end, text) {
3791 Ok(change) => {
3792 if coalesce {
3793 let _ = self.editor.coalesce_last_undo();
3794 }
3795 self.last_edit_kind = Some(kind);
3796 self.refresh();
3797 self.caret = change.new.end;
3798 self.anchor = None;
3799 self.goal_col = None;
3800 self.clear_pending();
3801 self.dirty = self.source != self.clean_source;
3802 self.status = None;
3803 // And the post-edit caret, so a later redo restores it.
3804 self.record_caret();
3805 true
3806 }
3807 // The edit was rolled back, so twig's history did not move and
3808 // neither may ours: pushing here would leave a step with no edit
3809 // under it and shift every later undo onto the wrong caret.
3810 Err(e) => {
3811 self.status = Some(format!("edit: {e}"));
3812 false
3813 }
3814 }
3815 }
3816
3817 /// The re-spelling that would keep the mark-edge rule for the edit
3818 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3819 /// against a delimiter and the plain splice is already right. Computed
3820 /// *before* the edit, while the run's spans and delimiters can still be read
3821 /// off the document; applied afterwards, and only if the mark really died —
3822 /// see [`repair_mark_edges`](Self::repair_mark_edges).
3823 ///
3824 /// Rich view only. Source view is for typing raw markup, where a space put
3825 /// against a `**` is exactly the character it looks like.
3826 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3827 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3828 return None;
3829 }
3830 // Every inline mark standing over the edit, outermost first, with the
3831 // content span that says where its delimiters are.
3832 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3833 .editor
3834 .ancestors_at(start)
3835 .unwrap_or_default()
3836 .into_iter()
3837 .filter_map(|m| {
3838 let kind = inline_kind(&m.kind)?;
3839 let content = m.content_span.clone()?;
3840 Some((kind, m.span.clone(), content))
3841 })
3842 .collect();
3843 // The innermost run whose *content* holds the whole edit: the one whose
3844 // text is being changed, rather than one the edit merely sits under.
3845 let (kind, span, content) = chain
3846 .iter()
3847 .rev()
3848 .find(|(_, _, c)| c.start <= start && end <= c.end)?
3849 .clone();
3850 // What that content becomes. Whitespace at either end of it is what
3851 // would put out the mark.
3852 let body = format!(
3853 "{}{text}{}",
3854 &self.source[content.start..start],
3855 &self.source[end..content.end]
3856 );
3857 let (lead, trail) = if body.trim().is_empty() {
3858 // Nothing but whitespace left: there is no content to mark at all,
3859 // and the delimiters go with it rather than closing on a space.
3860 (body.len(), 0)
3861 } else {
3862 (
3863 body.len() - body.trim_start().len(),
3864 body.len() - body.trim_end().len(),
3865 )
3866 };
3867 // Nothing against a delimiter, and something still between them: the
3868 // plain edit stands. An emptied run is broken just as surely (`**b**`
3869 // with the `b` deleted is the literal `****`) and is re-spelt as the
3870 // nothing it now says.
3871 if lead == 0 && trail == 0 && !body.is_empty() {
3872 return None;
3873 }
3874 // Marks that open or close exactly where this one does — `***both***` is
3875 // two runs sharing an edge — spell their delimiters as one run of bytes,
3876 // so the whitespace has to clear all of them together.
3877 let (mut open_at, mut close_at) = (span.start, span.end);
3878 for _ in 0..chain.len() {
3879 match chain.iter().find(|(_, _, c)| c.start == open_at) {
3880 Some((_, s, _)) => open_at = s.start,
3881 None => break,
3882 }
3883 }
3884 for _ in 0..chain.len() {
3885 match chain.iter().find(|(_, _, c)| c.end == close_at) {
3886 Some((_, s, _)) => close_at = s.end,
3887 None => break,
3888 }
3889 }
3890 let open = &self.source[open_at..content.start];
3891 let close = &self.source[content.end..close_at];
3892 let core = &body[lead..body.len() - trail];
3893 let respelt = if core.is_empty() {
3894 body.clone()
3895 } else {
3896 format!(
3897 "{}{open}{core}{close}{}",
3898 &body[..lead],
3899 &body[body.len() - trail..]
3900 )
3901 };
3902 // The caret sits just past the inserted text within the new content —
3903 // which, when that lands in the whitespace, is now outside the delimiters.
3904 let pos = (start - content.start) + text.len();
3905 let caret = if core.is_empty() || pos <= lead {
3906 open_at + pos
3907 } else if pos >= lead + core.len() {
3908 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3909 } else {
3910 open_at + lead + open.len() + (pos - lead)
3911 };
3912 Some(MarkEdgeFix {
3913 kind,
3914 probe: content.start,
3915 start: open_at,
3916 end: close_at + text.len() - (end - start),
3917 text: respelt,
3918 caret,
3919 // The marks in force here, resolved against any armed sticky delta —
3920 // what the writer is typing in, and so what has to still be true on
3921 // the far side of the delimiter the caret just stepped over.
3922 want: chain
3923 .iter()
3924 .filter(|(_, s, _)| start < s.end)
3925 .map(|(k, _, _)| *k)
3926 .collect::<InlineMarks>()
3927 .xor(self.pending_here()),
3928 })
3929 }
3930
3931 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3932 /// did break the mark. Whether whitespace at a delimiter is fatal is the
3933 /// format's business, not leaf's: `**bold **` is no longer strong, while
3934 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3935 /// don't care either. Asking the parser afterwards settles it for every kind
3936 /// and format at once, and costs a re-spelling only where one is due.
3937 ///
3938 /// The repair rides along with the edit that caused it — one undo step puts
3939 /// back what the writer typed, not a delimiter shuffle they never saw.
3940 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3941 if fix.end > self.source.len() {
3942 return;
3943 }
3944 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3945 return; // still a mark: these delimiters don't mind the whitespace
3946 }
3947 let resumed = self.last_edit_kind;
3948 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3949 return;
3950 }
3951 let _ = self.editor.coalesce_last_undo();
3952 // The keystroke owns the undo step, so the run of typing it belongs to
3953 // keeps coalescing over the repair rather than breaking in two here.
3954 self.last_edit_kind = resumed;
3955 self.caret = fix.caret.min(self.source.len());
3956 self.anchor = None;
3957 self.goal_col = None;
3958 self.rearm(fix.want);
3959 self.clamp_caret();
3960 self.record_caret();
3961 }
3962
3963 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3964 /// writer is typing in, carried across an edit that moved the caret out of
3965 /// the run holding them. Arms nothing when the caret already stands in
3966 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3967 /// a clean delta here (see [`toggle`](Self::toggle)).
3968 fn rearm(&mut self, want: InlineMarks) {
3969 let here: InlineMarks = self
3970 .marks_at(self.caret)
3971 .into_iter()
3972 .map(|(k, _)| k)
3973 .collect();
3974 self.pending_marks = want.xor(here);
3975 self.pending_at = Some(self.caret);
3976 }
3977
3978 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3979 /// which backslash-escapes any character that would otherwise open markup in
3980 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3981 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3982 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3983 /// a collapsed point — a selection is deleted by the caller first, since
3984 /// `insert_literal` inserts rather than replaces.
3985 fn insert_literal_at(
3986 &mut self,
3987 at: usize,
3988 text: &str,
3989 kind: EditKind,
3990 force_coalesce: bool,
3991 ) -> bool {
3992 // The read-only gate: this door goes to twig directly, not through
3993 // `splice_exact`, so it guards itself — see the field.
3994 if self.read_only {
3995 return false;
3996 }
3997 // `force_coalesce` folds this into the immediately preceding edit (the
3998 // selection-delete of an overwrite) so the pair is one undo step; else it
3999 // coalesces only when it continues a run of the same-kind typing.
4000 let coalesce =
4001 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4002 // The mark-edge rule holds for typed text however it is spelled — see
4003 // `splice`. Only an insert twig passed through unchanged can use it,
4004 // since a fix is measured in the bytes that actually land, and an escape
4005 // adds bytes this couldn't have counted.
4006 let fix = self.mark_edge_fix(at, at, text);
4007 self.record_caret();
4008 match self.editor.insert_literal(at, text) {
4009 Ok(change) => {
4010 if coalesce {
4011 let _ = self.editor.coalesce_last_undo();
4012 }
4013 self.last_edit_kind = Some(kind);
4014 self.refresh();
4015 self.caret = change.new.end;
4016 self.anchor = None;
4017 self.goal_col = None;
4018 self.clear_pending();
4019 self.dirty = self.source != self.clean_source;
4020 self.status = None;
4021 self.record_caret();
4022 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4023 self.repair_mark_edges(fix);
4024 }
4025 true
4026 }
4027 Err(e) => {
4028 self.status = Some(format!("edit: {e}"));
4029 false
4030 }
4031 }
4032 }
4033
4034 /// After a structural list edit (a new item, a nest/unnest), renumber the
4035 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4036 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4037 /// renumber as its own edit; fold it into the edit that triggered it so the
4038 /// two undo as one, and only when it actually changed the source (a no-op or
4039 /// a caret outside any ordered list must not coalesce the real edit into the
4040 /// step before it).
4041 fn renumber_here(&mut self) {
4042 self.renumber_at(self.caret);
4043 }
4044
4045 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4046 /// caret — for an edit that leaves the caret one past the item it just wrote,
4047 /// where twig resolves no list to renumber.
4048 fn renumber_at(&mut self, off: usize) {
4049 // The read-only gate — this door reaches twig without the splice.
4050 if self.read_only {
4051 return;
4052 }
4053 let before = self.source.clone();
4054 if self.editor.renumber_ordered_lists(off).is_err() {
4055 return; // not inside an ordered list — nothing to renumber
4056 }
4057 self.refresh();
4058 if self.source != before {
4059 let _ = self.editor.coalesce_last_undo();
4060 self.dirty = self.source != self.clean_source;
4061 self.clamp_caret();
4062 self.record_caret();
4063 }
4064 }
4065
4066 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4067 /// sub-item written directly beneath a text line reparses that text as a
4068 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
4069 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4070 /// bullets can't underline anything, so swap the dash for a `*`: the item
4071 /// stays an empty nested bullet, the parent stays prose, and the source
4072 /// round-trips instead of hiding a heading the user never asked for. Folded
4073 /// into the triggering edit's undo step, the way renumbering is.
4074 ///
4075 /// Gated on the collapse having actually happened (the swapped dash was
4076 /// swallowed into a `heading`), so a real setext heading the author wrote —
4077 /// or a `- x` with content, which can't underline anything — is never
4078 /// touched. This has to live in the *edit*, not the renderer: leaving the
4079 /// hazardous bytes on disk and only painting over them would ship a file
4080 /// every other CommonMark tool reads as a heading.
4081 ///
4082 /// This one keeps its own byte scan, and has to: the hazard is precisely
4083 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4084 /// which asks twig which lines open an item — reports nothing here. There is
4085 /// no node to ask about. It is also the last Markdown spelling leaf writes on
4086 /// purpose rather than for want of an answer; once twig spells continuations
4087 /// itself, avoiding the trap becomes twig's, and this goes.
4088 ///
4089 /// [`list_marker_on_line`]: Self::list_marker_on_line
4090 fn avoid_setext_collapse(&mut self) {
4091 let caret = self.caret.min(self.source.len());
4092 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4093 let bytes = self.source.as_bytes();
4094 let mut dash = line_start;
4095 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4096 dash += 1;
4097 }
4098 // A dash bullet is the only marker that doubles as a setext underline.
4099 if bytes.get(dash) != Some(&b'-') {
4100 return;
4101 }
4102 // Only an *empty* item is a bare underline; `- x` carries content and
4103 // can't fold the line above into a heading.
4104 let line_end = self.source[dash..]
4105 .find('\n')
4106 .map_or(self.source.len(), |i| dash + i);
4107 if !self.source[dash + 1..line_end].trim().is_empty() {
4108 return;
4109 }
4110 // The tell: that dash was swallowed into a `heading`. A properly nested
4111 // empty item sits under a `list_item`, with no heading in reach. Probe
4112 // the dash byte itself (well inside the heading), not the caret, whose
4113 // end-of-line offset can fall on the half-open span boundary.
4114 let collapsed = self
4115 .editor
4116 .ancestors_at(dash)
4117 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4118 .unwrap_or(false);
4119 if !collapsed {
4120 return;
4121 }
4122 let caret = self.caret;
4123 if self.splice(dash, dash + 1, "*", EditKind::Other) {
4124 // Same width, so the caret keeps its column; fold into the edit that
4125 // triggered this so Tab stays one undo step.
4126 let _ = self.editor.coalesce_last_undo();
4127 self.caret = caret.min(self.source.len());
4128 self.clamp_caret();
4129 self.record_caret();
4130 }
4131 }
4132
4133 fn snapshot(&self) -> CaretState {
4134 CaretState {
4135 caret: self.caret,
4136 anchor: self.anchor,
4137 }
4138 }
4139
4140 /// Hand twig the current caret and selection as the blob for the live
4141 /// document state. Called before an edit — so the step twig retires records
4142 /// where the caret was, and undo can restore it — and again once the op has
4143 /// placed the caret, so redo restores where the edit left it.
4144 ///
4145 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4146 /// caret through its own history, so coalescing falls out for free (folding
4147 /// two twig steps into one drops the intermediate blob, keeping the run's
4148 /// first) and the parallel stacks that had to march in lockstep — and could
4149 /// silently drift out of it — are gone.
4150 fn record_caret(&mut self) {
4151 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4152 }
4153
4154 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4155 /// the toggled region selected so a second press cleanly reverses it.
4156 pub fn toggle(&mut self, kind: InlineKind) {
4157 // The read-only gate — this door reaches twig without the splice.
4158 if self.read_only {
4159 return;
4160 }
4161 // Ahead of the no-selection branch below: arming a mark for text not yet
4162 // typed is a promise `insert` cannot keep in a format with no delimiters
4163 // to spell it with. Per *kind*, not per format — Markdown spells five
4164 // of the eight marks (highlight among them, under the `highlight`
4165 // extension leaf parses with), djot all eight, HTML seven.
4166 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4167 return;
4168 }
4169 let Some((s, e)) = self.selection() else {
4170 // No selection: arm the mark for the next text typed here, the way a
4171 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4172 // in the flow of typing without ever selecting anything — the delta
4173 // is realised onto the freshly typed text by `insert`. A fresh caret
4174 // position starts the delta over from the marks actually in force.
4175 if self.pending_at != Some(self.caret) {
4176 self.pending_marks = InlineMarks::empty();
4177 self.pending_at = Some(self.caret);
4178 }
4179 self.pending_marks.flip(kind);
4180 self.status = None;
4181 return;
4182 };
4183 // Whitespace at the edge of a selection is not part of what was chosen —
4184 // a double-click takes the space after the word with it — and a mark
4185 // cannot close against one anyway: `**word **` is four literal asterisks
4186 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4187 let picked = &self.source[s..e];
4188 let (s, e) = (
4189 s + (picked.len() - picked.trim_start().len()),
4190 e - (picked.len() - picked.trim_end().len()),
4191 );
4192 if s >= e {
4193 self.status = Some(format!("{kind:?}: nothing selected to mark"));
4194 return;
4195 }
4196 // Styling a selection is a one-shot act, not a sticky mode.
4197 self.clear_pending();
4198 self.record_caret();
4199 match self.editor.toggle_inline(s, e, kind) {
4200 Ok(change) => {
4201 self.last_edit_kind = None; // structural edit is its own undo step
4202 self.refresh();
4203 self.anchor = Some(change.new.start);
4204 self.caret = change.new.end;
4205 self.dirty = self.source != self.clean_source;
4206 self.status = None;
4207 self.record_caret();
4208 }
4209 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4210 }
4211 }
4212
4213 /// Whether the caret stands in a highlight — what a frontend asks to enable
4214 /// or disable its highlight-colour controls, the way
4215 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4216 ///
4217 /// A fact about the *caret*, and the other half of
4218 /// [`Capabilities::mark_color`], which is the fact about the format. A
4219 /// frontend needs both: djot spells a highlight and no colour for it, so a
4220 /// caret standing in `{=word=}` answers `true` here and still has no palette
4221 /// to offer.
4222 ///
4223 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4224 /// the palette appears exactly where the Highlight button is lit — with one
4225 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4226 /// into lights the button and answers `false` here, because there is no node
4227 /// to colour until the text exists.
4228 pub fn caret_in_mark(&mut self) -> bool {
4229 self.mark_offset().is_some()
4230 }
4231
4232 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4233 /// standing in the highlight the gesture means — or `None` when neither end
4234 /// of what is selected is in one.
4235 ///
4236 /// The caret first, and the selection's *start* after it, because of what
4237 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4238 /// whole, with the caret at its far edge, one past the closing `==` and so
4239 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4240 /// and colour it — the two presses a coloured highlight is made of — would
4241 /// otherwise refuse on the second, having just written the highlight the
4242 /// author is pointing at.
4243 fn mark_offset(&mut self) -> Option<usize> {
4244 let in_mark = |d: &mut Self, off: usize| {
4245 d.marks_at(off)
4246 .into_iter()
4247 .any(|(k, _)| k == InlineKind::Mark)
4248 .then_some(off)
4249 };
4250 let caret = self.caret.min(self.source.len());
4251 in_mark(self, caret).or_else(|| {
4252 let start = self.selection()?.0;
4253 in_mark(self, start)
4254 })
4255 }
4256
4257 /// The colour of the highlight at the caret — `None` both when the caret is
4258 /// in no highlight and when the highlight it is in names no colour, which
4259 /// are the same answer to "which swatch is lit".
4260 ///
4261 /// The innermost mark, by span, for the same reason
4262 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4263 /// what the caret is *in* is the deepest node containing it. A `data-color`
4264 /// naming a colour this build has no variant for reads as `None` — the
4265 /// renderer already draws that as a plain highlight rather than guessing,
4266 /// and the toolbar agrees with the renderer.
4267 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4268 let at = self.mark_offset()?;
4269 self.mark_color_at(at)
4270 }
4271
4272 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4273 /// the innermost `mark` covering it, and the colour it names.
4274 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4275 self.nodes()
4276 .into_iter()
4277 .filter(|n| n.kind == Kind::Mark)
4278 .filter(|n| n.span.start <= off && off < n.span.end)
4279 .min_by_key(|n| n.span.end - n.span.start)
4280 .and_then(|n| MarkColor::from_attrs(&n.attrs))
4281 }
4282
4283 /// Colour the highlight at the caret, or clear its colour with `None` — the
4284 /// palette behind a toolbar's Highlight button.
4285 ///
4286 /// Markdown only, and the one gesture whose availability is a fact about the
4287 /// *parse extensions* rather than about the format alone: the colour is
4288 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4289 /// records as the mark's `data-color`, and only an editor parsing with
4290 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4291 /// document) reads it back that way. Djot spells the highlight and no colour
4292 /// for it, so this refuses there — see [`Capabilities::mark_color`].
4293 ///
4294 /// **A colour is a property of a highlight that already exists.** There is
4295 /// no "highlight this in red" here, because that is two splices and would be
4296 /// two undo steps under one press; a frontend that wants it calls
4297 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4298 /// order the two buttons already sit in. With no highlight at the caret this
4299 /// says so in the status line and writes nothing.
4300 ///
4301 /// The caret keeps its place in the *text*: the splice is entirely in the
4302 /// prefix between the opening `==` and the first word, so an offset past it
4303 /// rides the emoji's width, and one standing on the prefix itself lands
4304 /// where the prefix now ends.
4305 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4306 // The read-only gate — this door reaches twig without the splice.
4307 if self.read_only {
4308 return;
4309 }
4310 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4311 return;
4312 }
4313 let Some(at) = self.mark_offset() else {
4314 self.status = Some("highlight colour: no highlight at the caret".into());
4315 return;
4316 };
4317 // Clearing a colour a highlight hasn't got is twig's one *successful*
4318 // no-op, and the `Change` it hands back then describes whatever edit came
4319 // before it — a stale span that would drag the caret somewhere it never
4320 // was. Answer it here, where the question is cheap, rather than trusting
4321 // a change that isn't one.
4322 if color.is_none() && self.mark_color_at(at).is_none() {
4323 self.status = None;
4324 return;
4325 }
4326 self.record_caret();
4327 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4328 Ok(change) => {
4329 // Re-anchored from the offsets as they were, *before* `refresh`
4330 // sees the new bytes: the caret it clamps is one standing inside
4331 // a prefix that didn't exist a moment ago, and walking it back to
4332 // a char boundary of the emoji loses the place this is restoring.
4333 let caret = reanchor(self.caret, &change);
4334 let anchor = self.anchor.map(|a| reanchor(a, &change));
4335 self.last_edit_kind = None; // structural edit is its own undo step
4336 self.refresh();
4337 self.caret = caret;
4338 self.anchor = anchor;
4339 self.dirty = self.source != self.clean_source;
4340 self.status = None;
4341 self.clamp_caret();
4342 self.record_caret();
4343 }
4344 Err(e) => self.status = Some(format!("highlight colour: {e}")),
4345 }
4346 }
4347
4348 /// One press of a colour swatch: colour the highlight at the caret, or —
4349 /// over a selection that isn't highlighted yet — highlight it and colour it,
4350 /// as **one** undo step.
4351 ///
4352 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4353 /// one splice; this is the compound every toolbar actually presses, and it
4354 /// lives here rather than in each frontend because the rule it encodes —
4355 /// what a swatch means when there is no highlight under it yet — is one
4356 /// answer, not one per frontend. The two splices are folded into a single
4357 /// history step, so the press that made a red highlight is taken back by a
4358 /// single undo rather than leaving an uncoloured one behind.
4359 ///
4360 /// `None` clears the colour, and over an unhighlighted selection means
4361 /// simply "highlight this" — the same thing the Highlight button does.
4362 /// A bare caret in no highlight is left alone with a status line, because
4363 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4364 /// colour cannot be armed with it.
4365 pub fn highlight(&mut self, color: Option<MarkColor>) {
4366 if self.caret_in_mark() || self.selection().is_none() {
4367 self.set_mark_color(color);
4368 return;
4369 }
4370 self.toggle(InlineKind::Mark);
4371 // The format may not spell a highlight at all (`toggle` said so), and
4372 // there is nothing to colour if it doesn't.
4373 if self.status.is_some() {
4374 return;
4375 }
4376 let before = self.revision;
4377 self.set_mark_color(color);
4378 // Only fold when the colour really spliced. `highlight(None)` over a
4379 // fresh highlight is a no-op by design, and coalescing there would eat
4380 // the *previous* edit into the toggle instead.
4381 if self.revision != before {
4382 let _ = self.editor.coalesce_last_undo();
4383 }
4384 }
4385
4386 // ── the presentation vocabulary ─────────────────────────────────────────
4387 //
4388 // Six gestures and five queries over twig's two attribute ops. Each gesture
4389 // edits **one key and keeps the rest**: it reads the node's attributes,
4390 // removes its own key (and, for alignment, its own tokens out of `class`),
4391 // adds the new value or nothing, and passes the list back whole — twig's
4392 // contract is replace-not-merge, so the read is the caller's job. A
4393 // paragraph that came in as `class="lead center" id="intro"
4394 // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4395 // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4396 // touched, which is what lets a document from elsewhere pass through the
4397 // editor unharmed.
4398 //
4399 // Clearing is the same gesture with `None`: the key goes, and an empty list
4400 // at the end unwraps the span or the Markdown div, which twig does.
4401
4402 /// Set — or with `None` clear — the alignment of the block the caret is in.
4403 ///
4404 /// A block property, so the gesture is `set_block_attrs` on the caret's
4405 /// block **whatever is selected**: a line is a block's, and "centre this"
4406 /// with three words selected means the paragraph, not the words. The
4407 /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4408 /// same `class` are kept.
4409 ///
4410 /// In Markdown the attributes live on a `<div>` around the block — twig has
4411 /// no paragraph attribute syntax to write — and this reads them back off
4412 /// that div when the block is its sole child, so a second press rewrites
4413 /// the div rather than nesting a second one.
4414 pub fn set_alignment(&mut self, align: Option<Align>) {
4415 let attrs = self.block_attrs_at_caret();
4416 if align.is_none()
4417 && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4418 {
4419 return;
4420 }
4421 let attrs = with_class_token(
4422 &attrs,
4423 |t| Align::from_token(t).is_some(),
4424 align.map(Align::name),
4425 );
4426 self.write_block_attrs("alignment", attrs);
4427 }
4428
4429 /// Set — or with `None` clear — the line spacing of the block the caret is
4430 /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4431 /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4432 /// three names or an exact ratio, written in its canonical spelling.
4433 pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4434 let attrs = self.block_attrs_at_caret();
4435 if spacing.is_none()
4436 && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4437 LineHeight::from_attrs(a).is_some()
4438 })
4439 {
4440 return;
4441 }
4442 let spelling = spacing.map(LineHeight::name);
4443 let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4444 self.write_block_attrs("line spacing", attrs);
4445 }
4446
4447 /// Set — or with `None` clear — the size of the selected run, or of the
4448 /// caret's whole block when nothing is selected.
4449 ///
4450 /// Size, face and colour are the *run's*, and the block's when no run is
4451 /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4452 /// the range in an attributed span or re-styles the span it already lies in
4453 /// (never nesting a second, and unwrapping it when the last key goes). With
4454 /// no selection it is `set_block_attrs` on the caret's block, so that "make
4455 /// this paragraph larger" is a click with the caret in it rather than a
4456 /// select-all first.
4457 ///
4458 /// The walker reads the key at both levels with the nearer winning, so a
4459 /// span's `data-size` inside a block carrying its own applies to the span.
4460 ///
4461 /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4462 /// what a menu offers first because a step reads as a step up under every
4463 /// theme, or the point size an author asked for, which is exact and is all
4464 /// it is. Either is written in its canonical spelling, so a size set twice
4465 /// from the same field writes the same bytes both times.
4466 pub fn set_font_size(&mut self, size: Option<FontSize>) {
4467 let spelling = size.map(FontSize::name);
4468 self.set_run_attr("size", "data-size", spelling.as_deref());
4469 }
4470
4471 /// Set — or with `None` clear — the face of the selected run, or of the
4472 /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4473 /// [`FontFace`] under `data-font` — one of the four generics, or the family
4474 /// the author named, which the frontends resolve through the platform's
4475 /// font registry and fall back to the body face without.
4476 pub fn set_font_family(&mut self, font: Option<FontFace>) {
4477 let spelling = font.as_ref().map(FontFace::name);
4478 self.set_run_attr("font", "data-font", spelling.as_deref());
4479 }
4480
4481 /// Set — or with `None` clear — the *text* colour of the selected run, or of
4482 /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4483 /// with [`TextColor`] under `data-color` — one of the seven names, whose
4484 /// two inks the theme owns, or the triple the author picked, which is
4485 /// painted as written in both appearances.
4486 ///
4487 /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4488 /// writes, and a different thing: that one colours a highlight's
4489 /// *background* and rides the `mark` node twig owns the spelling of, this
4490 /// one colours the letters and rides an attributed span. The two never
4491 /// collide, because a `mark` is a `mark` and a span is a span — and they
4492 /// share a vocabulary on purpose, so that a frontend with a red for a
4493 /// highlight has a red for text and both are *that* red.
4494 pub fn set_text_color(&mut self, color: Option<TextColor>) {
4495 let spelling = color.map(TextColor::name);
4496 self.set_run_attr("text colour", "data-color", spelling.as_deref());
4497 }
4498
4499 /// Insert a page break at the caret — `::page-break`, a leaf directive with
4500 /// no label and no attributes, which twig spells in every format that names
4501 /// a leaf container (Markdown under the `directives` extension
4502 /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4503 /// page-break` fence).
4504 ///
4505 /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4506 /// places a rule, and for the same reason: a directive is a block, so twig
4507 /// alone has nowhere to put one mid-paragraph and lands it after the
4508 /// caret's whole block. A bare paragraph is therefore parted at the caret
4509 /// first and the break aimed at the *first* half. See that method for the
4510 /// whole of the rule, including why a code block, a list item, a table and
4511 /// a setext heading are left unsplit.
4512 ///
4513 /// The frontends that paginate read the row's
4514 /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4515 /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4516 /// directive gets.
4517 pub fn insert_page_break(&mut self) {
4518 if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4519 return;
4520 }
4521 self.caret = self.skip_trailing_close_delims(self.caret);
4522 // A selection is replaced by the break, as a rule replaces one.
4523 if let Some((s, e)) = self.selection() {
4524 self.splice(s, e, "", EditKind::Other);
4525 }
4526 self.anchor = None;
4527 self.record_caret();
4528 let at = self.caret;
4529 if self.caret_parts_bare_paragraph() {
4530 // A failure here is not fatal: the break still lands after the
4531 // block, which is what this call was trying to improve on.
4532 let _ = self.editor.split_block(at);
4533 }
4534 match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4535 Ok(change) => {
4536 self.last_edit_kind = None;
4537 self.refresh();
4538 self.anchor = None;
4539 self.caret = change.new.end;
4540 self.dirty = self.source != self.clean_source;
4541 self.status = None;
4542 self.clamp_caret();
4543 self.record_caret();
4544 }
4545 Err(e) => self.status = Some(format!("page break: {e}")),
4546 }
4547 }
4548
4549 /// The alignment in force at the caret, or `None` for the theme's default —
4550 /// which swatch of an alignment control is lit.
4551 ///
4552 /// Read off the nearest node that names one: the block the caret is in, and
4553 /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4554 /// shape, one property along.
4555 pub fn alignment_at_caret(&mut self) -> Option<Align> {
4556 self.presentation_chain()
4557 .iter()
4558 .find_map(|attrs| Align::from_attrs(attrs))
4559 }
4560
4561 /// The line spacing in force at the caret, or `None` for the theme's own.
4562 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4563 pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
4564 self.presentation_chain()
4565 .iter()
4566 .find_map(|attrs| LineHeight::from_attrs(attrs))
4567 }
4568
4569 /// The size in force at the caret, or `None` for the theme's own — the
4570 /// entry a size menu shows ticked.
4571 ///
4572 /// Run-level, so the chain starts one node deeper: the attributed span the
4573 /// caret stands in, then its block, then the `div`s around it. The nearest
4574 /// wins, which is the rule the walker draws by.
4575 ///
4576 /// A name or a value, whichever the nearest node wrote. A `data-size` the
4577 /// grammar does not cover — a `huge` from elsewhere — is not a size this
4578 /// can answer, so the answer is `None` and the menu ticks *Default*, the
4579 /// same thing it did before the vocabulary opened.
4580 pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
4581 self.presentation_chain()
4582 .iter()
4583 .find_map(|attrs| FontSize::from_attrs(attrs))
4584 }
4585
4586 /// The face in force at the caret, or `None` for the theme's body face.
4587 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4588 pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
4589 self.presentation_chain()
4590 .iter()
4591 .find_map(|attrs| FontFace::from_attrs(attrs))
4592 }
4593
4594 /// The *text* colour in force at the caret, or `None` for the theme's.
4595 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4596 /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4597 /// highlight's background off a `mark`, and a `mark` is never in this chain.
4598 pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
4599 self.presentation_chain()
4600 .iter()
4601 .find_map(|attrs| TextColor::from_attrs(attrs))
4602 }
4603
4604 /// The selection-or-caret half of the three run-level gestures: a span over
4605 /// a real selection, the caret's block over none.
4606 fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4607 match self.selection() {
4608 Some((start, end)) => {
4609 let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4610 self.write_run_attrs(what, start, end, attrs);
4611 }
4612 None => {
4613 let own = self.block_attrs_at_caret();
4614 if value.is_none()
4615 && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4616 {
4617 return;
4618 }
4619 let attrs = with_attr(&own, key, value);
4620 self.write_block_attrs(what, attrs);
4621 }
4622 }
4623 }
4624
4625 /// A clear this gesture cannot carry out, said out loud instead of written:
4626 /// the node it rewrites — the caret's block, or the `<div>` around it that
4627 /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4628 /// — does not name the property at all, and a `div` further out does.
4629 ///
4630 /// Handing twig the block's attributes with the key already absent changes
4631 /// no byte, and the query goes on answering `Some` off the div: the menu
4632 /// entry the author pressed stays unticked, and nothing says why. Twig's
4633 /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4634 /// write on a node it is not rewriting — the honest answer is the status
4635 /// line, in the voice the other refusals use.
4636 ///
4637 /// `names` is the property's own reading of an attribute list, because
4638 /// alignment lives in a `class` token rather than a key of its own. Spans
4639 /// are skipped: one inside the block is not what a *block* gesture writes
4640 /// either, but neither is it "the div around the block", and the run-level
4641 /// gestures reach it through a selection.
4642 fn refuse_clear_from_div(
4643 &mut self,
4644 what: &str,
4645 own: &Attrs,
4646 names: impl Fn(&Attrs) -> bool,
4647 ) -> bool {
4648 if names(own) {
4649 return false;
4650 }
4651 let caret = self.caret.min(self.source.len());
4652 if !self
4653 .attr_chain_at(caret)
4654 .iter()
4655 .any(|(span, attrs)| !span && names(attrs))
4656 {
4657 return false;
4658 }
4659 self.status = Some(format!("{what}: set on the div around the block"));
4660 true
4661 }
4662
4663 /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4664 /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4665 /// has.
4666 ///
4667 /// **The caret keeps its place in the text, not its byte offset.** How a
4668 /// format spells a block's attributes is markup written *around* the block
4669 /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4670 /// it in Markdown, a longer opening tag in HTML — and every one of those
4671 /// grows or shrinks above the author's own bytes. Where twig's change
4672 /// rewrites the block whole (Markdown's div is spliced as one region, block
4673 /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4674 /// and parks the caret at the end of the splice, past the closing `</div>`:
4675 /// the caret is then in no block at all, so a second press of the same menu
4676 /// answers "no block at the caret" and the toolbar's queries read nothing.
4677 /// [`reanchor_in_block`] is what carries it across instead — the block's
4678 /// content span before and after, which is the one thing the respelling
4679 /// leaves alone.
4680 ///
4681 /// Read *before* the splice and applied *after* `refresh`, because both
4682 /// halves of that mapping are facts about a tree twig is between: the
4683 /// block's old bytes are gone once the edit lands, and its new ones are not
4684 /// in `self.source` until the refresh puts them there.
4685 fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4686 if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4687 return;
4688 }
4689 // A blank line has no block to carry an attribute, and twig answers
4690 // `NotFound` there — say so in leaf's own words instead.
4691 let Some(at) = self.block_offset_for_caret() else {
4692 self.status = Some(format!("{what}: no block at the caret"));
4693 return;
4694 };
4695 self.record_caret();
4696 let pairs = attr_pairs(&attrs);
4697 let was = self.block_content_at(at);
4698 let text = was.clone().map(|s| self.source[s].to_string());
4699 match self.editor.set_block_attrs(at, &pairs) {
4700 Ok(change) => {
4701 let (caret, anchor) = (self.caret, self.anchor);
4702 self.last_edit_kind = None; // structural edit is its own undo step
4703 self.refresh();
4704 let now = self.block_content_in(&change.new, text.as_deref());
4705 // A block the two halves cannot both name — a code block, a
4706 // caret in a list's marker — takes the plain arithmetic, which
4707 // is what it had before.
4708 let block = was.as_ref().zip(now.as_ref());
4709 self.caret = reanchor_in_block(caret, &change, block);
4710 self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4711 self.dirty = self.source != self.clean_source;
4712 self.status = None;
4713 // The clamp reads the caret floor off the map, and this edit
4714 // can move the floor: taking the `{…}` line off a djot
4715 // document's first block moves the first rendered offset to 0,
4716 // and a floor read from the old map stood the caret past the
4717 // block's text. So the map is this revision's before the clamp
4718 // — see `open_paragraph_at_block_edge`.
4719 self.rebuild_map();
4720 self.clamp_caret();
4721 self.record_caret();
4722 }
4723 Err(e) => self.status = Some(format!("{what}: {e}")),
4724 }
4725 }
4726
4727 /// The content span of the innermost paragraph or heading covering `off` —
4728 /// the author's own bytes, without the `# ` or the `<p>` that spells the
4729 /// block around them.
4730 ///
4731 /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4732 /// reads, so that what a gesture re-anchors by is the block it wrote to.
4733 fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4734 self.nodes()
4735 .into_iter()
4736 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4737 .filter(|n| n.span.start <= off && off <= n.span.end)
4738 .min_by_key(|n| n.span.end - n.span.start)
4739 .map(|n| n.content_span.unwrap_or(n.span))
4740 }
4741
4742 /// [`block_content_at`](Self::block_content_at)'s other half: the content
4743 /// span of the block `region` holds now, found by the bytes it held before.
4744 ///
4745 /// Matched on the text rather than taken as the first block in the region,
4746 /// because a rewritten region is markup and all — `<div class="center">`
4747 /// carries words of its own — and because the block this gesture moved is
4748 /// the one whose content the respelling did not touch. `None` where the
4749 /// region holds no block at all, which is djot's every case: the `{…}` line
4750 /// is spliced above the block and the block itself never moves through the
4751 /// change at all, only past it.
4752 fn block_content_in(
4753 &mut self,
4754 region: &Range<usize>,
4755 text: Option<&str>,
4756 ) -> Option<Range<usize>> {
4757 let text = text?;
4758 let spans: Vec<Range<usize>> = self
4759 .nodes()
4760 .into_iter()
4761 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4762 .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4763 .map(|n| n.content_span.unwrap_or(n.span))
4764 .collect();
4765 spans
4766 .into_iter()
4767 .find(|s| self.source.get(s.clone()) == Some(text))
4768 }
4769
4770 /// Hand `attrs` to twig as the attribute set of the span over `[start,
4771 /// end)` — wrapping one, or re-styling the one the range already lies in,
4772 /// or unwrapping it when `attrs` is empty.
4773 ///
4774 /// What the splice leaves selected is the span's **content** — the author's
4775 /// words — and not the whole of `change.new`, which is markup and all:
4776 /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4777 /// selection reaching past the node's own span lies in no span at all, so a
4778 /// second press of the menu would nest a fresh one instead of re-styling
4779 /// the one just written.
4780 fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4781 if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4782 return;
4783 }
4784 self.record_caret();
4785 let pairs = attr_pairs(&attrs);
4786 match self.editor.wrap_range_attrs(start, end, &pairs) {
4787 Ok(change) => {
4788 self.last_edit_kind = None;
4789 self.refresh();
4790 let content = self.span_content_in(&change.new);
4791 self.anchor = Some(content.start);
4792 self.caret = content.end;
4793 self.dirty = self.source != self.clean_source;
4794 self.status = None;
4795 self.clamp_caret();
4796 self.record_caret();
4797 }
4798 Err(e) => self.status = Some(format!("{what}: {e}")),
4799 }
4800 }
4801
4802 /// The content range of the attributed span `spliced` now holds — the
4803 /// outermost one inside it, since that is the one just written — or
4804 /// `spliced` itself where the splice left no span, which is what an unwrap
4805 /// leaves behind.
4806 fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4807 self.nodes()
4808 .into_iter()
4809 .filter(wysiwyg::is_run_span)
4810 .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4811 .max_by_key(|n| n.span.end - n.span.start)
4812 .and_then(|n| n.content_span)
4813 .unwrap_or_else(|| spliced.clone())
4814 }
4815
4816 /// The attribute set `set_block_attrs` is about to **replace** at the caret
4817 /// — which is the block's own, except in Markdown, where twig writes a
4818 /// block's attributes onto a `<div>` around it and rewrites that div when
4819 /// the block is its sole child. Reading the paragraph there would hand back
4820 /// an empty list and quietly drop everything the div said.
4821 ///
4822 /// Empty when the caret is in no block at all, which is the same list a
4823 /// block carrying no attributes gives — and the right one either way, since
4824 /// the gesture then refuses on its own.
4825 fn block_attrs_at_caret(&mut self) -> Attrs {
4826 let Some(off) = self.block_offset_for_caret() else {
4827 return Vec::new();
4828 };
4829 let nodes = self.nodes();
4830 let Some(block) = nodes
4831 .iter()
4832 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4833 .filter(|n| n.span.start <= off && off <= n.span.end)
4834 .min_by_key(|n| n.span.end - n.span.start)
4835 else {
4836 return Vec::new();
4837 };
4838 if self.format == Format::Markdown
4839 && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4840 && wysiwyg::element_tag(parent) == Some("div")
4841 && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4842 {
4843 return parent.attrs.clone();
4844 }
4845 block.attrs.clone()
4846 }
4847
4848 /// The attribute set `wrap_range_attrs` is about to **replace** over
4849 /// `[start, end)` — the innermost attributed span the range lies inside,
4850 /// which twig re-styles rather than nesting a second one in. Empty when the
4851 /// range lies in no span, where the gesture mints a fresh one.
4852 fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
4853 self.nodes()
4854 .into_iter()
4855 .filter(wysiwyg::is_run_span)
4856 .filter(|n| n.span.start <= start && end <= n.span.end)
4857 .min_by_key(|n| n.span.end - n.span.start)
4858 .map(|n| n.attrs)
4859 .unwrap_or_default()
4860 }
4861
4862 /// The attribute lists that bear on a presentation query, **nearest first**:
4863 /// the attributed spans the caret stands in (innermost first), then its
4864 /// block, then the `div`s around it. A `find_map` down this is the whole of
4865 /// each query, and the order is the rule the walker draws by.
4866 ///
4867 /// Read at the caret, and at the selection's *start* when the caret stands
4868 /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
4869 /// caret one past the span it just wrote — `toggle`'s convention — so
4870 /// asking the menu which entry that press just ticked must not answer
4871 /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
4872 fn presentation_chain(&mut self) -> Vec<Attrs> {
4873 let caret = self.caret.min(self.source.len());
4874 let mut chain = self.attr_chain_at(caret);
4875 if !chain.iter().any(|(span, _)| *span)
4876 && let Some((start, _)) = self.selection()
4877 {
4878 let alt = self.attr_chain_at(start);
4879 if alt.iter().any(|(span, _)| *span) {
4880 chain = alt;
4881 }
4882 }
4883 chain.into_iter().map(|(_, attrs)| attrs).collect()
4884 }
4885
4886 /// [`presentation_chain`](Self::presentation_chain) at one offset — every
4887 /// node bearing the vocabulary that covers it, innermost first, each paired
4888 /// with whether it is an attributed span (which is what tells the caller
4889 /// its run-level answer came from a run).
4890 ///
4891 /// Sorted by span length, which *is* the nesting order: a span lies inside
4892 /// its block and a block inside its div, so shortest-first is
4893 /// nearest-first without a second tree walk.
4894 fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
4895 let off = off.min(self.source.len());
4896 let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
4897 for n in self.nodes() {
4898 let span = wysiwyg::is_run_span(&n);
4899 let block = matches!(n.kind, Kind::Para | Kind::Heading);
4900 let div = wysiwyg::element_tag(&n) == Some("div");
4901 if !(span || block || div) {
4902 continue;
4903 }
4904 // A span is half-open, the way a mark is: the offset one past it is
4905 // the text after it. A block and a div claim their end too, so a
4906 // caret resting at the end of a line still reads its paragraph.
4907 let inside = if span {
4908 n.span.start <= off && off < n.span.end
4909 } else {
4910 n.span.start <= off && off <= n.span.end
4911 };
4912 if !inside {
4913 continue;
4914 }
4915 hits.push((n.span.end - n.span.start, span, n.attrs));
4916 }
4917 hits.sort_by_key(|(len, _, _)| *len);
4918 hits.into_iter()
4919 .map(|(_, span, attrs)| (span, attrs))
4920 .collect()
4921 }
4922
4923 /// Convert the block at the caret to a heading level or paragraph.
4924 pub fn set_block(&mut self, kind: BlockKind) {
4925 // The read-only gate — this door reaches twig without the splice.
4926 if self.read_only {
4927 return;
4928 }
4929 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
4930 return;
4931 }
4932 self.record_caret();
4933 // A blank line has no node to convert, and twig opens a block there
4934 // rather than declining — so the caret's own offset is the right thing
4935 // to hand it when `block_offset_for_caret` finds nothing.
4936 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
4937 match self.editor.set_block(offset, kind) {
4938 Ok(change) => {
4939 self.last_edit_kind = None;
4940 self.refresh();
4941 // Opening a block on a blank line writes a marker the caret
4942 // belongs *after*; converting an existing one moves nothing.
4943 self.caret = self.caret.max(change.new.end);
4944 self.clamp_caret();
4945 self.anchor = None;
4946 self.dirty = self.source != self.clean_source;
4947 self.status = None;
4948 self.record_caret();
4949 }
4950 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4951 }
4952 }
4953
4954 /// Whether `off` is inside a text block (paragraph, heading, code block…).
4955 fn has_block_at(&mut self, off: usize) -> bool {
4956 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
4957 chain
4958 .iter()
4959 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4960 })
4961 }
4962
4963 /// The offset to hand twig's `set_block`: the caret when it is already inside
4964 /// a block, otherwise nudged onto the previous character (a caret at a line
4965 /// end sits at the doc level, outside the block). `None` when the caret is on
4966 /// a blank line — a new paragraph with no block node to convert.
4967 fn block_offset_for_caret(&mut self) -> Option<usize> {
4968 let caret = self.caret.min(self.source.len());
4969 if self.has_block_at(caret) {
4970 return Some(caret);
4971 }
4972 // Nudge to the previous character — but never across a newline: that would
4973 // target the previous block, and a blank line genuinely has no block.
4974 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
4975 && ch != '\n'
4976 && self.has_block_at(i)
4977 {
4978 return Some(i);
4979 }
4980 None
4981 }
4982
4983 /// The heading level of the text block at the caret, or `None` when that
4984 /// block is not a heading.
4985 pub fn current_heading_level(&mut self) -> Option<u32> {
4986 let caret = self.caret;
4987 self.nodes()
4988 .into_iter()
4989 .filter(|n| n.kind == Kind::Heading)
4990 .find(|n| n.span.start <= caret && caret <= n.span.end)
4991 .and_then(|n| n.level)
4992 }
4993
4994 /// The inline marks in force at the caret (or over the selection) — what a
4995 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
4996 /// inline counterpart. Cheap enough to call every frame: one twig
4997 /// `ancestors_at` query per caret (two with a selection), each walking root
4998 /// → deepest node at one offset. It never snapshots the tree the way
4999 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5000 /// the only allocation is twig's own small ancestor `Vec`.
5001 ///
5002 /// **A selection reports a mark only when the mark covers *all* of it.**
5003 /// That's what every real toolbar means by an active button — Bold lit over
5004 /// a half-bold selection would claim a press turns bold *off*, when
5005 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5006 /// Whole-coverage is asked as "is the same mark node standing over both the
5007 /// first and the last character?": inline nodes are contiguous, so one node
5008 /// covering both ends covers every byte between them. Two touching runs
5009 /// (`**a****b**`) are two nodes, and correctly light nothing.
5010 ///
5011 /// At a bare caret a mark is active when the caret stands inside the mark's
5012 /// span — `span.start <= caret < span.end`, delimiters included, which is
5013 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5014 /// opening `*` (2) through the last byte of the closing `**` (9) are all
5015 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5016 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5017 /// typing would actually land inside the marked run. The offset one past the
5018 /// mark (10) is the text after it and reports nothing, at the end of the
5019 /// buffer exactly as in the middle.
5020 pub fn active_inline_marks(&mut self) -> InlineMarks {
5021 let Some((start, end)) = self.selection() else {
5022 // The marks actually in force at the caret, flipped by any armed
5023 // sticky delta — so `⌘b` at a bare caret lights the Bold button
5024 // immediately, before a single character is typed.
5025 let base: InlineMarks = self
5026 .marks_at(self.caret)
5027 .into_iter()
5028 .map(|(k, _)| k)
5029 .collect();
5030 return base.xor(self.pending_here());
5031 };
5032 // The selection's *last character*, not its exclusive end: `end` is the
5033 // offset one past the selection, which for a selection ending exactly at
5034 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5035 // entirely bold, but offset 10 is the space after).
5036 let last = prev_boundary(&self.source, end);
5037 let head = self.marks_at(start);
5038 let tail = self.marks_at(last);
5039 head.into_iter()
5040 .filter(|m| tail.contains(m))
5041 .map(|(k, _)| k)
5042 .collect()
5043 }
5044
5045 /// The inline marks whose span covers `off`, each with the id of the node
5046 /// carrying it — the id is what lets a selection tell one mark node from
5047 /// another of the same kind.
5048 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5049 let off = off.min(self.source.len());
5050 self.editor
5051 .ancestors_at(off)
5052 .unwrap_or_default()
5053 .into_iter()
5054 // `span.end` is the offset one *past* the mark, so it isn't in it.
5055 // twig already resolves a boundary to whatever starts there — in
5056 // `**bold** x` offset 8 is the following text, not the strong — but
5057 // when nothing follows, the tie has nobody to break for and the
5058 // chain still ends at the mark. That would make the answer at the
5059 // last offset of the document depend on whether the file happens to
5060 // end in a newline; the rule is `span.start <= off < span.end`, and
5061 // it's the same rule at the end of a buffer as in the middle.
5062 .filter(|m| off < m.span.end)
5063 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5064 .collect()
5065 }
5066
5067 /// Toggle a heading at the caret: if the block is already this heading level,
5068 /// revert it to a paragraph; otherwise convert it to this heading level.
5069 /// This gives the heading commands the same toggle feel as bold/italic/code —
5070 /// re-applying a heading a line already has turns it back into body text.
5071 pub fn toggle_heading(&mut self, level: u32) {
5072 if self.current_heading_level() == Some(level) {
5073 self.set_block(BlockKind::Paragraph);
5074 } else {
5075 self.set_block(BlockKind::Heading(level));
5076 }
5077 }
5078
5079 /// Toggle a block quote around the selection, or around the block at the
5080 /// caret — the toolbar's Quote button.
5081 pub fn toggle_blockquote(&mut self) {
5082 self.toggle_container(BlockContainerKind::BlockQuote);
5083 }
5084
5085 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5086 /// over the block at the caret — one op with the kind as a flag, the way
5087 /// `toggle_heading` takes its level, so a frontend needs no twig type to
5088 /// name the two buttons.
5089 ///
5090 /// Pressing the *other* list's button while in a list converts in place
5091 /// rather than nesting, so the pair reads as one three-state control
5092 /// (bulleted / numbered / neither) rather than two independent wrappers.
5093 pub fn toggle_list(&mut self, ordered: bool) {
5094 self.toggle_container(if ordered {
5095 BlockContainerKind::OrderedList
5096 } else {
5097 BlockContainerKind::BulletList
5098 });
5099 }
5100
5101 // ── Task list items ──────────────────────────────────────────────────────
5102 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5103 // inline content of the item's first paragraph rather than part of its
5104 // marker, so adding or removing one must leave the item's continuation
5105 // indentation alone, and an item inside a quote is found past the quote
5106 // markers. leaf names the gesture and the offset; the spelling is twig's.
5107
5108 /// Whether the list item at the caret carries a checkbox, and which way it
5109 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5110 /// item or no item at all. What a toolbar reads to light its checkbox button.
5111 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5112 self.task_checked_at(self.caret)
5113 }
5114
5115 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5116 /// offset — what a frontend asks before deciding a click landed on a box.
5117 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5118 self.innermost_list_item(offset.min(self.source.len()))?
5119 .checked
5120 }
5121
5122 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5123 /// A no-op with a reported reason when the caret is in no task item — minting
5124 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5125 pub fn toggle_task_checked(&mut self) {
5126 self.toggle_task_at(self.caret);
5127 }
5128
5129 /// Tick or untick the task item covering `offset` — what a *click* on a
5130 /// rendered checkbox is. Separate from the caret form because a click carries
5131 /// its own offset and must not first move the caret there: ticking a box
5132 /// three paragraphs away should not take the cursor with it.
5133 pub fn toggle_task_at(&mut self, offset: usize) {
5134 // The read-only gate — this door reaches twig without the splice.
5135 if self.read_only {
5136 return;
5137 }
5138 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5139 return;
5140 }
5141 let offset = offset.min(self.source.len());
5142 self.record_caret();
5143 match self.editor.toggle_task_checked(offset) {
5144 Ok(_) => self.after_task_edit(),
5145 Err(e) => self.status = Some(format!("task: {e}")),
5146 }
5147 }
5148
5149 /// Give the list item at the caret a checkbox, or take its checkbox away —
5150 /// the gesture that converts between a plain bullet and a task. A new box
5151 /// arrives unticked.
5152 pub fn toggle_task_item(&mut self) {
5153 // The read-only gate — this door reaches twig without the splice.
5154 if self.read_only {
5155 return;
5156 }
5157 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5158 return;
5159 }
5160 let caret = self.caret.min(self.source.len());
5161 self.record_caret();
5162 match self.editor.toggle_task_item(caret) {
5163 Ok(_) => self.after_task_edit(),
5164 Err(e) => self.status = Some(format!("task: {e}")),
5165 }
5166 }
5167
5168 /// Settle after a task gesture. The caret rides its old byte offset and is
5169 /// clamped back in: a box is three or four bytes on the item's first line, so
5170 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5171 /// real stop either way.
5172 fn after_task_edit(&mut self) {
5173 self.last_edit_kind = None;
5174 self.refresh();
5175 self.anchor = None;
5176 self.dirty = self.source != self.clean_source;
5177 self.status = None;
5178 self.clamp_caret();
5179 self.record_caret();
5180 }
5181
5182 // ── Tables ───────────────────────────────────────────────────────────────
5183 // A table is a grid, and twig edits it as one — add/remove/move a row or
5184 // column, set a column's alignment — re-spelling the whole table in a single
5185 // splice. Every gesture is anchored at the caret's cell. leaf just names the
5186 // gesture and re-reads the result; the whole table's numbering, borders, and
5187 // delimiter are twig's to keep straight.
5188
5189 /// Whether the caret is inside a table — what a frontend asks to enable or
5190 /// disable its table controls.
5191 ///
5192 /// An HTML `<table>` still answers `true`: the caret really is in a table,
5193 /// and the reason the grid controls stay dark there is
5194 /// [`Capabilities::table`], which is a fact about the document's format
5195 /// rather than about the caret. A frontend needs both.
5196 pub fn caret_in_table(&mut self) -> bool {
5197 let caret = self.caret.min(self.source.len());
5198 self.editor
5199 .ancestors_at(caret)
5200 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5201 .unwrap_or(false)
5202 }
5203
5204 /// One grid op, guarded and settled — the shared body of the seven below.
5205 ///
5206 /// The guard is why this exists rather than seven copies of the same three
5207 /// lines, and it is the one guard leaf cannot delegate to twig. The table
5208 /// editor is the gesture family that consults no `Syntax` table (it spells a
5209 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5210 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5211 /// grid as a *pipe table* and reports success, swapping the element out for
5212 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5213 /// downstream could tell that from a successful edit — the splice is real,
5214 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5215 /// stopping at the door rather than detecting after the fact. See
5216 /// [`spells_pipe_tables`].
5217 fn table_op(
5218 &mut self,
5219 what: &str,
5220 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5221 ) {
5222 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5223 return;
5224 }
5225 self.record_caret();
5226 let at = self.caret;
5227 let r = op(&mut self.editor, at);
5228 self.apply_table(r, what);
5229 }
5230
5231 /// Insert an empty row below (`below`) or above the caret's row.
5232 pub fn table_insert_row(&mut self, below: bool) {
5233 self.table_op("table row", |e, at| e.table_insert_row(at, below));
5234 }
5235
5236 /// Delete the caret's row (not the header, not the last body row).
5237 pub fn table_delete_row(&mut self) {
5238 self.table_op("table row", |e, at| e.table_delete_row(at));
5239 }
5240
5241 /// Insert an empty column right (`right`) or left of the caret's column.
5242 pub fn table_insert_column(&mut self, right: bool) {
5243 self.table_op("table column", |e, at| e.table_insert_column(at, right));
5244 }
5245
5246 /// Delete the caret's column (unless it is the only one).
5247 pub fn table_delete_column(&mut self) {
5248 self.table_op("table column", |e, at| e.table_delete_column(at));
5249 }
5250
5251 /// Set the caret's column to `alignment`.
5252 pub fn table_set_alignment(&mut self, alignment: Alignment) {
5253 self.table_op("table alignment", |e, at| {
5254 e.table_set_alignment(at, alignment)
5255 });
5256 }
5257
5258 /// Move the caret's row one place down (`down`) or up, within the body rows.
5259 pub fn table_move_row(&mut self, down: bool) {
5260 self.table_op("table row", |e, at| e.table_move_row(at, down));
5261 }
5262
5263 /// Move the caret's column one place right (`right`) or left.
5264 pub fn table_move_column(&mut self, right: bool) {
5265 self.table_op("table column", |e, at| e.table_move_column(at, right));
5266 }
5267
5268 /// Settle the caret and document flags after a table op (or report its
5269 /// error). twig re-spells the whole table, so the caret rides its old byte
5270 /// offset and is clamped back into the rebuilt bytes — near enough to where
5271 /// it was, since the op preserves the cells' content and order around it.
5272 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5273 match result {
5274 Ok(()) => {
5275 self.last_edit_kind = None;
5276 self.refresh();
5277 self.anchor = None;
5278 self.clamp_caret();
5279 self.dirty = self.source != self.clean_source;
5280 self.status = None;
5281 self.record_caret();
5282 }
5283 Err(e) => self.status = Some(format!("{what}: {e}")),
5284 }
5285 }
5286
5287 /// One `toggle_block_container` over the block-level target.
5288 ///
5289 /// leaf says *where*; twig decides everything else — which blocks the range
5290 /// covers, whether that means wrapping, unwrapping, nesting or converting,
5291 /// and how this document's format spells the prefix. The rule that a
5292 /// container only comes off when the range covers every block it holds is
5293 /// what the re-anchoring below is built around.
5294 fn toggle_container(&mut self, kind: BlockContainerKind) {
5295 // The read-only gate — this door reaches twig without the splice.
5296 if self.read_only {
5297 return;
5298 }
5299 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5300 return;
5301 }
5302 let selected = self.selection();
5303 // A blank line holds no block, and twig opens an *empty* container on one
5304 // — since 3.2.0; it used to decline the range with `NotFound`, which is
5305 // why this used to lend it a scratch paragraph to wrap. Worth knowing
5306 // here because the line-for-line caret mapping below cannot describe it:
5307 // opening one under a paragraph writes the blank line the format needs
5308 // above the marker too, so the rewritten region has a line the old one
5309 // didn't, and "the same line, the same distance from its end" lands on
5310 // that new blank instead of in the container.
5311 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5312 // Without a selection the target is the caret's own block, resolved the
5313 // way `set_block` resolves it — a caret at a line end sits at the doc
5314 // level and has to be nudged back onto the block it looks like it's in.
5315 // An empty range is enough: twig widens to the whole lines it touches.
5316 let (start, end) = match selected {
5317 Some(range) => range,
5318 None => {
5319 let off = self.block_offset_for_caret().unwrap_or(self.caret);
5320 (off, off)
5321 }
5322 };
5323 self.record_caret();
5324 match self.editor.toggle_block_container(start, end, kind) {
5325 Ok(change) => {
5326 // Read the caret's place out of the *pre-edit* source, before
5327 // `refresh` swaps that source out from under it.
5328 let place = (selected.is_none() && !opened_empty)
5329 .then(|| self.caret_line_tail(&change.old));
5330 self.last_edit_kind = None; // structural edit is its own undo step
5331 self.refresh();
5332 match place {
5333 // Both land the caret at the far end of what twig wrote, and
5334 // differ only in what they leave selected.
5335 //
5336 // From a selection: select what the container now holds, the
5337 // way `toggle` keeps its marked region selected — and for a
5338 // stronger reason than symmetry: a container comes *off* only
5339 // a range covering every block it holds, so a selection left
5340 // on its old bytes (now short by a prefix per line) would nest
5341 // on the second press instead of reversing the first.
5342 //
5343 // From a blank line: nothing to select, and the end of the
5344 // region is exactly past the bare `> ` / `- ` twig wrote —
5345 // the caret standing inside the container that was asked for.
5346 None => {
5347 self.anchor = (!opened_empty).then_some(change.new.start);
5348 self.caret = change.new.end;
5349 }
5350 Some(place) => {
5351 self.anchor = None;
5352 self.caret = self.line_tail_offset(&change.new, place);
5353 }
5354 }
5355 self.dirty = self.source != self.clean_source;
5356 self.status = None;
5357 self.clamp_caret();
5358 self.record_caret();
5359 }
5360 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5361 }
5362 }
5363
5364 /// The caret's place inside the region a container toggle is rewriting, in
5365 /// the only terms the rewrite preserves: which of the region's lines it sits
5366 /// on, and how many bytes of that line lie ahead of it.
5367 ///
5368 /// A container's markup goes in at column 0 and never touches what follows
5369 /// on the line, so that pair survives the edit exactly where a byte offset
5370 /// does not — a caret left on its old offset slides back by one prefix per
5371 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
5372 /// `> ` it just asked for.
5373 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
5374 let caret = self.caret.clamp(old.start, old.end);
5375 let line = self.source[old.start..caret].matches('\n').count();
5376 let end = self.source[caret..old.end]
5377 .find('\n')
5378 .map_or(old.end, |i| caret + i);
5379 (line, end - caret)
5380 }
5381
5382 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
5383 /// region: the offset `tail` bytes back from the end of the region's `line`.
5384 ///
5385 /// Both walks are clamped rather than trusted, because the one op that does
5386 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
5387 /// the items back apart with blank lines between them — and a caret landing
5388 /// on the nearest line of the right item beats one landing out of the region
5389 /// entirely.
5390 fn line_tail_offset(
5391 &self,
5392 new: &std::ops::Range<usize>,
5393 (line, tail): (usize, usize),
5394 ) -> usize {
5395 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
5396 let mut start = 0;
5397 for _ in 0..line {
5398 match region[start..].find('\n') {
5399 Some(i) => start += i + 1,
5400 None => break,
5401 }
5402 }
5403 let end = region[start..]
5404 .find('\n')
5405 .map_or(region.len(), |i| start + i);
5406 new.start + end.saturating_sub(tail).max(start)
5407 }
5408
5409 /// Link the selection to `destination` — the toolbar's Link button. With no
5410 /// selection it acts at the caret, which re-points a link the caret is
5411 /// already standing in (twig replaces an existing link's destination and
5412 /// keeps its text) and otherwise spells a link that has no text of its own:
5413 /// an autolink (`<https://x.dev>`) where the destination is one, and
5414 /// `[destination](destination)` where it isn't.
5415 ///
5416 /// `destination` reaches twig raw. Escaping it is format knowledge and the
5417 /// two formats genuinely disagree — Markdown ends a destination at the first
5418 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
5419 /// itself — so the side holding the document is the side that gets to spell
5420 /// it. A destination twig can't carry at all (one with a newline) comes back
5421 /// as an error rather than a quietly rewritten URL.
5422 pub fn insert_link(&mut self, destination: &str) {
5423 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
5424 return;
5425 }
5426 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5427 self.record_caret();
5428 match self.editor.insert_link(start, end, destination) {
5429 Ok(change) => {
5430 self.last_edit_kind = None;
5431 self.refresh();
5432 match self.link_text_span(change.new.start) {
5433 // A link with text of its own: select it, so typing replaces
5434 // a `[dest](dest)`'s stand-in label and a second press
5435 // re-points what the first one linked.
5436 Some(text) => {
5437 self.anchor = (text.start != text.end).then_some(text.start);
5438 self.caret = text.end;
5439 }
5440 // An autolink is finished the moment it's written — its text
5441 // *is* the URL. Leaving it selected would aim the next press
5442 // at the one shape twig still wraps instead of re-points.
5443 None => {
5444 self.anchor = None;
5445 self.caret = change.new.end;
5446 }
5447 }
5448 self.dirty = self.source != self.clean_source;
5449 self.status = None;
5450 self.clamp_caret();
5451 self.record_caret();
5452 }
5453 Err(e) => self.status = Some(format!("link: {e}")),
5454 }
5455 }
5456
5457 /// Insert a block-level image at the caret: ``. Any
5458 /// selection becomes the alt text (so "select a caption, insert image" labels
5459 /// it); with no selection, `alt` is used — empty for none. The caret lands
5460 /// just past the inserted image.
5461 ///
5462 /// Both halves go through twig (`insert_literal` for the alt text,
5463 /// `insert_image` for the image), so neither is spelled here. That used to be a
5464 /// `format!`, and it was wrong the first time an app inserted a real filename:
5465 /// Markdown ends a destination at the first space, so `` is
5466 /// not an image at all — and the fix is per-format, since moving into the
5467 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
5468 pub fn insert_image(&mut self, destination: &str, alt: &str) {
5469 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5470 return;
5471 }
5472 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5473 self.record_caret();
5474 // With no selection and an explicit `alt`, the alt text has to exist in the
5475 // document before it can be the image's — and it is raw caller input, so
5476 // it goes in through `insert_literal`, which escapes it for the format
5477 // rather than letting a `]` in someone's caption close the image early.
5478 let (start, end) = if start == end && !alt.is_empty() {
5479 match self.editor.insert_literal(start, alt) {
5480 Ok(change) => (change.new.start, change.new.end),
5481 Err(e) => {
5482 self.status = Some(format!("image: {e}"));
5483 return;
5484 }
5485 }
5486 } else {
5487 (start, end)
5488 };
5489 match self.editor.insert_image(start, end, destination) {
5490 Ok(change) => {
5491 self.last_edit_kind = None;
5492 self.refresh();
5493 // Just past the image, nothing selected — where a caret belongs
5494 // after inserting one.
5495 self.anchor = None;
5496 self.caret = change.new.end;
5497 self.dirty = self.source != self.clean_source;
5498 self.status = None;
5499 self.clamp_caret();
5500 self.record_caret();
5501 }
5502 Err(e) => self.status = Some(format!("image: {e}")),
5503 }
5504 }
5505
5506 /// Insert a block-level image, video, or audio at the caret. The image case
5507 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5508 /// HTML elements, which is the only spelling Markdown and Djot have for them:
5509 ///
5510 /// ```text
5511 /// <video src="clip.mp4" controls>alt</video>
5512 /// <audio src="take.mp3" controls>alt</audio>
5513 /// ```
5514 ///
5515 /// HTML rather than a `::video{…}` directive deliberately. A directive means
5516 /// something only to an app that knows the vocabulary, so the document would
5517 /// read as literal punctuation everywhere else; `<video>` is what every other
5518 /// renderer already understands, and what leaf's own reader picks back up
5519 /// through `html_elements` promotion (see [`parse_extensions`]).
5520 ///
5521 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5522 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5523 /// `html_elements`. Before that only the multi-line form parsed as a block at
5524 /// all, and this wrote three lines to work around it.
5525 ///
5526 /// `controls` is always written: a player with no transport is a still frame
5527 /// the reader can't do anything with. Any selection becomes the element's
5528 /// fallback text, exactly as it becomes an image's alt.
5529 ///
5530 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5531 /// applies, and bites harder here: a `"` in `destination` closes the
5532 /// attribute. A frontend taking these from a file picker is fine; one taking
5533 /// them from free text should keep them tame.
5534 ///
5535 /// [`MediaInfo`]: crate::MediaInfo
5536 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5537 if kind == MediaKind::Image {
5538 return self.insert_image(destination, alt);
5539 }
5540 // Gated on the *image* gesture, not on one of its own — there isn't one,
5541 // since the bytes below are spelled here rather than by twig, and an HTML
5542 // document would in fact parse them. The button is one control with three
5543 // kinds behind it, and two of them working in a format where the third
5544 // cannot is a worse surface than three that agree — especially as
5545 // `insert_image` is the kind anyone reaches for first.
5546 if self.refuse_unsupported("media", Gesture::InsertImage) {
5547 return;
5548 }
5549 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5550 let alt_text = self
5551 .selected_text()
5552 .map(str::to_string)
5553 .unwrap_or_else(|| alt.to_string());
5554 let tag = match kind {
5555 MediaKind::Audio => "audio",
5556 _ => "video",
5557 };
5558 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5559 self.edit(start, end, &markup);
5560 }
5561
5562 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5563 /// button. Spelling and placement are both twig's; leaf used to write `---`
5564 /// itself, which was the Markdown spelling in a djot document too.
5565 ///
5566 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5567 /// mid-paragraph and lands it after the caret's whole block. To get a rule
5568 /// *at* the caret — the paragraph parted in two around it, which is what a
5569 /// rule button is understood to do — the paragraph is first divided with
5570 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5571 /// the offset `split_block` returns puts the rule after the *second* half
5572 /// instead, which is a rule in the right document and the wrong place.
5573 ///
5574 /// Only a plain paragraph is split, and only where there is something to
5575 /// part: at the paragraph's end the split has no second half to mint and
5576 /// would write the separator anyway — a blank line and the empty slot Enter
5577 /// leaves for the next paragraph, which the rule then lands above and
5578 /// nothing fills — so there the rule goes straight after the paragraph,
5579 /// which is where the split-and-aim was sending it regardless. At the
5580 /// paragraph's *start* the split is kept, though it parts nothing either:
5581 /// `|para` becomes `\npara` with the caret on the new blank line, and a
5582 /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5583 /// "before the paragraph" is said through a gesture that only knows
5584 /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5585 /// else the rule simply lands after the block, which is both twig's own
5586 /// answer and the better one: splitting a fenced code block would leave two
5587 /// fences with a rule between them, and splitting a list item would mint an
5588 /// item nobody asked for on the way to a rule that lands after the list
5589 /// regardless. A table and a setext heading refuse the split outright, so
5590 /// they take the same path by themselves.
5591 pub fn insert_thematic_break(&mut self) {
5592 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5593 {
5594 return;
5595 }
5596 self.caret = self.skip_trailing_close_delims(self.caret);
5597 // A selection is replaced by the rule, so collapse it first and let the
5598 // split-and-rule below run from the caret it leaves behind.
5599 if let Some((s, e)) = self.selection() {
5600 self.splice(s, e, "", EditKind::Other);
5601 }
5602 self.anchor = None;
5603 self.record_caret();
5604 let at = self.caret;
5605 if self.caret_parts_bare_paragraph() {
5606 // A failure here is not fatal: the rule still lands after the block,
5607 // which is exactly what this call was trying to improve on.
5608 let _ = self.editor.split_block(at);
5609 }
5610 match self.editor.insert_thematic_break(at) {
5611 Ok(change) => {
5612 self.last_edit_kind = None;
5613 self.refresh();
5614 self.anchor = None;
5615 self.caret = change.new.end;
5616 self.dirty = self.source != self.clean_source;
5617 self.status = None;
5618 self.clamp_caret();
5619 self.record_caret();
5620 }
5621 Err(e) => self.status = Some(format!("thematic break: {e}")),
5622 }
5623 }
5624
5625 /// Insert a fresh table at the caret — the toolbar's Table button. One
5626 /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5627 /// the document's own dialect and placed the way its thematic break is:
5628 /// after the caret's block, blank-separated. A bare paragraph is parted
5629 /// around the caret first, exactly as
5630 /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5631 /// table lands *at* the caret rather than after everything the caret's
5632 /// paragraph says.
5633 ///
5634 /// The caret ends in the first header cell, selected the way Tab selects
5635 /// a cell — the natural next act is to type the heading, and Tab then
5636 /// walks the grid. That cell is read back from the rebuilt table map
5637 /// rather than computed from the splice, because twig's blank line and
5638 /// quote prefix put the first bar at an offset only the reparse knows.
5639 ///
5640 /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5641 /// rows or columns is twig's refusal (a header with nothing under it is
5642 /// what its row delete refuses to leave), reported through `status`.
5643 pub fn insert_table(&mut self, rows: usize, cols: usize) {
5644 if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5645 return;
5646 }
5647 self.caret = self.skip_trailing_close_delims(self.caret);
5648 if let Some((s, e)) = self.selection() {
5649 self.splice(s, e, "", EditKind::Other);
5650 }
5651 self.anchor = None;
5652 self.record_caret();
5653 let at = self.caret;
5654 if self.caret_parts_bare_paragraph() {
5655 let _ = self.editor.split_block(at);
5656 }
5657 match self.editor.insert_table(at, rows, cols) {
5658 Ok(change) => {
5659 self.last_edit_kind = None;
5660 self.refresh();
5661 self.anchor = None;
5662 self.caret = change.new.end;
5663 self.dirty = self.source != self.clean_source;
5664 self.status = None;
5665 self.clamp_caret();
5666 // Into the first header cell of the table just written: the
5667 // first table whose grid begins inside the splice.
5668 self.rebuild_map();
5669 let first_cell = self
5670 .vmap
5671 .tables
5672 .iter()
5673 .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5674 .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5675 .map(|cell| (cell.start, cell.end));
5676 if let Some((start, end)) = first_cell {
5677 self.select_cell(start, end);
5678 }
5679 self.record_caret();
5680 }
5681 Err(e) => self.status = Some(format!("table: {e}")),
5682 }
5683 }
5684
5685 /// Whether the caret sits in a paragraph and nothing else — no list item, no
5686 /// quote, no fence, no table — with paragraph text still ahead of it. The
5687 /// one shape where parting the block around the caret is unambiguously what
5688 /// a rule button means; see
5689 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5690 /// container is left to take the rule after itself.
5691 ///
5692 /// The "text ahead" half is what keeps `split_block` from running at the
5693 /// one edge where its output composes badly. At a paragraph's end twig
5694 /// cannot mint the empty second half (no format spells an empty
5695 /// paragraph), so it writes only the separator — a blank line and the
5696 /// slot Enter leaves for the paragraph to come — and a block then aimed at
5697 /// the first half lands above a slot that nothing fills: `para\n` with the
5698 /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
5699 /// as nothing ahead, since the split would shed it as the second half's
5700 /// leading indent and leave the same slot. Which end of the newline a
5701 /// paragraph's span stops at differs between the formats (Markdown before
5702 /// it, djot after), which is why this reads the remaining bytes rather
5703 /// than comparing offsets. The paragraph's
5704 /// start is deliberately not the same case — see
5705 /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
5706 /// split is kept.
5707 fn caret_parts_bare_paragraph(&mut self) -> bool {
5708 let caret = self.caret.min(self.source.len());
5709 let Ok(chain) = self.editor.ancestors_at(caret) else {
5710 return false;
5711 };
5712 let mut para_end = None;
5713 for m in chain {
5714 match m.kind {
5715 Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
5716 Kind::ListItem
5717 | Kind::TaskListItem
5718 | Kind::BlockQuote
5719 | Kind::CodeBlock
5720 | Kind::Table => return false,
5721 _ => {}
5722 }
5723 }
5724 match para_end {
5725 Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
5726 _ => false,
5727 }
5728 }
5729
5730 /// The destination of the link under the caret — what a Link prompt shows so
5731 /// ⌘K on an existing link edits its URL instead of asking for it again.
5732 /// `None` when the caret stands in no link.
5733 ///
5734 /// An autolink carries no separate destination: its text *is* the URL, so
5735 /// that's what comes back for one.
5736 pub fn link_destination_at_caret(&mut self) -> Option<String> {
5737 self.link_destination_at(self.caret)
5738 }
5739
5740 /// The destination of the link at `off`.
5741 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
5742 /// the caret isn't.
5743 ///
5744 /// The offset form exists for the same reason
5745 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
5746 /// the document somewhere else — a footnote's text in a popover, say — has
5747 /// rows and runs but no caret in them, and still needs to know which of those
5748 /// runs a reader can follow.
5749 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
5750 self.nodes()
5751 .into_iter()
5752 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
5753 .filter(|n| n.span.start <= off && off < n.span.end)
5754 .max_by_key(|n| n.span.start)
5755 .and_then(|n| n.destination.or(n.text))
5756 }
5757
5758 /// Where the locator `id` lands in this document — the `#v2` half of a
5759 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
5760 /// answers to it.
5761 ///
5762 /// The other end of a link, and the reason this exists: without it a
5763 /// destination has only file granularity, so following a citation into a
5764 /// chapter drops the reader at the top of it to hunt for the verse. Which is
5765 /// also why it is a *document* query rather than a caret one — the document
5766 /// being asked is usually not the one the reader is in.
5767 ///
5768 /// Three readings, tried in order, because the same `#some-heading` is
5769 /// written three ways across the formats leaf opens:
5770 ///
5771 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
5772 /// the auto-ids djot mints for its headings. The only exact answer, so it
5773 /// goes first — a document that says `{#v1}` has settled the question.
5774 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
5775 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
5776 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
5777 /// authored anywhere land on a djot heading.
5778 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
5779 /// none and `{#custom}` is literal text in a Markdown heading — so for
5780 /// the format most vaults are written in, the heading's own words are the
5781 /// only thing a fragment can name. This is the rule every Markdown
5782 /// renderer already follows, which is what makes `#a-heading` mean in
5783 /// diaryx what it means on the web.
5784 ///
5785 /// Ties go to the earliest match, then to the widest: a duplicated id is the
5786 /// document's mistake and the first one is the answer every anchor
5787 /// implementation gives, while preferring the wider span picks the section
5788 /// over the heading that opens it — more for a peek to show, same place to
5789 /// land.
5790 pub fn locate(&mut self, id: &str) -> Option<Landing> {
5791 let id = id.trim();
5792 if id.is_empty() {
5793 return None;
5794 }
5795 let nodes = self.nodes();
5796
5797 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
5798 // is picking, among nodes that start together, the one that ends last.
5799 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
5800 matches
5801 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
5802 .map(|n| Landing {
5803 start: n.span.start,
5804 end: n.span.end,
5805 })
5806 };
5807
5808 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
5809 return Some(landing);
5810 }
5811 let want = slug(id);
5812 if want.is_empty() {
5813 return None;
5814 }
5815 if let Some(landing) = pick(
5816 &mut nodes
5817 .iter()
5818 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
5819 ) {
5820 return Some(landing);
5821 }
5822
5823 // A heading by its words. Its span is one line, so the end comes from
5824 // where the *section* it opens gives out — the next heading that is not
5825 // under it, or the end of the document. A Markdown heading has no
5826 // section node to ask (twig only builds those for djot), and a peek that
5827 // showed the heading alone would answer "what does that say" with the
5828 // title of the thing it says.
5829 let heading = nodes
5830 .iter()
5831 .filter(|n| n.kind == Kind::Heading)
5832 .filter(|n| {
5833 n.content_span
5834 .clone()
5835 .and_then(|s| self.source.get(s))
5836 .is_some_and(|text| slug(text) == want)
5837 })
5838 .min_by_key(|n| n.span.start)?;
5839 let level = heading.level.unwrap_or(u32::MAX);
5840 let end = nodes
5841 .iter()
5842 .filter(|n| n.kind == Kind::Heading)
5843 .filter(|n| n.span.start > heading.span.start)
5844 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
5845 .map(|n| n.span.start)
5846 .min()
5847 .unwrap_or(self.source.len());
5848 Some(Landing {
5849 start: heading.span.start,
5850 end,
5851 })
5852 }
5853
5854 /// Write a footnote at the caret — the toolbar's Footnote button, and the
5855 /// one gesture in the footnote story that *authors* rather than follows.
5856 ///
5857 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
5858 /// the `[^1]:` definition at the end of the document. Half a footnote is not
5859 /// a footnote — a bare reference with nothing defining it renders as literal
5860 /// brackets — so a single button that wrote only the reference would leave
5861 /// the author to hand-spell the other half in a document that had just
5862 /// stopped showing them what the first half meant. One edit also means one
5863 /// undo takes both back.
5864 ///
5865 /// The definition's body is left empty and **the caret lands in it**, which
5866 /// is the whole point of pressing the button: nobody wants a reference to a
5867 /// note they have not written yet. Getting back to where they were writing
5868 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
5869 /// the same return leg a reader following a reference already uses, so the
5870 /// author is left standing on the near end of a round trip that works.
5871 ///
5872 /// A selection collapses to its *end* rather than being replaced: a
5873 /// reference annotates the words before it, so "select the claim, add a
5874 /// footnote" should mark that claim, not consume it.
5875 pub fn insert_footnote(&mut self) {
5876 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
5877 return;
5878 }
5879 let at = self.selection().map_or(self.caret, |(_, end)| end);
5880 self.anchor = None;
5881 self.caret = at;
5882 self.record_caret();
5883 let label = self.next_footnote_label();
5884 match self.editor.insert_footnote(at, &label) {
5885 Ok(change) => {
5886 self.last_edit_kind = None;
5887 self.refresh();
5888 self.anchor = None;
5889 // `change.new` runs from the reference to the end of the
5890 // document, so its start is the `[^1]` just written and
5891 // `footnote_at` resolves it to the note the same way a reader's
5892 // tap does — and to the note's *body*, which is already a caret
5893 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
5894 // and has none), so this needs no snap on top. The fallback is
5895 // the reference's own offset: a format that spelled the pair some
5896 // way leaf can't read back should still leave the caret on the
5897 // edit rather than at the far end of a document it just grew.
5898 self.caret = self
5899 .footnote_at(change.new.start)
5900 .and_then(|note| note.offset)
5901 .unwrap_or(change.new.start);
5902 self.dirty = self.source != self.clean_source;
5903 self.status = None;
5904 self.clamp_caret();
5905 self.record_caret();
5906 }
5907 Err(e) => self.status = Some(format!("footnote: {e}")),
5908 }
5909 }
5910
5911 /// The label to give a footnote the author has not named: the lowest counting
5912 /// number no footnote in the document is already wearing.
5913 ///
5914 /// twig takes the label rather than minting one, because it holds no opinion
5915 /// about what a document's footnotes should be called — and it is right not
5916 /// to. Numbering them is what every author of a numbered note expects, and
5917 /// re-using a taken number would silently point the new reference at somebody
5918 /// else's note (twig reuses an existing definition rather than appending a
5919 /// second one, which is the right rule for citing a note twice on purpose and
5920 /// exactly the wrong accident to have by default).
5921 ///
5922 /// *References* are counted alongside definitions, not just definitions: a
5923 /// document carrying a dangling `[^2]` has a 2 that means something to
5924 /// whoever wrote it, and minting a definition for it here would answer a
5925 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
5926 /// count entirely — they take no number, so they block none.
5927 fn next_footnote_label(&mut self) -> String {
5928 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
5929 .into_iter()
5930 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
5931 .filter_map(|label| label.parse().ok())
5932 .collect();
5933 taken.extend(
5934 self.nodes()
5935 .into_iter()
5936 .filter(|n| n.kind == Kind::FootnoteReference)
5937 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
5938 .filter_map(|label| label.parse::<u32>().ok()),
5939 );
5940 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
5941 }
5942
5943 /// The footnote reference under the caret, resolved to the note it names.
5944 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
5945 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
5946 self.footnote_at(self.caret)
5947 }
5948
5949 /// The footnote reference at `off`, resolved to the note it names — what a
5950 /// frontend shows when a reader activates a `[^1]`.
5951 ///
5952 /// A reference is not a link node, so
5953 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
5954 /// (and should not) answer for one: a link names a destination to leave for,
5955 /// a reference names a note that is already in this document. Following one
5956 /// is a move within the page, which is why this hands back an `offset`
5957 /// rather than something to open.
5958 ///
5959 /// Offset-based rather than caret-only because the gesture that wants this
5960 /// most is the one that must not move the caret: a pointer hovering a `[1]`
5961 /// asks what note it names without disturbing where the reader was typing.
5962 /// The caret is just the offset a click already placed —
5963 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
5964 ///
5965 /// `None` when `off` stands in no reference. A reference whose note the
5966 /// document never defines is *not* `None` — it answers with the label it
5967 /// looked for and no text, which is what lets a frontend say so instead of
5968 /// silently doing nothing.
5969 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
5970 // Innermost-wins by latest start, the rule its link sibling uses.
5971 let span = self
5972 .nodes()
5973 .into_iter()
5974 .filter(|n| n.kind == Kind::FootnoteReference)
5975 .filter(|n| n.span.start <= off && off < n.span.end)
5976 .max_by_key(|n| n.span.start)?
5977 .span;
5978 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
5979
5980 // The note itself. Definitions are roots beside `doc` rather than
5981 // children of it, so they're asked for directly — see
5982 // `wysiwyg::footnote_definitions`.
5983 let note = wysiwyg::footnote_definitions(&mut self.editor)
5984 .into_iter()
5985 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
5986 let Some(note) = note else {
5987 return Some(FootnoteRef {
5988 label,
5989 text: None,
5990 offset: None,
5991 end: None,
5992 });
5993 };
5994 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
5995 Some(FootnoteRef {
5996 label,
5997 text: body
5998 .clone()
5999 .and_then(|b| self.source.get(b))
6000 .map(str::to_string),
6001 // The body's start, not the definition's — see `FootnoteRef::offset`.
6002 offset: body.clone().map(|b| b.start),
6003 end: body.map(|b| b.end),
6004 })
6005 }
6006
6007 /// The footnote *definition* the caret stands in, and where the reference
6008 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6009 /// at the caret's offset.
6010 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6011 self.footnote_definition_at(self.caret)
6012 }
6013
6014 /// The footnote definition spanning `off`, and where the reference that
6015 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6016 ///
6017 /// The mirror image, deliberately: the same gesture that takes a reader from
6018 /// `[1]` down to the note takes them from the note back up to `[1]`, so
6019 /// following a footnote is a round trip rather than a fall. It needs no
6020 /// memory of how the reader arrived — the document says where the reference
6021 /// is — which is what makes it work for a reader who scrolled to the notes
6022 /// themselves, and what keeps it right after an edit moves either end.
6023 ///
6024 /// `None` when `off` stands in no definition. A definition nothing cites is
6025 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6026 /// its label and no offset, so a frontend can say "nothing refers to this"
6027 /// rather than offer a jump that goes nowhere.
6028 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6029 // Definitions are roots beside `doc`, so `nodes()` — which walks the
6030 // document body — never reports one. They're asked for directly, the way
6031 // `footnote_at` asks for the note it resolves to.
6032 //
6033 // Closed at the end, unlike the half-open test its neighbours use. A
6034 // definition's span stops at its last content byte — the newline ending
6035 // the line is outside it — so `span.end` is the caret stop at the end of
6036 // the note's own row, not the first byte of anything after. Excluding it
6037 // meant the one caret an author is guaranteed to have, the one left
6038 // sitting at the end of the note they just typed, was in no definition at
6039 // all: writing a note and then asking to go back to its reference
6040 // answered nothing. Two definitions in a row still can't both match —
6041 // there is a blank line between them — and `max_by_key` decides anyway.
6042 let note = wysiwyg::footnote_definitions(&mut self.editor)
6043 .into_iter()
6044 .filter(|m| m.span.start <= off && off <= m.span.end)
6045 .max_by_key(|m| m.span.start)?;
6046 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6047
6048 // The earliest reference carrying this label. `min` rather than a `find`,
6049 // because `nodes()` reports a flattened walk whose order is twig's
6050 // business, not document order. Bound first: the walk needs `&mut self`
6051 // and reading the labels back out needs `&self.source`.
6052 let nodes = self.nodes();
6053 let offset = nodes
6054 .into_iter()
6055 .filter(|n| n.kind == Kind::FootnoteReference)
6056 .filter(|n| {
6057 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6058 })
6059 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6060 .map(|n| n.span.start + 2)
6061 .min();
6062 Some(FootnoteDef { label, offset })
6063 }
6064
6065 /// The destination of the image under the caret — what an image prompt shows
6066 /// so editing an existing image starts from its current URL instead of blank,
6067 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6068 /// `None` when the caret stands in no image. A caret resting just after a
6069 /// block image (its trailing stop) is still "in" it — the half-open span test
6070 /// excludes that offset, which is the intended precision: past the image is
6071 /// past it.
6072 pub fn image_destination_at_caret(&mut self) -> Option<String> {
6073 let off = self.caret;
6074 self.nodes()
6075 .into_iter()
6076 .filter(|n| n.kind == Kind::Image)
6077 .filter(|n| n.span.start <= off && off < n.span.end)
6078 .max_by_key(|n| n.span.start)
6079 .and_then(|n| n.destination)
6080 }
6081
6082 /// The language of the fenced code block the caret stands in — what a
6083 /// language prompt shows so editing it starts from the current value rather
6084 /// than blank. `None` when the caret is in no code block, or in one whose
6085 /// fence carries no language (or an indented block, which has no fence).
6086 pub fn code_language_at_caret(&mut self) -> Option<String> {
6087 let start = self.code_block_start_at_caret()?;
6088 wysiwyg::code_language(&self.source, start)
6089 }
6090
6091 /// Whether the caret stands in a fenced code block — the one a language
6092 /// prompt could edit. A frontend gates its "set language" affordance on this
6093 /// (an indented block, which can't carry a language, reports `false`).
6094 pub fn caret_in_fenced_code(&mut self) -> bool {
6095 self.code_block_start_at_caret()
6096 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6097 }
6098
6099 /// Set (or clear, with `""`) the language of the fenced code block the caret
6100 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6101 /// block, and a reported error for a language the format's fence cannot
6102 /// carry.
6103 ///
6104 /// twig rewrites the info string, so the fence's own width — measured
6105 /// against a body neither side touches — is kept, and a language holding a
6106 /// space, a line end or the fence character is refused rather than written
6107 /// out to reparse as something else. Leaf used to splice over the info span
6108 /// itself and `trim()` the input, which handled the one bad case it had
6109 /// thought of.
6110 pub fn set_code_language(&mut self, lang: &str) {
6111 // The read-only gate — this door reaches twig without the splice.
6112 if self.read_only {
6113 return;
6114 }
6115 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6116 return;
6117 }
6118 if self.code_block_start_at_caret().is_none() {
6119 return;
6120 }
6121 let lang = lang.trim();
6122 // `None` clears the info string; `Some("")` asks for an empty one. Both
6123 // write a bare fence, and the prompt's empty value means "clear".
6124 let want = (!lang.is_empty()).then_some(lang);
6125 self.record_caret();
6126 match self.editor.set_code_language(self.caret, want) {
6127 Ok(_) => {
6128 self.last_edit_kind = None;
6129 self.refresh();
6130 self.anchor = None;
6131 self.dirty = self.source != self.clean_source;
6132 self.status = None;
6133 self.clamp_caret();
6134 self.record_caret();
6135 }
6136 Err(e) => self.status = Some(format!("code language: {e}")),
6137 }
6138 }
6139
6140 /// The `span.start` of the code block covering the caret — the anchor
6141 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6142 /// in none.
6143 fn code_block_start_at_caret(&mut self) -> Option<usize> {
6144 let off = self.caret;
6145 self.nodes()
6146 .into_iter()
6147 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6148 .max_by_key(|n| n.span.start)
6149 .map(|n| n.span.start)
6150 }
6151
6152 /// The source range of the text inside the link covering `off` — what sits
6153 /// between its `[` and `]`. `None` when twig reports no link there.
6154 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6155 self.nodes()
6156 .into_iter()
6157 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6158 // the other's `span.start`; the link that starts latest at or before
6159 // `off` is the one `off` is actually in.
6160 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6161 .max_by_key(|n| n.span.start)
6162 .and_then(|n| n.content_span)
6163 }
6164
6165 // ── undo / redo ───────────────────────────────────────────────────────────
6166 // twig owns the history of *bytes* (it owns the buffer) and now carries the
6167 // caret through it too: `record_caret` stashes each state's caret in twig's
6168 // opaque per-step blob, and undo/redo hand it back with the source they
6169 // restore. So leaf keeps no history of its own — no parallel stacks to march
6170 // in lockstep and silently drift out of it.
6171
6172 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6173 /// where they were when that step began.
6174 pub fn undo(&mut self) {
6175 if self.read_only {
6176 return;
6177 }
6178 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6179 match self.editor.undo() {
6180 Ok(Some(change)) => {
6181 self.after_history(change);
6182 // `refresh` counted the restore as an edit; it was a step back.
6183 self.undo_steps = undone.saturating_sub(1);
6184 self.redo_steps = redoable + 1;
6185 }
6186 Ok(None) => {
6187 self.undo_steps = 0;
6188 self.status = Some("nothing to undo".into());
6189 }
6190 Err(e) => self.status = Some(format!("undo: {e}")),
6191 }
6192 }
6193
6194 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6195 /// selection back where that step originally left them.
6196 pub fn redo(&mut self) {
6197 if self.read_only {
6198 return;
6199 }
6200 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6201 match self.editor.redo() {
6202 Ok(Some(change)) => {
6203 self.after_history(change);
6204 // `refresh` counted the restore as an edit; it was a step forward.
6205 self.undo_steps = undone + 1;
6206 self.redo_steps = redoable.saturating_sub(1);
6207 }
6208 Ok(None) => {
6209 self.redo_steps = 0;
6210 self.status = Some("nothing to redo".into());
6211 }
6212 Err(e) => self.status = Some(format!("redo: {e}")),
6213 }
6214 }
6215
6216 /// Refresh the cached source and put the caret back where the step being
6217 /// undone/redone had it, clearing any active run.
6218 ///
6219 /// The caret comes from twig's blob for the restored state (what
6220 /// `record_caret` stored). `change` is only the fallback for a state with no
6221 /// blob — a caret at the end of the restored text, which is where this always
6222 /// landed before the blobs were kept. It is the edit site, not where the user
6223 /// was standing, so it's a floor and not the behaviour: undoing should hand
6224 /// back the document *and* the place you were working, which for an edit made
6225 /// anywhere but under the caret are two different places.
6226 fn after_history(&mut self, change: Change) {
6227 self.refresh();
6228 match self
6229 .editor
6230 .caret_blob()
6231 .ok()
6232 .and_then(|b| CaretState::from_blob(&b))
6233 {
6234 Some(state) => {
6235 self.caret = state.caret.min(self.source.len());
6236 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6237 }
6238 None => {
6239 self.caret = change.new.end.min(self.source.len());
6240 self.anchor = None;
6241 }
6242 }
6243 self.goal_col = None;
6244 self.last_edit_kind = None;
6245 self.dirty = self.source != self.clean_source;
6246 self.status = None;
6247 self.clamp_caret();
6248 }
6249
6250 // ── the file ──────────────────────────────────────────────────────────────
6251
6252 #[cfg(feature = "fs")]
6253 pub fn save(&mut self) {
6254 if self.is_untitled() {
6255 // No path to write and no name to invent: ⌘S on an untitled document
6256 // is a Save As, and only a frontend has a picker to ask with. Say so
6257 // rather than failing at the filesystem with an empty path.
6258 self.status = Some("untitled — save as…".into());
6259 return;
6260 }
6261 let path = self.path.clone();
6262 if self.write(&path) {
6263 self.mark_saved();
6264 }
6265 }
6266
6267 /// Save As: write the document to `path` and *move* it there — `self.path`
6268 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6269 /// what Save As means; a copy would leave the user editing a document whose
6270 /// name is no longer where their keystrokes go.
6271 ///
6272 /// The move only happens if the bytes actually landed. A failed write leaves
6273 /// the path, `dirty`, and the disk watermark exactly as they were, with the
6274 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6275 /// must never come away believing it was saved.
6276 ///
6277 /// An existing `path` is overwritten, and the caller is the one that knows
6278 /// whether to ask first: a Save As picker has already run that prompt, and a
6279 /// second confirmation from down here would be the same question twice.
6280 ///
6281 /// `format` does **not** follow the new extension. The buffer is parsed as
6282 /// the format it was opened with, and re-reading it as another one is a
6283 /// conversion — a different, lossy operation that would throw away the undo
6284 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6285 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6286 /// until it's reopened.
6287 #[cfg(feature = "fs")]
6288 pub fn save_as(&mut self, path: PathBuf) {
6289 if !self.write(&path) {
6290 return;
6291 }
6292 self.path = path;
6293 self.mark_saved();
6294 }
6295
6296 /// Put `source` on disk at `path`, reporting whether it got there. The one
6297 /// place leaf writes a document, so a save and a Save As can't disagree
6298 /// about what a failure looks like.
6299 #[cfg(feature = "fs")]
6300 fn write(&mut self, path: &Path) -> bool {
6301 match std::fs::write(path, self.source.as_bytes()) {
6302 Ok(()) => true,
6303 Err(e) => {
6304 self.status = Some(format!("save failed: {e}"));
6305 false
6306 }
6307 }
6308 }
6309
6310 /// Re-base the document's saved watermark to the current bytes: clears
6311 /// `dirty`, records `source` as the new clean state (so undoing back to here
6312 /// clears the flag again), and re-stamps the on-disk hash.
6313 ///
6314 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
6315 /// the hook a **filesystem-free host** calls itself once it has persisted
6316 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
6317 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
6318 /// are already where that host wants them, and this just tells the model they
6319 /// are safe.
6320 pub fn mark_saved(&mut self) {
6321 self.clean_source = self.source.clone();
6322 self.dirty = false;
6323 // The bytes on disk are now ours, so this is the new watermark: without
6324 // re-stamping it, every save would report its own work as an external
6325 // change forever after.
6326 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
6327 self.status = Some(format!("saved {}", self.file_name()));
6328 }
6329
6330 /// What the file looks like now against the bytes leaf last read or wrote.
6331 ///
6332 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
6333 /// so this is a filesystem round-trip, not a per-frame question — ask it
6334 /// when a window regains focus, on a timer, or before a save.
6335 ///
6336 /// This *only* reports the file. Whether the document also has unsaved edits
6337 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
6338 /// [`DiskState::Changed`] means a save overwrites someone's work and a
6339 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
6340 /// it has no way to ask — so it hands a frontend both halves and lets it put
6341 /// the question to the person who can answer it.
6342 #[cfg(feature = "fs")]
6343 pub fn disk_state(&self) -> DiskState {
6344 let Some(want) = self.disk_hash else {
6345 return DiskState::Untitled;
6346 };
6347 match std::fs::read(&self.path) {
6348 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
6349 Ok(_) => DiskState::Changed,
6350 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
6351 Err(_) => DiskState::Unreadable,
6352 }
6353 }
6354
6355 /// Re-read the file and replace the document with what's there — the other
6356 /// answer to a [`DiskState::Changed`].
6357 ///
6358 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
6359 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
6360 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
6361 /// document shouldn't have to argue with a guard.
6362 ///
6363 /// **The undo history survives, and the reload is one step in it.** The
6364 /// whole buffer is spliced with the file's bytes through the same door every
6365 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
6366 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
6367 /// swapped the document out from under a reader gives them back what they
6368 /// were looking at, marked dirty, and ^Z again carries on into whatever they
6369 /// had done before it. This used to build a fresh parse and drop the stack,
6370 /// on the reasoning that twig's history belongs to the buffer and these are
6371 /// different bytes; that is true of *rebasing* a step onto them and not of
6372 /// recording the swap itself as one, which is all this is. A splice twig
6373 /// won't take falls back to the fresh parse, and only that path still costs
6374 /// the history.
6375 ///
6376 /// The caret keeps its byte offset, clamped to the new length; the selection
6377 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
6378 /// file changed, so it can't know where the caret "still" is. Clamping keeps
6379 /// it where the user left it in the common case (a change further down the
6380 /// file, or none in the text they're sitting in), and never puts it
6381 /// somewhere invalid. A selection has two such offsets and no such excuse —
6382 /// silently reinterpreting one over changed bytes would arm the *next*
6383 /// keystroke to delete something the user never selected.
6384 ///
6385 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
6386 /// document alone with a status.
6387 #[cfg(feature = "fs")]
6388 pub fn reload(&mut self) {
6389 if self.is_untitled() {
6390 self.status = Some("no file to reload".into());
6391 return;
6392 }
6393 let bytes = match std::fs::read(&self.path) {
6394 Ok(b) => b,
6395 Err(e) => {
6396 self.status = Some(format!("reload failed: {e}"));
6397 return;
6398 }
6399 };
6400 let Ok(source) = String::from_utf8(bytes) else {
6401 self.status = Some("reload failed: file is not UTF-8".into());
6402 return;
6403 };
6404 // Already these bytes — someone saved a file back unchanged, or leaf's
6405 // own write is being read back. Re-baseline against it and stop: a
6406 // splice of the text onto itself would put an undo step on the stack for
6407 // something nobody did.
6408 if source == self.source {
6409 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6410 self.clean_source = source;
6411 self.dirty = false;
6412 self.status = Some(format!("reloaded {}", self.file_name()));
6413 return;
6414 }
6415 let caret = self.caret;
6416 // The pre-reload caret, so undoing the swap puts it back where the
6417 // reader was standing — the same bracketing `splice_exact` does.
6418 self.record_caret();
6419 if self
6420 .editor
6421 .edit_range(0, self.source.len(), &source)
6422 .is_ok()
6423 {
6424 self.refresh();
6425 } else {
6426 // twig wouldn't take the splice. Start over from the bytes, which is
6427 // what this always did, and is the one path that still costs the
6428 // history — `format` is the format this document *is*, not what the
6429 // (unchanged) name now says, see `save_as`.
6430 match new_editor(source.as_bytes(), self.format) {
6431 Ok(editor) => {
6432 self.editor = editor;
6433 self.source = source.clone();
6434 // Not going through `refresh`, so the revision has to move
6435 // here or every frontend keeps painting the old file from
6436 // cache.
6437 self.revision += 1;
6438 }
6439 Err(e) => {
6440 self.status = Some(format!("reload failed: {e}"));
6441 return;
6442 }
6443 }
6444 }
6445 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6446 self.clean_source = self.source.clone();
6447 self.caret = caret.min(self.source.len());
6448 self.anchor = None;
6449 self.goal_col = None;
6450 self.last_edit_kind = None;
6451 self.dirty = false;
6452 self.status = Some(format!("reloaded {}", self.file_name()));
6453 self.clamp_caret();
6454 // And the post-reload caret, so a redo restores it.
6455 self.record_caret();
6456 }
6457
6458 /// Re-read the source from twig after it has changed the document. The one
6459 /// funnel every edit, undo, and redo comes through — so it's where the
6460 /// revision moves, and anything cached against the text dies here.
6461 fn refresh(&mut self) {
6462 if let Ok(s) = self.editor.source_str() {
6463 self.source = s;
6464 }
6465 self.revision += 1;
6466 // An edit is a step onto the history and the end of anything undone;
6467 // `undo`/`redo` come through here too and correct this after.
6468 self.undo_steps += 1;
6469 self.redo_steps = 0;
6470 self.clamp_caret();
6471 }
6472
6473 /// Whether [`undo`](Self::undo) has a step to take back — for a native
6474 /// Edit menu to enable its item by. See the note on `undo_steps` for what
6475 /// "has" means here.
6476 pub fn can_undo(&self) -> bool {
6477 !self.read_only && self.undo_steps > 0
6478 }
6479
6480 /// Whether [`redo`](Self::redo) has an undone step to restore.
6481 pub fn can_redo(&self) -> bool {
6482 !self.read_only && self.redo_steps > 0
6483 }
6484
6485 // ── caret movement ─────────────────────────────────────────────────────────
6486 // `extend` grows the selection (Shift+motion): it pins the anchor on the
6487 // first extended step and moves only the caret; an un-extended motion drops
6488 // the selection.
6489
6490 /// Place the caret at byte `offset` (clamped to a char boundary), extending
6491 /// the selection when `extend` is set. The public form of `move_to`, for a
6492 /// frontend that hit-tests pixels straight to a source offset.
6493 pub fn place_caret(&mut self, offset: usize, extend: bool) {
6494 self.goal_col = None;
6495 let before = self.caret;
6496 // A pixel hit-test can land between the visible caret stops — in the
6497 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6498 // Snap to the nearest real stop so the caret can't come to rest where it
6499 // would draw in one place and type in another. The `(row, col)` click
6500 // path (`click`) already snaps this way through `offset_of_pos`; the
6501 // source view reaches every byte, so it snaps to nothing.
6502 let target = match self.view {
6503 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6504 // The source view reaches every byte, so there is no stop to snap
6505 // to — but "every byte" still means every *character* boundary. A
6506 // caret resting inside a multi-byte character draws nowhere real
6507 // and panics the next time anything slices there.
6508 View::Source => self.char_boundary_at_or_before(offset),
6509 };
6510 self.move_to(target, extend);
6511 self.clamp_caret();
6512 self.debug_assert_on_a_stop(before);
6513 }
6514
6515 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6516 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6517 /// grab the metadata) while the source view still selects the literal whole.
6518 pub fn select_all(&mut self) {
6519 self.anchor = Some(self.caret_floor());
6520 self.caret = self.source.len();
6521 self.goal_col = None;
6522 self.last_edit_kind = None;
6523 self.status = None;
6524 }
6525
6526 /// Select the word (or whitespace / punctuation run) at `offset` — the
6527 /// double-click gesture. Anchors on the run's start with the caret at its
6528 /// end so a following Shift-motion extends from the far edge.
6529 pub fn select_word_at(&mut self, offset: usize) {
6530 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6531 self.anchor = Some(s);
6532 self.caret = e;
6533 self.goal_col = None;
6534 self.last_edit_kind = None;
6535 self.status = None;
6536 self.clamp_caret();
6537 }
6538
6539 /// Select the whole enclosing text block (paragraph, heading, list item's
6540 /// text…) at `offset` — the triple-click gesture. Reads the range straight
6541 /// from the AST (twig's `content_span`), so it selects the entire *logical*
6542 /// paragraph even when that paragraph soft-wraps across several visual rows —
6543 /// where a visual-row-based select breaks down, because one source offset at
6544 /// a wrap boundary belongs to two rows at once.
6545 pub fn select_block_at(&mut self, offset: usize) {
6546 let off = offset.min(self.source.len());
6547 let range = self
6548 .editor
6549 .ancestors_at(off)
6550 .ok()
6551 .and_then(|chain| {
6552 // Ancestors run root → deepest; the deepest node that is neither
6553 // an inline span nor a multi-block container is the text block
6554 // the caret sits in (a paragraph, a heading, a code block…).
6555 chain
6556 .into_iter()
6557 .rev()
6558 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6559 .map(|m| m.content_span.unwrap_or(m.span))
6560 })
6561 .unwrap_or_else(|| source_line_range(&self.source, off));
6562 self.anchor = Some(range.start.min(self.source.len()));
6563 self.caret = range.end.min(self.source.len());
6564 self.goal_col = None;
6565 self.last_edit_kind = None;
6566 self.status = None;
6567 self.clamp_caret();
6568 }
6569
6570 /// Select the exact source range `[start, end)` — anchor at `start`, caret
6571 /// at `end` — without snapping either end to a visible caret stop.
6572 ///
6573 /// The one caret verb that takes a range it was *handed* rather than one it
6574 /// worked out, for a host that already knows the bytes it means: a search
6575 /// hit, an annotation's footprint, a quote re-anchored through
6576 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6577 /// wrong tool for that, and not by a little — it snaps to the nearest
6578 /// *visible* stop, and where a range butts up against a hidden delimiter
6579 /// the nearest stop is the one before it, so selecting the "needle" of
6580 /// `**needle**` comes back with "needl" and an edit against it strands the
6581 /// "e".
6582 ///
6583 /// What `place_caret` does that is bookkeeping rather than snapping still
6584 /// happens here, because a host handing in a range is not asking to opt out
6585 /// of the invariants:
6586 ///
6587 /// - both ends are clamped into the document and up to
6588 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6589 /// frontmatter is hidden, and a caret parked in it draws nowhere and
6590 /// types into the metadata;
6591 /// - both land on character boundaries, so nothing slices a `é` in half;
6592 /// - the sticky vertical goal column is dropped, and any armed inline mark
6593 /// disarmed, since a range from outside inherits neither.
6594 ///
6595 /// An empty range is a caret rather than a selection —
6596 /// [`selection`](Self::selection) reports `None` for it, as it does for any
6597 /// anchor that has met the caret.
6598 pub fn select_range(&mut self, start: usize, end: usize) {
6599 let floor = self.caret_floor();
6600 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6601 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6602 self.anchor = Some(anchor);
6603 self.caret = caret;
6604 self.goal_col = None;
6605 self.status = None;
6606 self.last_edit_kind = None;
6607 self.clear_pending();
6608 }
6609
6610 /// `offset` itself if it is a character boundary, else the boundary before
6611 /// it. An offset that isn't one draws nowhere real and panics the next time
6612 /// anything slices there.
6613 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6614 let mut o = offset.min(self.source.len());
6615 while o > 0 && !self.source.is_char_boundary(o) {
6616 o -= 1;
6617 }
6618 o
6619 }
6620
6621 /// The lowest source offset the caret may occupy in the active view. In
6622 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6623 /// the first rendered offset; the source view reaches everything, so it's 0.
6624 fn caret_floor(&self) -> usize {
6625 match self.view {
6626 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6627 View::Source => 0,
6628 }
6629 }
6630
6631 /// Land in a table cell with its whole content selected — the anchor at the
6632 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6633 /// like tabbing into a form field: the text comes up selected, so typing
6634 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6635 /// end`) collapses to a plain caret home (an empty selection is no selection).
6636 fn select_cell(&mut self, start: usize, end: usize) {
6637 self.select_range(start, end);
6638 }
6639
6640 fn move_to(&mut self, offset: usize, extend: bool) {
6641 if extend {
6642 if self.anchor.is_none() {
6643 self.anchor = Some(self.caret);
6644 }
6645 } else {
6646 self.anchor = None;
6647 }
6648 self.caret = offset.min(self.source.len()).max(self.caret_floor());
6649 self.status = None;
6650 // A caret move ends the current typing/deletion run, so the next edit
6651 // starts a fresh undo group rather than coalescing across the gap.
6652 self.last_edit_kind = None;
6653 // Moving away disarms any sticky mark — "start bold" applies only where
6654 // it was asked for, not wherever the caret next lands.
6655 self.clear_pending();
6656 }
6657
6658 // In the source view, motion walks source bytes / source lines. In the
6659 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6660 // what steps the caret cleanly over hidden delimiters.
6661
6662 pub fn move_left(&mut self, extend: bool) {
6663 self.goal_col = None;
6664 if !extend && let Some((s, _e)) = self.selection() {
6665 self.move_to(s, false);
6666 return;
6667 }
6668 let target = match self.view {
6669 View::Source => {
6670 if self.caret > 0 {
6671 prev_boundary(&self.source, self.caret)
6672 } else {
6673 0
6674 }
6675 }
6676 // Walks caret *stops*, not columns: decoration (a table border, a
6677 // cell's padding) is stepped over in one press, and a hidden
6678 // delimiter never holds the caret up — though the end of a mark's
6679 // content is a stop of its own (`VisualMap::mark_ends`), so
6680 // leaving `**bold**` from past its `**` is a press onto the end of
6681 // the bold and another onto the `d`.
6682 View::Wysiwyg => self
6683 .vmap
6684 .caret_stop_before(self.caret)
6685 .unwrap_or(self.caret),
6686 };
6687 let before = self.caret;
6688 self.move_to(target, extend);
6689 self.debug_assert_on_a_stop(before);
6690 }
6691
6692 pub fn move_right(&mut self, extend: bool) {
6693 self.goal_col = None;
6694 if !extend && let Some((_s, e)) = self.selection() {
6695 self.move_to(e, false);
6696 return;
6697 }
6698 let target = match self.view {
6699 View::Source => {
6700 if self.caret < self.source.len() {
6701 next_boundary(&self.source, self.caret)
6702 } else {
6703 self.caret
6704 }
6705 }
6706 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
6707 };
6708 let before = self.caret;
6709 self.move_to(target, extend);
6710 self.debug_assert_on_a_stop(before);
6711 }
6712
6713 /// Move to the start of the previous word (⌥← / Ctrl+←).
6714 pub fn move_word_left(&mut self, extend: bool) {
6715 self.goal_col = None;
6716 let before = self.caret;
6717 let target = self.word_left_from(self.caret);
6718 self.move_to(target, extend);
6719 self.debug_assert_on_a_stop(before);
6720 }
6721
6722 /// Move to the end of the next word (⌥→ / Ctrl+→).
6723 pub fn move_word_right(&mut self, extend: bool) {
6724 self.goal_col = None;
6725 let before = self.caret;
6726 let target = self.word_right_from(self.caret);
6727 self.move_to(target, extend);
6728 self.debug_assert_on_a_stop(before);
6729 }
6730
6731 // Word boundaries are found in the space the *view* is in. The source view
6732 // walks the source, because there the source is what's rendered. WYSIWYG
6733 // walks the rendered text instead: `**` is invisible to the user, so it has
6734 // to be invisible to word motion too — a caret parked inside one draws in
6735 // the column after `bold` and types two bytes earlier, and a word-delete
6736 // that stops there shreds the markup into `a ** c`.
6737
6738 /// The word boundary to the left of `off` in the active view's space.
6739 fn word_left_from(&self, off: usize) -> usize {
6740 match self.view {
6741 View::Source => prev_word(&self.source, off),
6742 View::Wysiwyg => self.glyph_word_left(off),
6743 }
6744 }
6745
6746 /// The word boundary to the right of `off` in the active view's space.
6747 fn word_right_from(&self, off: usize) -> usize {
6748 match self.view {
6749 View::Source => next_word(&self.source, off),
6750 View::Wysiwyg => self.glyph_word_right(off),
6751 }
6752 }
6753
6754 /// The character class of the glyph drawn at stop `off`.
6755 ///
6756 /// Read from the source, because a stop points at the source byte its glyph
6757 /// came from — the source *is* where the rendered character is written. What
6758 /// makes the walk glyph space rather than source space is that it only ever
6759 /// visits stops, and the hidden bytes between them have none.
6760 fn class_at(&self, off: usize) -> Class {
6761 self.source
6762 .get(off..)
6763 .and_then(|s| s.chars().next())
6764 .map_or(Class::Space, classify)
6765 }
6766
6767 /// [`next_word`] in glyph space: skip any leading separators, then consume
6768 /// the following word run, with the stop table standing in for the source's
6769 /// characters.
6770 fn glyph_word_right(&self, from: usize) -> usize {
6771 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
6772 return from;
6773 };
6774 let mut in_word = false;
6775 loop {
6776 match self.class_at(off) {
6777 Class::Word => in_word = true,
6778 _ if in_word => return off,
6779 _ => {}
6780 }
6781 match self.vmap.stop_after(off) {
6782 Some(next) => off = next,
6783 None => return off,
6784 }
6785 }
6786 }
6787
6788 /// [`prev_word`] in glyph space: skip separators walking left, then consume
6789 /// the preceding word run.
6790 fn glyph_word_left(&self, from: usize) -> usize {
6791 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
6792 return from;
6793 };
6794 let mut in_word = false;
6795 while let Some(prev) = self.vmap.stop_before(off) {
6796 match self.class_at(prev) {
6797 Class::Word => in_word = true,
6798 _ if in_word => return off,
6799 _ => {}
6800 }
6801 off = prev;
6802 }
6803 off
6804 }
6805
6806 /// After a motion that walks the visual map, the caret must be *on* the map.
6807 /// A stop is the only offset where the caret draws and edits in the same
6808 /// place, and it's the invariant both a caret parked inside an emoji and one
6809 /// parked inside a `**` were quietly breaking.
6810 ///
6811 /// Only when the caret actually moved: a walk with nowhere to go leaves it
6812 /// where it was, which is wherever the floor or a frontend put it rather
6813 /// than somewhere this motion chose.
6814 fn debug_assert_on_a_stop(&self, before: usize) {
6815 debug_assert!(
6816 self.view != View::Wysiwyg
6817 || self.vmap.num_rows() == 0
6818 || self.caret == before
6819 || self.vmap.is_stop(self.caret),
6820 "motion left the caret at {}, which is not a caret stop: it would draw in \
6821 one place and type in another",
6822 self.caret
6823 );
6824 }
6825
6826 // Up and Down run off the ends of the document rather than stopping dead at
6827 // them: Up from the first row lands at the document's start, Down from the
6828 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
6829 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
6830 // reaching the end of the text is what a reader means by it.
6831 //
6832 // The views used to disagree here by accident rather than by decision: the
6833 // source view fell into the edge behaviour through `row_col_to_offset`
6834 // clamping an out-of-range row to the end of the string, while WYSIWYG had
6835 // no row below to walk to and did nothing at all. They share the rule now,
6836 // each in its own space — the source view reaches every byte, WYSIWYG only
6837 // the offsets it draws.
6838
6839 pub fn move_up(&mut self, extend: bool) {
6840 let (row, col) = self.caret_pos();
6841 let goal = self.goal_col.unwrap_or(col);
6842 let target = match self.view {
6843 View::Source => match row.checked_sub(1) {
6844 Some(r) => row_col_to_offset(&self.source, r, goal),
6845 None => self.reachable_start(),
6846 },
6847 // A table's border rules are drawn but hold no caret, so Up steps
6848 // over them to the row that does.
6849 View::Wysiwyg => match self.vmap.navigable_above(row) {
6850 Some(r) => self.row_target(r, goal),
6851 None => self.reachable_start(),
6852 },
6853 };
6854 self.step_vertical(target, goal, extend);
6855 }
6856
6857 pub fn move_down(&mut self, extend: bool) {
6858 let (row, col) = self.caret_pos();
6859 let goal = self.goal_col.unwrap_or(col);
6860 let target = match self.view {
6861 View::Source => match self.source_row_below(row) {
6862 Some(r) => row_col_to_offset(&self.source, r, goal),
6863 None => self.reachable_end(),
6864 },
6865 View::Wysiwyg => match self.vmap.navigable_below(row) {
6866 Some(r) => self.row_target(r, goal),
6867 None => self.reachable_end(),
6868 },
6869 };
6870 self.step_vertical(target, goal, extend);
6871 }
6872
6873 /// Land a vertical motion at `target`, latching the `goal` column it aimed
6874 /// with so the rest of the run keeps aiming there.
6875 ///
6876 /// A motion with nowhere to go changes *nothing*, the goal column included:
6877 /// the latch used to run before the early return at the top of the document,
6878 /// so an Up that did nothing still armed a column, and the next Down aimed
6879 /// at one the caret had never been in.
6880 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
6881 let before = self.caret;
6882 if target == before {
6883 return;
6884 }
6885 self.goal_col = Some(goal);
6886 self.move_to(target, extend);
6887 self.debug_assert_on_a_stop(before);
6888 }
6889
6890 /// The source line below `row`, or `None` when `row` is the last one. Lines
6891 /// are counted by newline, so a trailing one leaves a real, empty last line
6892 /// for the caret to sit on — the document ends below it, not on it.
6893 fn source_row_below(&self, row: usize) -> Option<usize> {
6894 let last = self.source.bytes().filter(|&b| b == b'\n').count();
6895 (row < last).then_some(row + 1)
6896 }
6897
6898 /// Where a vertical motion aiming at the `goal` column lands on visual row
6899 /// `r`: the column clamped to the row, mapped to its offset, then held
6900 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
6901 /// column belongs to the row below, and a gutter's column 0 points at the
6902 /// block rather than at this row.
6903 fn row_target(&self, r: usize, goal: usize) -> usize {
6904 let (start, end) = self.row_bounds(r);
6905 self.vmap
6906 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
6907 .clamp(start, end)
6908 }
6909
6910 /// The first and last offsets the caret can reach in the active view.
6911 ///
6912 /// Not the same span in both: the source view shows every byte, so it can
6913 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
6914 /// sits below the first stop, and a document's trailing newline is drawn
6915 /// nowhere and so sits past the last.
6916 fn reachable_start(&self) -> usize {
6917 match self.view {
6918 View::Source => 0,
6919 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
6920 }
6921 }
6922
6923 fn reachable_end(&self) -> usize {
6924 match self.view {
6925 View::Source => self.source.len(),
6926 View::Wysiwyg => self
6927 .vmap
6928 .stop_at_or_before(self.source.len())
6929 .unwrap_or(self.caret),
6930 }
6931 }
6932
6933 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
6934 /// including the space a soft wrap ate off its end, which is drawn on this
6935 /// row however much the offset past it belongs to the next one.
6936 fn row_span(&self, r: usize) -> (usize, usize) {
6937 let start = self
6938 .vmap
6939 .row_start(r)
6940 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
6941 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
6942 (start.min(end), end)
6943 }
6944
6945 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
6946 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
6947 /// this row's last position is the one before it — the offset before the
6948 /// space the wrap ate, where the caret draws just past the row's last word
6949 /// and types there too.
6950 ///
6951 /// Aiming at the shared offset instead is what stalled End: it is the row's
6952 /// last *column*, so End pressed on the row reached it and then read back as
6953 /// the row below's start, where a second press ran on to that row's end and
6954 /// the next to the one after — End walking down the paragraph a row a press.
6955 fn row_bounds(&self, r: usize) -> (usize, usize) {
6956 let (start, end) = self.row_span(r);
6957 let wraps = self
6958 .vmap
6959 .navigable_below(r)
6960 .and_then(|b| self.vmap.row_start(b))
6961 .is_some_and(|off| off == end);
6962 match wraps {
6963 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
6964 false => (start, end),
6965 }
6966 }
6967
6968 /// The `[start, end]` of the line Home and End aim at: the visual row in
6969 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
6970 ///
6971 /// A soft-wrapped row is a line here, because it is one to the eye and the
6972 /// eye is what these keys are aimed by — a reader pressing End means the end
6973 /// of the line they can see. (`select_block_at` wants the opposite and reads
6974 /// the AST for it: a triple-click grabs the whole paragraph, however many
6975 /// rows it folds into.)
6976 fn line_bounds(&self) -> (usize, usize) {
6977 let (row, _) = self.caret_pos();
6978 match self.view {
6979 View::Source => {
6980 let start = line_start(&self.source, row);
6981 (start, line_end_from(&self.source, start))
6982 }
6983 View::Wysiwyg => self.row_bounds(row),
6984 }
6985 }
6986
6987 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
6988 /// *drawn* — what a kill takes.
6989 ///
6990 /// The two part only at a soft wrap, over the space the wrap ate: the caret
6991 /// can't stand after it (that offset opens the row below, and End stopping
6992 /// there would walk), but it is on this row, and a kill that spared it would
6993 /// leave a double space behind where the row's text had been. Deleting it
6994 /// joins nothing — a wrap is drawn, not written.
6995 fn line_span(&self) -> (usize, usize) {
6996 let (row, _) = self.caret_pos();
6997 match self.view {
6998 View::Source => self.line_bounds(),
6999 View::Wysiwyg => self.row_span(row),
7000 }
7001 }
7002
7003 /// The first offset in `[start, end]` holding something other than
7004 /// whitespace, or `end` when the line holds nothing else — where Home aims.
7005 ///
7006 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7007 /// so a hidden delimiter is never taken for the line's first character (nor
7008 /// landed on), and the source view steps the source it is showing.
7009 fn first_non_space(&self, start: usize, end: usize) -> usize {
7010 let mut off = start;
7011 while off < end {
7012 if self.class_at(off) != Class::Space {
7013 return off;
7014 }
7015 off = match self.view {
7016 View::Source => next_boundary(&self.source, off),
7017 View::Wysiwyg => match self.vmap.stop_after(off) {
7018 Some(next) => next,
7019 None => return end,
7020 },
7021 };
7022 }
7023 end
7024 }
7025
7026 /// Home: to the first character on the line, or to column 0 when the caret
7027 /// is already on it — the two-press toggle every editor spells this way.
7028 /// The indentation is somewhere the caret has to be able to reach and almost
7029 /// never where a reader is headed, so it costs the second press.
7030 pub fn move_home(&mut self, extend: bool) {
7031 self.goal_col = None;
7032 let (start, end) = self.line_bounds();
7033 let text = self.first_non_space(start, end);
7034 let target = if self.caret == text { start } else { text };
7035 let before = self.caret;
7036 self.move_to(target, extend);
7037 self.debug_assert_on_a_stop(before);
7038 }
7039
7040 /// End: to the end of the line.
7041 pub fn move_end(&mut self, extend: bool) {
7042 self.goal_col = None;
7043 let (_, end) = self.line_bounds();
7044 let before = self.caret;
7045 self.move_to(end, extend);
7046 self.debug_assert_on_a_stop(before);
7047 }
7048
7049 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7050 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7051 /// when the caret isn't in a table, or is already in the last/first cell — the
7052 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7053 /// meaning everywhere else.
7054 pub fn cell_hop(&mut self, forward: bool) -> bool {
7055 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7056 return false;
7057 };
7058 // Flatten to document (row-major) order and step one cell either way.
7059 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7060 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7061 let next = if forward {
7062 i.checked_add(1)
7063 } else {
7064 i.checked_sub(1)
7065 };
7066 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7067 return false; // at the table's edge; leave Tab to the frontend
7068 };
7069 self.select_cell(start, end);
7070 true
7071 }
7072
7073 /// Move the caret to the cell directly above (`down == false`) or below in
7074 /// the same column, landing with the cell's whole content selected (see
7075 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7076 /// when the caret isn't in a table), so the frontend can fall through — the
7077 /// vertical counterpart of [`Self::cell_hop`].
7078 ///
7079 /// A ragged row that is short a column clamps to its last cell, so Down never
7080 /// falls out of the table over a gap the row above happened to have.
7081 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7082 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7083 return false;
7084 };
7085 let target = match down {
7086 true => r + 1,
7087 false if r == 0 => return false,
7088 false => r - 1,
7089 };
7090 let Some(row) = grid.get(target) else {
7091 return false;
7092 };
7093 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7094 return false;
7095 };
7096 self.select_cell(start, end);
7097 true
7098 }
7099
7100 /// The table containing `off` as a row-major grid of `(start, end)` cell
7101 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7102 /// isn't in a table. Read straight off the visual map's laid-out grid, so
7103 /// every cell (an empty one included, whose derived home twig gives no
7104 /// `content_span` for) is present and in the order Tab walks them.
7105 // Grid, row, column — three returns that only ever travel together, and a
7106 // named type for the pair of them would be read at one call site.
7107 #[allow(clippy::type_complexity)]
7108 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7109 for t in &self.vmap.tables {
7110 let mut pos = None;
7111 let grid: Vec<Vec<(usize, usize)>> = t
7112 .grid
7113 .iter()
7114 .enumerate()
7115 .map(|(r, row)| {
7116 row.cells
7117 .iter()
7118 .enumerate()
7119 .map(|(c, cell)| {
7120 if pos.is_none() && off >= cell.start && off <= cell.end {
7121 pos = Some((r, c));
7122 }
7123 (cell.start, cell.end)
7124 })
7125 .collect()
7126 })
7127 .collect();
7128 if let Some((r, c)) = pos {
7129 return Some((grid, r, c));
7130 }
7131 }
7132 None
7133 }
7134
7135 // ── table key policy ──────────────────────────────────────────────────────
7136 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7137 // as one policy every frontend shares, rather than each re-deriving it. Each
7138 // reports whether it acted *as a table key*; a `false` hands the key back to
7139 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7140 // everywhere else.
7141
7142 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7143 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7144 /// back and simply stays put at the very first cell. `false` when the caret
7145 /// isn't in a table.
7146 pub fn cell_tab(&mut self, forward: bool) -> bool {
7147 if !self.caret_in_table() {
7148 return false;
7149 }
7150 if self.cell_hop(forward) {
7151 return true;
7152 }
7153 // Off the last cell: grow the table by a row and step into its first
7154 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7155 if forward {
7156 self.append_row_and_enter(0);
7157 }
7158 true
7159 }
7160
7161 /// Return inside a table: drop to the cell below in the same column,
7162 /// appending a new row when the caret is already in the last one. `false`
7163 /// when the caret isn't in a table, so the frontend inserts a newline.
7164 pub fn cell_return(&mut self) -> bool {
7165 if !self.caret_in_table() {
7166 return false;
7167 }
7168 if self.cell_move_vertical(true) {
7169 return true;
7170 }
7171 // Already on the last row: grow one below and drop into the same column.
7172 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7173 self.append_row_and_enter(col);
7174 true
7175 }
7176
7177 /// Append a row below the caret's (last) row and land in `col` of it. The
7178 /// caret is in the last row, so twig's "insert below" makes the fresh row the
7179 /// table's new last — but twig re-spells the whole table, moving every byte,
7180 /// so the destination is read back from the rebuilt grid by the table's
7181 /// position (stable across a row insert), not from the pre-edit caret.
7182 fn append_row_and_enter(&mut self, col: usize) {
7183 let table = self.caret_table_index();
7184 self.table_insert_row(true);
7185 self.rebuild_map();
7186 let Some((start, end)) = table
7187 .and_then(|ti| self.vmap.tables.get(ti))
7188 .and_then(|t| t.grid.last())
7189 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7190 .map(|cell| (cell.start, cell.end))
7191 else {
7192 return;
7193 };
7194 self.select_cell(start, end);
7195 }
7196
7197 /// The index, among the document's tables, of the one the caret sits in —
7198 /// `None` when it's in none. Used to re-find a table after an edit re-spells
7199 /// it (a row insert leaves the table order unchanged).
7200 fn caret_table_index(&self) -> Option<usize> {
7201 let off = self.caret;
7202 self.vmap.tables.iter().position(|t| {
7203 t.grid
7204 .iter()
7205 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7206 })
7207 }
7208
7209 /// Shift+Return inside a table: insert a hard line break *within* the current
7210 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7211 /// table, so the frontend inserts an ordinary line break.
7212 ///
7213 /// A table row is a single source line, so the newline-spelled hard break
7214 /// can't live in a cell. twig spells the in-cell break the format's way
7215 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7216 /// break round-trips as structure the renderer reads back as a line — not the
7217 /// opaque raw HTML the old raw-splice left behind.
7218 ///
7219 /// Djot has no idiomatic in-cell break, so twig refuses it
7220 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7221 /// would render as the literal text `<br>`. The gesture is still *consumed*
7222 /// there — returning `false` would let the frontend insert a real newline,
7223 /// which splits the one-line row — it just leaves the cell unchanged and says
7224 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7225 ///
7226 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7227 /// have to be read together: djot is not the only `false`, and naming it in
7228 /// the message was already a guess that HTML — which spells the break as its
7229 /// own `<br>` — would have made wrong.
7230 pub fn cell_line_break(&mut self) -> bool {
7231 if self.read_only || !self.caret_in_table() {
7232 return false;
7233 }
7234 self.record_caret();
7235 match self.editor.insert_line_break(self.caret) {
7236 Ok(change) => {
7237 self.last_edit_kind = None;
7238 self.refresh();
7239 self.caret = change.new.end;
7240 self.anchor = None;
7241 self.goal_col = None;
7242 self.clamp_caret();
7243 self.dirty = self.source != self.clean_source;
7244 self.status = None;
7245 self.record_caret();
7246 }
7247 Err(twig::Error::UnsupportedFormat) => {
7248 self.status = Some(format!(
7249 "in-cell line breaks aren't supported in {}",
7250 self.format_name()
7251 ));
7252 }
7253 Err(_) => {}
7254 }
7255 true
7256 }
7257
7258 /// Rebuild the visual map at the width the last build used. A structural edit
7259 /// bumps the revision and swaps the source in, but leaves the *map* stale;
7260 /// when a single gesture edits and then moves over the result (Tab appending
7261 /// a row, then stepping into it), the move needs the map to already show the
7262 /// edit rather than waiting for the frontend's next frame.
7263 fn rebuild_map(&mut self) {
7264 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7265 self.build_map(wrap);
7266 }
7267
7268 /// Move the caret to the very start of the document (⌘↑ on macOS,
7269 /// Ctrl+Home on Windows/Linux).
7270 pub fn move_doc_start(&mut self, extend: bool) {
7271 self.goal_col = None;
7272 self.move_to(0, extend);
7273 }
7274
7275 /// Move the caret to the very end of the document (⌘↓ on macOS,
7276 /// Ctrl+End on Windows/Linux).
7277 pub fn move_doc_end(&mut self, extend: bool) {
7278 self.goal_col = None;
7279 let end = self.source.len();
7280 self.move_to(end, extend);
7281 }
7282
7283 /// Point the caret at the body cell `(row, col)` the mouse landed on —
7284 /// `col` being a cell of the terminal grid, which is what a display column
7285 /// is. A click on the far cell of a wide character lands at that
7286 /// character's start; the mapping's own doc-comments carry the rule.
7287 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
7288 self.goal_col = None;
7289 let target = match self.view {
7290 View::Source => row_col_to_offset(&self.source, row, col),
7291 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
7292 };
7293 let before = self.caret;
7294 self.move_to(target, extend);
7295 self.debug_assert_on_a_stop(before);
7296 }
7297
7298 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
7299 /// screen if it has moved since the last frame, and never scroll past the
7300 /// last of `rows`.
7301 ///
7302 /// Only if it has *moved* — that's the whole point. Revealing the caret on
7303 /// every frame ties the viewport to it, and a scroll wheel that fights the
7304 /// caret for the viewport loses: the view snaps back the instant it tries to
7305 /// pass the caret's row, so the document can't be scrolled beyond what's
7306 /// already on screen. A caret move is the frontend's cue to follow; a scroll
7307 /// with the caret sitting still is the reader's cue to leave it alone.
7308 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
7309 if self.drawn_caret != Some(self.caret) {
7310 if caret_row < self.scroll {
7311 self.scroll = caret_row;
7312 } else if height > 0 && caret_row >= self.scroll + height {
7313 self.scroll = caret_row + 1 - height;
7314 }
7315 self.drawn_caret = Some(self.caret);
7316 }
7317 self.scroll = self.scroll.min(rows.saturating_sub(1));
7318 }
7319
7320 /// The caret's screen position `(row, col)` in the active view's grid, with
7321 /// `col` a display column: the cell to draw the caret in, which on a line of
7322 /// `你好` or emoji is not the count of characters before it.
7323 pub fn caret_pos(&self) -> (usize, usize) {
7324 match self.view {
7325 View::Source => offset_to_row_col(&self.source, self.caret),
7326 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
7327 }
7328 }
7329
7330 fn clamp_caret(&mut self) {
7331 if self.caret > self.source.len() {
7332 self.caret = self.source.len();
7333 }
7334 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
7335 // any selection anchor) to the first rendered offset.
7336 let floor = self.caret_floor();
7337 if self.caret < floor {
7338 self.caret = floor;
7339 }
7340 if let Some(a) = self.anchor
7341 && a < floor
7342 {
7343 self.anchor = Some(floor);
7344 }
7345 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
7346 self.caret -= 1;
7347 }
7348 }
7349}
7350
7351// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
7352
7353// Left/right motion and backspace/delete step by *grapheme cluster*, not
7354// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
7355// marks moves and deletes as the single character a user sees. Grapheme
7356// boundaries are a superset of char boundaries, so the caret stays valid for twig.
7357
7358/// How an insert of `text` groups for undo: a single typed character folds into
7359/// the run of typing around it, while a newline or a multi-character insert is a
7360/// step of its own.
7361fn typed_edit_kind(text: &str) -> EditKind {
7362 if text.chars().take(2).count() == 1 && text != "\n" {
7363 EditKind::Insert
7364 } else {
7365 EditKind::Other
7366 }
7367}
7368
7369fn prev_boundary(s: &str, i: usize) -> usize {
7370 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7371 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
7372}
7373
7374fn next_boundary(s: &str, i: usize) -> usize {
7375 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7376 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
7377}
7378
7379// ── word boundaries ──────────────────────────────────────────────────────────
7380// The shared primitive behind word-wise motion, word deletion, and
7381// double-click-to-select-a-word. A "word" is a maximal run of one character
7382// class; whitespace and punctuation are their own classes, so motion skips
7383// cleanly between them the way native text fields do.
7384
7385#[derive(PartialEq, Eq, Clone, Copy)]
7386enum Class {
7387 Word,
7388 Space,
7389 Other,
7390}
7391
7392/// The source range of an inline node's own visible text — the part of it a
7393/// WYSIWYG caret can reach, as against the delimiters that only spell it.
7394/// `None` for a node with no interior to empty (a `str`, a break).
7395///
7396/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
7397/// one delimiter in from the span — the same place the renderer maps it to. A
7398/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
7399/// against the source rather than trusted: a range guessed wrong here is text
7400/// deleted wrong.
7401fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
7402 if let Some(span) = n.content_span.clone() {
7403 return Some(span);
7404 }
7405 match n.kind.as_str() {
7406 "verbatim" | "inline_math" => {
7407 let text = n.text.as_ref()?;
7408 let start = n.span.start + 1;
7409 let range = start..start + text.len();
7410 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
7411 }
7412 _ => None,
7413 }
7414}
7415
7416/// The `id` a node declares, or `None` for one that declares none — the
7417/// attribute djot writes for a `{#v1}` and mints for a heading.
7418///
7419/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
7420/// names nothing, so it reads as absent rather than as the empty string.
7421fn declared_id(n: &FlatNode) -> Option<&str> {
7422 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
7423}
7424
7425/// A heading's words reduced to the form a link fragment spells them in:
7426/// lowercase, runs of anything else collapsed to a single `-`, with none left
7427/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
7428///
7429/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
7430/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
7431/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
7432/// any other language is still a heading someone will link to. Underscores
7433/// survive for the same reason they do on the web: they are word characters
7434/// wherever identifiers are written.
7435fn slug(text: &str) -> String {
7436 let mut out = String::new();
7437 let mut pending = false;
7438 for c in text.chars() {
7439 if c.is_alphanumeric() || c == '_' {
7440 if pending && !out.is_empty() {
7441 out.push('-');
7442 }
7443 pending = false;
7444 out.extend(c.to_lowercase());
7445 } else {
7446 pending = true;
7447 }
7448 }
7449 out
7450}
7451
7452fn is_block_container(kind: &Kind) -> bool {
7453 matches!(
7454 kind,
7455 Kind::Doc
7456 | Kind::Section
7457 | Kind::BlockQuote
7458 | Kind::BulletList
7459 | Kind::OrderedList
7460 | Kind::TaskList
7461 | Kind::ListItem
7462 | Kind::TaskListItem
7463 // Every `container` — a directive in any of its three forms, or a
7464 // promoted HTML element. A *text* directive is really inline, so
7465 // claiming it here is a small overreach, and the deliberate one this
7466 // function's kind-only peer `is_inline_kind` documents: the pair is
7467 // consulted together, and answering "block container" for something
7468 // inline is what keeps an ancestor walk from stopping short of the
7469 // paragraph that actually holds it.
7470 | Kind::Container
7471 )
7472}
7473
7474/// The `[start, end)` byte range of the source line containing `off` (newline
7475/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7476/// line between paragraphs).
7477fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7478 let off = off.min(s.len());
7479 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7480 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7481 start..end
7482}
7483
7484/// How many leading bytes an outdent takes off `line`: a whole indent level
7485/// where the line has one, and whatever it has where it has less.
7486///
7487/// A leading tab counts as a level on its own. It's indentation some other
7488/// editor wrote, and one tab is one level everywhere it came from — measuring it
7489/// in spaces it doesn't contain would leave it untouchable.
7490fn outdent_width(line: &str, unit: usize) -> usize {
7491 if line.starts_with('\t') {
7492 return 1;
7493 }
7494 line.bytes().take(unit).take_while(|b| *b == b' ').count()
7495}
7496
7497/// A list marker found at the head of a line, together with everything before it
7498/// that a sibling line has to repeat.
7499///
7500/// The three offsets differ only inside a block quote, where `> - b` opens with
7501/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7502/// `line_start == marker_start`, and `text` is the plain `" - "`.
7503#[derive(Clone, Debug)]
7504struct ListMarker {
7505 /// The line's first byte.
7506 line_start: usize,
7507 /// Where the marker proper begins, past any quote prefix. The offset to hand
7508 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7509 marker_start: usize,
7510 /// `line_start` through the marker's trailing space — quote prefix, indent
7511 /// and bullet together, which is what the next item's line opens with.
7512 text: String,
7513}
7514
7515impl ListMarker {
7516 /// Where the item's content starts — one past the marker's trailing space.
7517 fn content_start(&self) -> usize {
7518 self.line_start + self.text.len()
7519 }
7520}
7521
7522fn classify(c: char) -> Class {
7523 if c == '_' || c.is_alphanumeric() {
7524 Class::Word
7525 } else if c.is_whitespace() {
7526 Class::Space
7527 } else {
7528 Class::Other
7529 }
7530}
7531
7532/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7533/// skip any leading separators, then consume the following word run.
7534fn next_word(s: &str, i: usize) -> usize {
7535 let mut off = i;
7536 let mut in_word = false;
7537 for c in s[i..].chars() {
7538 if classify(c) == Class::Word {
7539 in_word = true;
7540 } else if in_word {
7541 break;
7542 }
7543 off += c.len_utf8();
7544 }
7545 off
7546}
7547
7548/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7549/// skip separators walking left, then consume the preceding word run.
7550fn prev_word(s: &str, i: usize) -> usize {
7551 let mut off = i;
7552 let mut in_word = false;
7553 for c in s[..i].chars().rev() {
7554 if classify(c) == Class::Word {
7555 in_word = true;
7556 } else if in_word {
7557 break;
7558 }
7559 off -= c.len_utf8();
7560 }
7561 off
7562}
7563
7564/// The `[start, end)` run of same-class characters surrounding `off` — the
7565/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7566/// the run ending there is used.
7567fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7568 if s.is_empty() {
7569 return (0, 0);
7570 }
7571 let off = off.min(s.len());
7572 let reference = if off < s.len() {
7573 s[off..].chars().next()
7574 } else {
7575 s[..off].chars().next_back()
7576 };
7577 let Some(rc) = reference else {
7578 return (off, off);
7579 };
7580 let class = classify(rc);
7581
7582 let mut start = off;
7583 for c in s[..start].chars().rev() {
7584 if classify(c) == class {
7585 start -= c.len_utf8();
7586 } else {
7587 break;
7588 }
7589 }
7590 let mut end = off;
7591 for c in s[end..].chars() {
7592 if classify(c) == class {
7593 end += c.len_utf8();
7594 } else {
7595 break;
7596 }
7597 }
7598 (start, end)
7599}
7600
7601/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7602/// the line's start — terminal cells, not characters, so the column names the
7603/// cell the caret is drawn in even on a line of `你好` or emoji.
7604fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7605 let off = off.min(s.len());
7606 let mut row = 0;
7607 let mut line_start = 0;
7608 for (i, &b) in s.as_bytes().iter().enumerate() {
7609 if i >= off {
7610 break;
7611 }
7612 if b == b'\n' {
7613 row += 1;
7614 line_start = i + 1;
7615 }
7616 }
7617 (row, wysiwyg::text_width(&s[line_start..off]))
7618}
7619
7620/// The byte offset at display column `col` of `row` (clamped to that line's
7621/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
7622///
7623/// A column landing *inside* a character — the second cell of `你`, or any cell
7624/// but the first of an emoji — resolves to that character's start, which is the
7625/// column the caret would have been drawn at to begin with. So both cells of a
7626/// wide character mean the character, and every offset survives the round trip
7627/// out to a column and back. The walk steps by grapheme cluster for the same
7628/// reason the caret does: a cluster is the character, and the cells belong to it
7629/// rather than to the codepoints spelling it.
7630fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
7631 let start = line_start(s, row);
7632 let end = line_end_from(s, start);
7633 let mut off = start;
7634 let mut at = 0; // the display column `off` sits at
7635 while off < end {
7636 let next = next_boundary(s, off).min(end);
7637 let cells = wysiwyg::text_width(&s[off..next]);
7638 if at + cells > col {
7639 break; // `col` is one of this cluster's own cells
7640 }
7641 at += cells;
7642 off = next;
7643 }
7644 off
7645}
7646
7647fn line_start(s: &str, row: usize) -> usize {
7648 if row == 0 {
7649 return 0;
7650 }
7651 let mut r = 0;
7652 for (i, &b) in s.as_bytes().iter().enumerate() {
7653 if b == b'\n' {
7654 r += 1;
7655 if r == row {
7656 return i + 1;
7657 }
7658 }
7659 }
7660 s.len()
7661}
7662
7663fn line_end_from(s: &str, start: usize) -> usize {
7664 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
7665}
7666
7667/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
7668/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
7669/// twig applies writing the mark out, so the toolbar can light the same button
7670/// that made the node.
7671///
7672/// `None` for every other kind, including the inline nodes that aren't marks at
7673/// all (`str`, `link`, `image`, the math and break kinds): they're things a
7674/// caret stands in, not formatting a button toggles.
7675/// Whether a match from an ancestor chain is an inline run whose delimiters
7676/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
7677/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
7678/// span is a [`Kind::Container`], which the kind alone cannot tell from a
7679/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
7680/// answer is the node's own. Every delete and caret step that walks over a
7681/// `**` walks over a span's tags by this test; without it Backspace after
7682/// `</span>` took the `>` and left the paragraph unparseable.
7683fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
7684 inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
7685}
7686
7687fn inline_kind(kind: &Kind) -> Option<InlineKind> {
7688 Some(match kind {
7689 Kind::Strong => InlineKind::Strong,
7690 Kind::Emph => InlineKind::Emph,
7691 Kind::Verbatim => InlineKind::Verbatim,
7692 Kind::Mark => InlineKind::Mark,
7693 Kind::Superscript => InlineKind::Superscript,
7694 Kind::Subscript => InlineKind::Subscript,
7695 Kind::Insert => InlineKind::Insert,
7696 Kind::Delete => InlineKind::Delete,
7697 _ => return None,
7698 })
7699}
7700
7701/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
7702/// a highlight's opening `==`.
7703///
7704/// Two enums for one closed vocabulary, and the duplication is the boundary
7705/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
7706/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
7707/// the editor writes. Spelled as a match rather than routed through the two
7708/// crates' name strings so that a colour added on either side is a compile
7709/// error here, where the pairing is decided, rather than a runtime `None` that
7710/// would read as "clear the colour".
7711fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
7712 match color {
7713 MarkColor::Red => twig::MarkColor::Red,
7714 MarkColor::Orange => twig::MarkColor::Orange,
7715 MarkColor::Yellow => twig::MarkColor::Yellow,
7716 MarkColor::Green => twig::MarkColor::Green,
7717 MarkColor::Blue => twig::MarkColor::Blue,
7718 MarkColor::Purple => twig::MarkColor::Purple,
7719 MarkColor::Brown => twig::MarkColor::Brown,
7720 }
7721}
7722
7723/// Where an offset lands after a splice it didn't make — twig's own rule, from
7724/// [`Change`]: shift anything at or past the replaced range's end by the length
7725/// the replacement gained or lost, and leave anything before it alone.
7726///
7727/// An offset *inside* the replaced range has no text of its own to ride any
7728/// more, and lands at the end of what replaced it: for
7729/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
7730/// the prefix is cleared, which then sits where the highlighted text begins.
7731/// One node's attribute list, twig's own `(key, value)` pairs owned — what
7732/// every presentation gesture reads, edits one key of, and passes back whole.
7733type Attrs = Vec<(String, Option<String>)>;
7734
7735/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
7736/// writes it and the walker draws it, and a frontend that paginates matches a
7737/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
7738/// stated once.
7739pub const PAGE_BREAK: &str = "page-break";
7740
7741/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
7742/// every other attribute kept in its place — the read-edit-write half of twig's
7743/// replace-not-merge contract for a `data-` key.
7744///
7745/// **A key that is already there is rewritten where it stands**, and only a key
7746/// the node did not have goes on the end. That is what makes the proposal's
7747/// worked example true: `class="lead center" id="intro"
7748/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
7749/// data-line-height="1.5"` — the same document with one token changed, and a
7750/// one-line diff. Removing the key and pushing it back would reorder the
7751/// author's attributes on every press, so a document that passed through the
7752/// editor came out shuffled even where nothing about it had changed.
7753///
7754/// A duplicate key — which no format leaf opens can spell, but twig reports
7755/// verbatim — collapses onto the first of its copies, since twig is handed one
7756/// value for one key either way.
7757fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
7758 let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
7759 let mut written = false;
7760 for (k, v) in attrs {
7761 if k != key {
7762 out.push((k.clone(), v.clone()));
7763 continue;
7764 }
7765 if let Some(new) = value.filter(|_| !written) {
7766 out.push((k.clone(), Some(new.to_string())));
7767 written = true;
7768 }
7769 }
7770 if let Some(new) = value.filter(|_| !written) {
7771 out.push((key.to_string(), Some(new.to_string())));
7772 }
7773 out
7774}
7775
7776/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
7777/// `token` added, with the rest of the list kept in order.
7778///
7779/// `class` is a space-separated token list, and leaf owns three of the tokens in
7780/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
7781/// goes out as `class="lead right"`; one whose last owned token goes and which
7782/// carried nothing else loses the key, so a block that has lost its whole
7783/// vocabulary is spelled bare again. `class` itself keeps its place among the
7784/// attributes, because [`with_attr`] does the writing.
7785fn with_class_token(
7786 attrs: &[(String, Option<String>)],
7787 mine: impl Fn(&str) -> bool,
7788 token: Option<&str>,
7789) -> Attrs {
7790 let kept: Vec<&str> = attrs
7791 .iter()
7792 .find(|(k, _)| k == "class")
7793 .and_then(|(_, v)| v.as_deref())
7794 .unwrap_or_default()
7795 .split_whitespace()
7796 .filter(|t| !mine(t))
7797 .collect();
7798 let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
7799 with_attr(
7800 attrs,
7801 "class",
7802 (!class.is_empty()).then_some(class.as_str()),
7803 )
7804}
7805
7806/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
7807///
7808/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
7809/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
7810/// (djot has no bare attribute, so no format reads one back everywhere), and
7811/// `hidden=""` is the same document where `hidden` is; dropping it instead
7812/// would lose what the author wrote, which is the one thing these gestures
7813/// promise not to do.
7814fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
7815 attrs
7816 .iter()
7817 .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
7818 .collect()
7819}
7820
7821fn reanchor(off: usize, change: &Change) -> usize {
7822 if off < change.old.start {
7823 return off;
7824 }
7825 if off < change.old.end {
7826 return change.new.end;
7827 }
7828 (off + change.new.end).saturating_sub(change.old.end)
7829}
7830
7831/// [`reanchor`] for an edit that respells the markup *around* a block and
7832/// leaves the block's own bytes alone — which is every attribute gesture.
7833///
7834/// `block` is that block's content span before and after the splice, so an
7835/// offset standing in the text keeps its distance from the text's start and how
7836/// many bytes twig wrote above it never enters the arithmetic. That is the whole
7837/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
7838/// on the same div lengthens the attribute line, clearing the last one takes the
7839/// div away entirely, and both are the same sum. `None` where the splice named
7840/// no block at either end, which is every djot case — the `{…}` line is written
7841/// above the block, and the block itself only shifts past it.
7842///
7843/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
7844/// shifted by its delta after it, and at the splice's end for an offset that
7845/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
7846/// text to keep.
7847fn reanchor_in_block(
7848 off: usize,
7849 change: &Change,
7850 block: Option<(&Range<usize>, &Range<usize>)>,
7851) -> usize {
7852 if let Some((was, now)) = block
7853 && was.start <= off
7854 && off <= was.end
7855 {
7856 return now.start + (off - was.start).min(now.end - now.start);
7857 }
7858 reanchor(off, change)
7859}
7860
7861/// A watermark for a file's contents (see `Doc::disk_hash`).
7862///
7863/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
7864/// watermark is compared only against one taken by the same process moments
7865/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
7866/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
7867fn hash_bytes(bytes: &[u8]) -> u64 {
7868 use std::hash::{Hash, Hasher};
7869 let mut h = std::collections::hash_map::DefaultHasher::new();
7870 bytes.hash(&mut h);
7871 h.finish()
7872}
7873
7874#[cfg(feature = "fs")]
7875fn detect_format(path: &Path) -> Result<Format> {
7876 let ext = path
7877 .extension()
7878 .and_then(|e| e.to_str())
7879 .unwrap_or("")
7880 .to_ascii_lowercase();
7881 Ok(match ext.as_str() {
7882 "dj" | "djot" => Format::Djot,
7883 "md" | "markdown" => Format::Markdown,
7884 "xml" => Format::Xml,
7885 "html" | "htm" => Format::Html,
7886 other => return Err(anyhow!("unknown document extension: .{other}")),
7887 })
7888}
7889
7890#[cfg(test)]
7891mod tests {
7892 use super::*;
7893 use crate::style::{FontFamily, LineSpacing, SizeStep};
7894
7895 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
7896 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
7897 /// without one is a view no user is ever in.
7898 fn doc_in(view: View, name: &str, body: &str) -> Doc {
7899 // The fixture name doubles as the temp file's, so two tests picking the
7900 // same one raced under the parallel runner and read each other's body —
7901 // a green suite proving the wrong thing. The counter makes that
7902 // unreachable rather than asking every future caller to notice.
7903 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7904 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7905 let mut p = std::env::temp_dir();
7906 p.push(format!("leaf_test_{name}_{seq}.md"));
7907 std::fs::write(&p, body).unwrap();
7908 let mut d = Doc::open(p).unwrap();
7909 d.view = view;
7910 if view == View::Wysiwyg {
7911 d.build_visual(80);
7912 }
7913 d
7914 }
7915
7916 // Source-view document for the source-behaviour tests. `Doc::open` now
7917 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
7918 // `wysiwyg_doc` builds the rich-text variant on top of this.
7919 fn doc_with(name: &str, body: &str) -> Doc {
7920 doc_in(View::Source, name, body)
7921 }
7922
7923 /// Every visual row's drawn text — what the reader actually sees, which is
7924 /// the only thing the reveal preference is supposed to change.
7925 fn drawn_rows(d: &Doc) -> Vec<String> {
7926 d.vmap
7927 .rows
7928 .iter()
7929 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7930 .collect()
7931 }
7932
7933 /// Put the caret at the first byte of `needle` and rebuild, so the row under
7934 /// it becomes the revealed line.
7935 fn caret_at(d: &mut Doc, needle: &str) {
7936 d.caret = d.source.find(needle).expect("needle in source");
7937 d.build_visual(80);
7938 }
7939
7940 #[test]
7941 fn blockquote_after_a_list_is_not_bulleted() {
7942 // twig nests a following top-level block quote under the `bullet_list`
7943 // (a direct child, not a `list_item`). The map must render it de-nested —
7944 // `│ quote`, never `• │ quote` — with a blank separator, like any block
7945 // that follows a list. Regression for the "combined list + blockquote" bug.
7946 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
7947 d.build_visual(80);
7948 let rows: Vec<String> = d
7949 .vmap
7950 .rows
7951 .iter()
7952 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7953 .collect();
7954 assert!(
7955 rows.iter().any(|r| r == "│ quote"),
7956 "block quote should render on its own gutter, got rows: {rows:?}"
7957 );
7958 assert!(
7959 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
7960 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
7961 );
7962 }
7963
7964 // ── the map is built at most once per (revision, wrap) ───────────────────
7965 //
7966 // A frontend repaints for reasons that have nothing to do with the text — a
7967 // blinking caret, a scroll — and rebuilding the map is O(document). These
7968 // pin *that the cache fires*, which a passing suite can't tell you: a cache
7969 // that never hits is invisible to every other test in this file.
7970 //
7971 // The probe is to wreck the built map and ask for it again. A rebuild
7972 // repairs it; a cache hit hands the wreckage straight back. Nothing else
7973 // can distinguish the two from outside.
7974
7975 #[test]
7976 fn a_rebuild_with_nothing_changed_reuses_the_map() {
7977 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
7978 d.build_visual(80);
7979 assert!(!d.vmap.rows.is_empty());
7980 d.vmap.rows.clear(); // wreck it
7981 d.build_visual(80);
7982 assert!(
7983 d.vmap.rows.is_empty(),
7984 "the map was rebuilt though nothing changed — the cache never fired"
7985 );
7986 }
7987
7988 #[test]
7989 fn an_edit_rebuilds_the_map() {
7990 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
7991 d.build_visual(80);
7992 let before = d.revision();
7993 d.vmap.rows.clear();
7994 d.insert("x");
7995 d.build_visual(80);
7996 assert!(d.revision() > before, "an edit must move the revision");
7997 assert!(
7998 !d.vmap.rows.is_empty(),
7999 "an edited document must not paint from a stale map"
8000 );
8001 }
8002
8003 #[test]
8004 fn a_width_change_rebuilds_the_map() {
8005 // The map is a function of the wrap width too, so a resize is a miss
8006 // even though the text is untouched.
8007 let mut d = doc_in(
8008 View::Wysiwyg,
8009 "cache_width",
8010 "one two three four five six\n",
8011 );
8012 d.build_visual(80);
8013 d.vmap.rows.clear();
8014 d.build_visual(12);
8015 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8016 // And the unwrapped map is its own key, not the same as any width.
8017 d.vmap.rows.clear();
8018 d.build_visual_unwrapped();
8019 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8020 }
8021
8022 #[test]
8023 fn a_motion_does_not_rebuild_the_map() {
8024 // The whole point: moving the caret changes nothing the map is built
8025 // from. If a motion bumped the revision, every arrow key would cost a
8026 // full rebuild and the cache would be worthless.
8027 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8028 d.build_visual(80);
8029 let rev = d.revision();
8030 d.move_right(false);
8031 d.move_right(true);
8032 d.move_down(false);
8033 assert_eq!(d.revision(), rev, "a motion must not move the revision");
8034 d.vmap.rows.clear();
8035 d.build_visual(80);
8036 assert!(
8037 d.vmap.rows.is_empty(),
8038 "a motion should not rebuild the map"
8039 );
8040 }
8041
8042 #[test]
8043 fn saving_does_not_rebuild_the_map() {
8044 // Saving changes `dirty`, not the text.
8045 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8046 d.insert("x");
8047 d.build_visual(80);
8048 let rev = d.revision();
8049 d.save();
8050 assert_eq!(d.revision(), rev, "a save must not move the revision");
8051 assert!(!d.dirty, "the save should have cleaned the document");
8052 }
8053
8054 #[test]
8055 fn a_reload_rebuilds_the_map() {
8056 // Reload replaces the text without going through `refresh`, so it has to
8057 // move the revision itself — else the editor paints the old file.
8058 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8059 d.build_visual(80);
8060 let rev = d.revision();
8061 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8062 d.reload();
8063 assert!(d.revision() > rev, "a reload must move the revision");
8064 d.build_visual(80);
8065 let text: String = d
8066 .vmap
8067 .rows
8068 .iter()
8069 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8070 .collect();
8071 assert!(
8072 text.contains("wholly new"),
8073 "the reloaded text should be on screen, got {text:?}"
8074 );
8075 }
8076
8077 // ── golden-case harness ──────────────────────────────────────────────────
8078 // The pattern the whole parity suite can reuse: write a fixture with the
8079 // caret marked by `|`, run one action, and compare the rendered result —
8080 // also caret-marked — against the expected string. One readable line per
8081 // behavior, and it exercises the exact `Doc` ops both frontends call.
8082
8083 /// Split a `|`-marked fixture into `(source, caret_offset)`.
8084 fn parse_caret(marked: &str) -> (String, usize) {
8085 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8086 (marked.replacen('|', "", 1), caret)
8087 }
8088
8089 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8090 /// selection) so a result reads like the fixtures.
8091 fn render_caret(d: &Doc) -> String {
8092 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8093 // so the caret always renders inside its own selection.
8094 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8095 if let Some((s, e)) = d.selection() {
8096 marks.push((s, 0, '['));
8097 marks.push((e, 2, ']'));
8098 }
8099 // Insert right-to-left: descending offset, then descending rank.
8100 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8101 let mut out = d.source.clone();
8102 for (at, _, ch) in marks {
8103 out.insert(at, ch);
8104 }
8105 out
8106 }
8107
8108 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8109 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8110 golden_in(View::Source, name, marked, action)
8111 }
8112
8113 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8114 /// the same fixture has to read the same way in both.
8115 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8116 let (src, caret) = parse_caret(marked);
8117 let mut d = doc_in(view, name, &src);
8118 d.caret = caret;
8119 action(&mut d);
8120 render_caret(&d)
8121 }
8122
8123 #[test]
8124 fn word_motion_walks_word_by_word() {
8125 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8126 assert_eq!(
8127 g("hello wor|ld", |d| d.move_word_left(false)),
8128 "hello |world"
8129 );
8130 assert_eq!(
8131 g("hello| world", |d| d.move_word_left(false)),
8132 "|hello world"
8133 );
8134 assert_eq!(
8135 g("hel|lo world", |d| d.move_word_right(false)),
8136 "hello| world"
8137 );
8138 assert_eq!(
8139 g("hello| world", |d| d.move_word_right(false)),
8140 "hello world|"
8141 );
8142 // Punctuation is its own class, so motion stops at the boundary.
8143 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8144 }
8145
8146 #[test]
8147 fn word_motion_extends_the_selection_when_asked() {
8148 assert_eq!(
8149 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8150 "hello [world|]"
8151 );
8152 }
8153
8154 #[test]
8155 fn delete_word_removes_a_whole_word() {
8156 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8157 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8158 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8159 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8160 }
8161
8162 // ── Home / End ───────────────────────────────────────────────────────────
8163
8164 #[test]
8165 fn home_toggles_between_the_line_s_text_and_its_margin() {
8166 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8167 // indent to the markup it spells everywhere it means one, so the fixture
8168 // with whitespace left to walk is a code block, which is verbatim.
8169 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8170 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
8171 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
8172 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
8173 // A line with no indentation has one place to go, so the toggle is a
8174 // no-op rather than a trip to nowhere.
8175 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8176 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8177
8178 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
8179 let indent = d.source.find(" indented").unwrap();
8180 d.caret = indent + 6; // inside "indented"
8181 d.move_home(false);
8182 assert_eq!(
8183 d.caret,
8184 indent + 4,
8185 "wysiwyg: Home aims at the code line's text"
8186 );
8187 d.move_home(false);
8188 assert_eq!(
8189 d.caret, indent,
8190 "wysiwyg: the second press takes the indent"
8191 );
8192 d.move_home(false);
8193 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8194 }
8195
8196 #[test]
8197 fn end_takes_the_line_the_view_is_showing() {
8198 // The line differs by view for the same document, and that is the point:
8199 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8200 // as a space on one row and the source view as two lines.
8201 let mut d = doc_with("end_src", "one two\nthree\n");
8202 d.caret = 1;
8203 d.move_end(false);
8204 assert_eq!(d.caret, 7, "source: the end of the source line");
8205
8206 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8207 d.caret = 1;
8208 d.move_end(false);
8209 assert_eq!(
8210 d.caret, 13,
8211 "wysiwyg: the end of the row, soft break and all"
8212 );
8213 }
8214
8215 #[test]
8216 fn home_and_end_extend_the_selection_when_asked() {
8217 for (view, tag) in VIEWS {
8218 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
8219 d.caret = 6;
8220 d.move_end(true);
8221 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
8222 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
8223 d.caret = 6;
8224 d.move_home(true);
8225 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
8226 }
8227 }
8228
8229 // ── kill to the line's start / end ───────────────────────────────────────
8230
8231 #[test]
8232 fn kill_to_the_line_start_and_end_in_both_views() {
8233 for (view, tag) in VIEWS {
8234 // The gap that reads as a paragraph break in each view: the source
8235 // view's lines are the renderer's rows only where the source says so.
8236 let gap = if view == View::Source { "\n" } else { "\n\n" };
8237 let mut d = doc_in(
8238 view,
8239 &format!("kill_end_{tag}"),
8240 &format!("one two{gap}three\n"),
8241 );
8242 d.caret = 3;
8243 d.delete_to_line_end();
8244 assert_eq!(
8245 d.source,
8246 format!("one{gap}three\n"),
8247 "{tag}: ^K to the line's end"
8248 );
8249 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
8250
8251 let mut d = doc_in(
8252 view,
8253 &format!("kill_start_{tag}"),
8254 &format!("one two{gap}three\n"),
8255 );
8256 d.caret = 7; // the end of the first line
8257 d.delete_to_line_start();
8258 assert_eq!(
8259 d.source,
8260 format!("{gap}three\n"),
8261 "{tag}: ⌘⌫ to the line's start"
8262 );
8263 assert_eq!(d.caret, 0, "{tag}");
8264 }
8265 }
8266
8267 #[test]
8268 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
8269 // The decision: at the boundary both kills do nothing, rather than
8270 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
8271 // at a soft wrap as often as at a newline, where there is nothing
8272 // written to delete — and a source newline is only half of the blank
8273 // line between two paragraphs, so taking it leaves a soft break rather
8274 // than the join it looks like. Backspace and Delete are the keys for it.
8275 for (view, tag) in VIEWS {
8276 let gap = if view == View::Source { "\n" } else { "\n\n" };
8277 let src = format!("one{gap}three\n");
8278 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
8279 d.caret = 3; // the end of "one"
8280 d.delete_to_line_end();
8281 assert_eq!(
8282 d.source, src,
8283 "{tag}: ^K at the line's end joined it to the next"
8284 );
8285
8286 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
8287 d.caret = 3 + gap.len(); // the start of "three"
8288 d.delete_to_line_start();
8289 assert_eq!(
8290 d.source, src,
8291 "{tag}: ⌘⌫ at the line's start joined it to the last"
8292 );
8293 }
8294 }
8295
8296 #[test]
8297 fn a_kill_takes_the_selection_when_there_is_one() {
8298 // What every other delete here does with one, so these two as well.
8299 for (view, tag) in VIEWS {
8300 for (name, kill) in [
8301 (
8302 "end",
8303 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
8304 ),
8305 ("start", |d: &mut Doc| d.delete_to_line_start()),
8306 ] {
8307 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
8308 d.anchor = Some(4);
8309 d.caret = 7; // "two"
8310 kill(&mut d);
8311 assert_eq!(
8312 d.source, "one three\n",
8313 "{tag}: {name} ignored the selection"
8314 );
8315 assert_eq!(d.selection(), None, "{tag}: {name}");
8316 }
8317 }
8318 }
8319
8320 #[test]
8321 fn a_kill_takes_the_markup_it_empties_with_it() {
8322 // The same hazard a word-delete has: a WYSIWYG range covers what the
8323 // user can see, which for `**bold**` is the word and never the
8324 // delimiters, so a kill that stopped at the text would leave `a ****` —
8325 // markup wrapped around nothing.
8326 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
8327 d.caret = d.source.find("bold").unwrap();
8328 d.delete_to_line_end();
8329 assert_eq!(d.source, "a \n");
8330 }
8331
8332 #[test]
8333 fn a_kill_is_undone_in_one_step() {
8334 for (view, tag) in VIEWS {
8335 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
8336 d.caret = 3;
8337 d.delete_to_line_end();
8338 assert_eq!(d.source, "one\n", "{tag}");
8339 d.undo();
8340 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
8341 }
8342 }
8343
8344 #[test]
8345 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
8346 // Regression: triple-click used move_home/move_end over visual rows, so
8347 // it only worked on a paragraph's first row (a wrap-boundary offset maps
8348 // to the earlier row). select_block_at reads the AST, so every offset in
8349 // the paragraph selects the whole thing.
8350 let body = "one two three four five six seven eight\n";
8351 let mut d = doc_with("sel_block", body);
8352 d.view = View::Wysiwyg;
8353 d.build_visual(12); // force the paragraph to wrap into several rows
8354 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
8355 let para = (0, "one two three four five six seven eight".len());
8356 for off in [0usize, 8, 19, 28, 38] {
8357 d.caret = 0;
8358 d.anchor = None;
8359 d.select_block_at(off);
8360 assert_eq!(
8361 d.selection(),
8362 Some(para),
8363 "offset {off} should select the paragraph"
8364 );
8365 }
8366 }
8367
8368 #[test]
8369 fn select_block_uses_content_span_for_a_heading() {
8370 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
8371 d.select_block_at(4); // inside "Title"
8372 // content_span excludes the "# " marker.
8373 assert_eq!(d.selected_text(), Some("Title"));
8374 d.select_block_at(10); // inside "body"
8375 assert_eq!(d.selected_text(), Some("body"));
8376 }
8377
8378 #[test]
8379 fn select_all_spans_the_document() {
8380 let mut d = doc_with("sel_all", "abc\n\ndef\n");
8381 d.select_all();
8382 assert_eq!(d.selection(), Some((0, d.source.len())));
8383 }
8384
8385 #[test]
8386 fn select_word_at_picks_the_surrounding_word() {
8387 let mut d = doc_with("sel_word", "hello world\n");
8388 d.select_word_at(8); // inside "world"
8389 assert_eq!(d.selection(), Some((6, 11)));
8390 // Double-clicking at end-of-word still grabs the word to its left.
8391 d.select_word_at(5); // the space between the words
8392 assert_eq!(d.selection(), Some((5, 6)));
8393 }
8394
8395 #[test]
8396 fn word_helpers_respect_utf8_boundaries() {
8397 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
8398 assert_eq!(
8399 golden("utf8", "|café ok", |d| d.move_word_right(false)),
8400 "café| ok"
8401 );
8402 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
8403 }
8404
8405 #[test]
8406 fn typing_inserts_at_the_caret_and_advances_it() {
8407 let mut d = doc_with("type", "hello\n");
8408 d.insert("Hi ");
8409 assert_eq!(d.source, "Hi hello\n");
8410 assert_eq!(d.caret, 3);
8411 assert!(d.dirty);
8412 }
8413
8414 #[test]
8415 fn backspace_deletes_the_char_before_the_caret() {
8416 let mut d = doc_with("bs", "hello\n");
8417 d.caret = 3; // after "hel"
8418 d.backspace();
8419 assert_eq!(d.source, "helo\n");
8420 assert_eq!(d.caret, 2);
8421 }
8422
8423 #[test]
8424 fn typing_replaces_the_selection() {
8425 let mut d = doc_with("replace", "a word b\n");
8426 d.anchor = Some(2);
8427 d.caret = 6; // "word" selected
8428 d.insert("X");
8429 assert_eq!(d.source, "a X b\n");
8430 assert_eq!(d.caret, 3);
8431 assert_eq!(d.anchor, None);
8432 }
8433
8434 #[test]
8435 fn toggle_bold_wraps_then_unwraps_the_selection() {
8436 let mut d = doc_with("bold", "a word b\n");
8437 d.anchor = Some(2);
8438 d.caret = 6;
8439 d.toggle(InlineKind::Strong);
8440 assert_eq!(d.source, "a **word** b\n");
8441 // The toggled region stays selected, so a second toggle reverses it.
8442 d.toggle(InlineKind::Strong);
8443 assert_eq!(d.source, "a word b\n");
8444 d.toggle(InlineKind::Strong);
8445 assert_eq!(d.source, "a **word** b\n");
8446 }
8447
8448 #[test]
8449 fn toggle_code_wraps_then_unwraps_the_selection() {
8450 let mut d = doc_with("code_rt", "a word b\n");
8451 d.anchor = Some(2);
8452 d.caret = 6;
8453 d.toggle(InlineKind::Verbatim);
8454 assert_eq!(d.source, "a `word` b\n");
8455 d.toggle(InlineKind::Verbatim);
8456 assert_eq!(d.source, "a word b\n");
8457 }
8458
8459 #[test]
8460 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
8461 // ⌘b at a bare caret, then type: the text comes out bold with no
8462 // selection ever made — the word-processor "start bold here" gesture.
8463 let mut d = doc_with("sticky_wrap", "xy\n");
8464 d.caret = 1; // between x and y
8465 d.toggle(InlineKind::Strong);
8466 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
8467 d.insert("A");
8468 assert_eq!(d.source, "x**A**y\n");
8469 }
8470
8471 #[test]
8472 fn sticky_bold_lights_the_toolbar_before_any_typing() {
8473 // The button must light the instant ⌘b is pressed, or the mode is
8474 // invisible until the first character lands.
8475 let mut d = doc_with("sticky_light", "xy\n");
8476 d.caret = 1;
8477 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8478 d.toggle(InlineKind::Strong);
8479 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8480 }
8481
8482 #[test]
8483 fn sticky_bold_toggled_off_types_normally_again() {
8484 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8485 // in the flow of typing, the exact sequence the user described.
8486 let mut d = doc_with("sticky_off", "\n");
8487 d.caret = 0;
8488 d.toggle(InlineKind::Strong);
8489 d.insert("a");
8490 d.insert("b"); // continues inside the run, no re-arming
8491 assert_eq!(d.source, "**ab**\n");
8492 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8493 d.insert("c");
8494 assert_eq!(d.source, "**ab**c\n");
8495 }
8496
8497 #[test]
8498 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8499 // Once a mark is realised the caret sits inside the run, so plain typing
8500 // extends it rather than starting a second, adjacent bold span.
8501 let mut d = doc_with("sticky_cont", "\n");
8502 d.caret = 0;
8503 d.toggle(InlineKind::Emph);
8504 d.insert("h");
8505 d.insert("i");
8506 assert_eq!(d.source, "*hi*\n");
8507 }
8508
8509 #[test]
8510 fn moving_the_caret_disarms_a_sticky_mark() {
8511 // Arming a mark and then moving away must not style text elsewhere.
8512 let mut d = doc_with("sticky_disarm", "xy\n");
8513 d.caret = 0;
8514 d.toggle(InlineKind::Strong);
8515 d.move_right(false); // caret 0 → 1, disarms
8516 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8517 d.insert("A");
8518 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8519 }
8520
8521 #[test]
8522 fn stacked_sticky_marks_apply_together() {
8523 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8524 let mut d = doc_with("sticky_stack", "\n");
8525 d.caret = 0;
8526 d.toggle(InlineKind::Strong);
8527 d.toggle(InlineKind::Emph);
8528 d.insert("x");
8529 // Land the caret on the styled character and confirm both marks are live.
8530 d.anchor = Some(d.source.find('x').unwrap());
8531 d.caret = d.anchor.unwrap() + 1;
8532 let marks = d.active_inline_marks();
8533 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8534 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8535 }
8536
8537 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8538
8539 #[test]
8540 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8541 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8542 // The space inside the run made `**bold **`, which is *not* bold — four
8543 // literal asterisks — so the rich view drew them, correctly and
8544 // uselessly, until the next character happened to close the run again.
8545 let mut d = wysiwyg_doc("edge_typing", "a \n");
8546 d.caret = 2;
8547 d.toggle(InlineKind::Strong);
8548 for c in "bold".chars() {
8549 d.insert(&c.to_string());
8550 }
8551 assert_eq!(d.source, "a **bold**\n");
8552 d.insert(" ");
8553 assert_eq!(
8554 d.source, "a **bold** \n",
8555 "the space belongs outside the run"
8556 );
8557 assert!(
8558 d.active_inline_marks().contains(InlineKind::Strong),
8559 "bold is still what's being typed, so the button stays lit"
8560 );
8561 // What the writer is looking at while all this happens: their words.
8562 d.build_visual(80);
8563 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8564 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8565 for c in "hey".chars() {
8566 d.insert(&c.to_string());
8567 }
8568 assert_eq!(
8569 d.source, "a **bold hey**\n",
8570 "one bold phrase, not two runs"
8571 );
8572 }
8573
8574 #[test]
8575 fn typing_past_a_space_can_still_leave_the_bold_behind() {
8576 // The other half: the marks stay armed across the space, so ⌘b turns
8577 // them off again there and the next word is plain — the run isn't
8578 // rejoined by a caret that was told not to.
8579 let mut d = wysiwyg_doc("edge_shed", "\n");
8580 d.caret = 0;
8581 d.toggle(InlineKind::Strong);
8582 for c in "bold ".chars() {
8583 d.insert(&c.to_string());
8584 }
8585 assert_eq!(d.source, "**bold** \n");
8586 d.toggle(InlineKind::Strong);
8587 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8588 d.insert("x");
8589 assert_eq!(d.source, "**bold** x\n");
8590 }
8591
8592 #[test]
8593 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8594 // ⌘b and then a space before any word: the space is not marked (nothing
8595 // is), and the word after it is.
8596 let mut d = wysiwyg_doc("edge_space_first", "a\n");
8597 d.caret = 1;
8598 d.toggle(InlineKind::Strong);
8599 d.insert(" ");
8600 assert_eq!(d.source, "a \n");
8601 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8602 d.insert("b");
8603 assert_eq!(d.source, "a **b**\n");
8604 }
8605
8606 #[test]
8607 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8608 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8609 d.caret = 8; // the caret's home at the end of the run's text
8610 d.insert(" ");
8611 assert_eq!(
8612 d.source, "x **bold** \n",
8613 "the space lands past the delimiters"
8614 );
8615 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
8616
8617 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
8618 d.caret = 4; // in front of the "b"
8619 d.insert(" ");
8620 assert_eq!(d.source, "x **bold** y\n");
8621 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
8622 }
8623
8624 #[test]
8625 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
8626 // Backspace over the last letter of a bold phrase.
8627 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
8628 d.caret = 10; // past the "h"
8629 d.backspace();
8630 assert_eq!(d.source, "a **bold** \n");
8631 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
8632 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8633 d.insert("x");
8634 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
8635 }
8636
8637 #[test]
8638 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
8639 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
8640 // mark, which is only text. The marks live on in the caret instead.
8641 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
8642 d.caret = 5;
8643 d.backspace();
8644 assert_eq!(d.source, "a c\n");
8645 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8646 d.insert("x");
8647 assert_eq!(d.source, "a **x** c\n");
8648 }
8649
8650 #[test]
8651 fn typing_over_a_whole_bold_word_keeps_it_bold() {
8652 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
8653 d.anchor = Some(4);
8654 d.caret = 8; // the word, not its delimiters
8655 d.insert("x");
8656 assert_eq!(d.source, "a **x** c\n");
8657 }
8658
8659 #[test]
8660 fn a_code_span_keeps_the_space_it_is_given() {
8661 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
8662 // is still verbatim, so nothing is re-spelt. The repair asks the parser
8663 // rather than a table of kinds, and this is the answer it gets.
8664 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
8665 d.caret = 7;
8666 d.insert(" ");
8667 assert_eq!(d.source, "a `code ` c\n");
8668 }
8669
8670 #[test]
8671 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
8672 // A run's closing delimiter has a caret home on each side of it, one
8673 // column apart on screen — and a plain ← off the space after a bold word
8674 // lands on the outer one. The character drawn behind the caret there is
8675 // still the last letter of the phrase, so that is what Backspace takes;
8676 // the byte behind it is a `*` nobody can see.
8677 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
8678 d.caret = 9;
8679 d.move_left(false);
8680 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
8681 d.backspace();
8682 assert_eq!(
8683 d.source, "**bol** x\n",
8684 "a letter of the phrase, not its `*`"
8685 );
8686 assert_eq!(d.caret, 5);
8687
8688 // And the mirror in front of the opening delimiter, where Delete's
8689 // character is the first letter of the run.
8690 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
8691 d.caret = 1;
8692 d.delete_forward();
8693 assert_eq!(d.source, "x**old**\n");
8694 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
8695 }
8696
8697 #[test]
8698 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
8699 // The byte beside the caret at either edge of a bold word is a `*` the
8700 // rich view draws nothing for. Taking it is not the character delete the
8701 // key was pressed for — it unspells the run and puts a literal asterisk
8702 // on screen (`a *bold** c`). The visible character is the one that goes.
8703 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
8704 d.caret = 4; // in front of the "b"
8705 d.backspace();
8706 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
8707
8708 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
8709 d.caret = 8; // past the "d"
8710 d.delete_forward();
8711 assert_eq!(d.source, "a **bold**c\n");
8712 assert_eq!(d.caret, 8, "and the caret stays inside the run");
8713 d.insert("x");
8714 assert_eq!(d.source, "a **boldx**c\n");
8715
8716 // A code span's backticks are hidden the same way, so they are covered
8717 // by the same rule and not by a list of kinds.
8718 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
8719 d.caret = 3;
8720 d.backspace();
8721 assert_eq!(d.source, "a`code` c\n");
8722 }
8723
8724 #[test]
8725 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
8726 // The asterisks are on the screen there and the caret can stand between
8727 // them, so a delete takes exactly the byte it is aimed at.
8728 let mut d = doc_with("edge_open_src", "a **bold** c\n");
8729 d.caret = 4;
8730 d.backspace();
8731 assert_eq!(d.source, "a *bold** c\n");
8732
8733 let mut d = doc_with("edge_close_src", "a **bold** c\n");
8734 d.caret = 8;
8735 d.delete_forward();
8736 assert_eq!(d.source, "a **bold* c\n");
8737 }
8738
8739 #[test]
8740 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
8741 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
8742 // The space had stepped outside the run (the mark-edge rule), taking the
8743 // caret with it, so the delete put it back down on the far side of the
8744 // closing `**` — one place on screen, and the wrong side of it. Typing
8745 // came out plain and the toolbar went dark, with nothing to see.
8746 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
8747 d.caret = 0;
8748 d.toggle(InlineKind::Strong);
8749 for c in "bold".chars() {
8750 d.insert(&c.to_string());
8751 }
8752 d.insert(" ");
8753 assert_eq!(d.source, "**bold** \n");
8754 d.backspace();
8755 assert_eq!(
8756 d.source, "**bold**\n",
8757 "the space goes, the delimiters stay"
8758 );
8759 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
8760 assert!(
8761 d.active_inline_marks().contains(InlineKind::Strong),
8762 "so the button is still lit"
8763 );
8764 d.insert("x");
8765 assert_eq!(
8766 d.source, "**boldx**\n",
8767 "and the next character is still bold"
8768 );
8769 }
8770
8771 #[test]
8772 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
8773 // What the stranded caret did next: the byte behind it was the closing
8774 // `*`, so a second press took that instead of a letter — `**bold*`, the
8775 // styling gone and an asterisk on the screen where the word had been.
8776 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
8777 d.caret = 0;
8778 d.toggle(InlineKind::Strong);
8779 for c in "bold ".chars() {
8780 d.insert(&c.to_string());
8781 }
8782 assert_eq!(d.source, "**bold** \n");
8783 d.backspace();
8784 d.backspace();
8785 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
8786 assert_eq!(d.caret, 5);
8787 }
8788
8789 #[test]
8790 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
8791 // `***both***` closes two runs with one stack of asterisks: the caret has
8792 // to walk in through all of them, or it lands between the emph and the
8793 // strong and types half-marked.
8794 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
8795 d.caret = 11;
8796 d.backspace();
8797 assert_eq!(d.source, "***both***\n");
8798 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
8799 d.insert("x");
8800 assert_eq!(d.source, "***bothx***\n");
8801 }
8802
8803 #[test]
8804 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
8805 // The settle only moves a caret a run actually closed over. Ordinary
8806 // deletes — inside a run, or in plain prose — are untouched.
8807 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
8808 d.caret = 8;
8809 d.backspace();
8810 assert_eq!(d.source, "a **bol** c\n");
8811 assert_eq!(d.caret, 7);
8812
8813 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
8814 d.caret = 5;
8815 d.backspace();
8816 assert_eq!(d.source, "plai\n");
8817 assert_eq!(d.caret, 4);
8818 }
8819
8820 #[test]
8821 fn the_source_view_leaves_a_delete_where_it_landed() {
8822 // The delimiters are on the screen there, so the offset past them is a
8823 // place the caret can be seen to be — nothing to settle.
8824 let mut d = doc_with("edge_bksp_src", "**bold** \n");
8825 d.caret = 9;
8826 d.backspace();
8827 assert_eq!(d.source, "**bold**\n");
8828 assert_eq!(d.caret, 8);
8829 }
8830
8831 #[test]
8832 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
8833 // `***both***` closes two runs with one stack of asterisks; a space that
8834 // clears only the inner one lands against the outer's and breaks that
8835 // instead.
8836 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
8837 d.caret = 9;
8838 d.insert(" ");
8839 assert_eq!(d.source, "a ***both*** \n");
8840 assert_eq!(d.caret, 13);
8841 d.insert("x");
8842 assert_eq!(d.source, "a ***both x***\n");
8843 }
8844
8845 #[test]
8846 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
8847 // The delimiter shuffle is not an edit the writer made, so it is not a
8848 // step they have to undo past.
8849 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
8850 d.caret = 8;
8851 d.insert(" ");
8852 assert_eq!(d.source, "a **bold** \n");
8853 d.undo();
8854 assert_eq!(d.source, "a **bold**\n");
8855 }
8856
8857 #[test]
8858 fn the_source_view_types_the_space_where_it_was_asked_to() {
8859 // The rule is a rich-view courtesy. In the source view the delimiters are
8860 // on the screen and the user is editing the bytes they can see.
8861 let mut d = doc_with("edge_src", "a **bold** c\n");
8862 d.caret = 8;
8863 d.insert(" ");
8864 assert_eq!(d.source, "a **bold ** c\n");
8865 }
8866
8867 #[test]
8868 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
8869 // Double-clicking a word takes the space after it; bolding that must not
8870 // spell `**word **`, which is not bold at all.
8871 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
8872 d.anchor = Some(2);
8873 d.caret = 7; // "word "
8874 d.toggle(InlineKind::Strong);
8875 assert_eq!(d.source, "a **word** b\n");
8876 d.toggle(InlineKind::Strong);
8877 assert_eq!(d.source, "a word b\n");
8878 d.toggle(InlineKind::Strong);
8879 assert_eq!(
8880 d.source, "a **word** b\n",
8881 "reapplying the mark must not wrap stale delimiter offsets"
8882 );
8883 // And a selection of nothing but whitespace has no word to mark.
8884 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
8885 d.anchor = Some(6);
8886 d.caret = 7;
8887 d.toggle(InlineKind::Strong);
8888 assert_eq!(d.source, "a word b\n");
8889 assert!(d.status.is_some());
8890 }
8891
8892 #[test]
8893 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
8894 let mut d = doc_with("head_set", "hello\n");
8895 d.caret = 2; // caret inside the paragraph, no selection
8896 d.set_block(BlockKind::Heading(1));
8897 assert_eq!(d.source, "# hello\n");
8898 }
8899
8900 #[test]
8901 fn set_block_heading_works_in_wysiwyg_view() {
8902 // The app defaults to WYSIWYG; the caret is a source offset either way.
8903 let mut d = wysiwyg_doc("head_wys", "hello\n");
8904 d.caret = 2;
8905 d.set_block(BlockKind::Heading(1));
8906 assert_eq!(d.source, "# hello\n");
8907 }
8908
8909 #[test]
8910 fn toggle_heading_applies_switches_and_reverts() {
8911 let mut d = doc_with("head_toggle", "hello\n");
8912 d.caret = 2;
8913 d.toggle_heading(1);
8914 assert_eq!(d.source, "# hello\n"); // paragraph → H1
8915 d.toggle_heading(2);
8916 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
8917 d.toggle_heading(2);
8918 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
8919 }
8920
8921 #[test]
8922 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
8923 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
8924 // the blank line but the caret rendered on the *next* line, because the
8925 // separator was a non-navigable decoration row. In Preserve flow that
8926 // blank line is a real caret home — the caret must resolve onto it, and
8927 // typing there makes the soft break that continues the paragraph.
8928 let src = "line one:\nsecond line\n";
8929 let mut d = wysiwyg_doc("pre_enter_lineend", src);
8930 d.set_line_flow(LineFlow::Preserve);
8931 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
8932 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
8933 d.newline();
8934 d.build_visual_unwrapped();
8935 assert_eq!(d.source, "line one:\n\nsecond line\n");
8936 assert_eq!(
8937 d.caret, 10,
8938 "caret sits on the new blank line, not the next line"
8939 );
8940 // The blank line is row 1, and the caret resolves onto it — not row 2.
8941 assert_eq!(
8942 d.vmap.pos_of_offset(10),
8943 (1, 0),
8944 "caret renders on the blank row"
8945 );
8946 assert!(
8947 !d.vmap.rows[1].decoration,
8948 "the blank line is navigable in Preserve"
8949 );
8950 // Typing there makes a soft break: one paragraph, three lines.
8951 d.insert("new clause,");
8952 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
8953 }
8954
8955 #[test]
8956 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
8957 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
8958 // that keeps it one paragraph — where Fold would open a second paragraph.
8959 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
8960 d.set_line_flow(LineFlow::Preserve);
8961 d.caret = 3;
8962 d.newline();
8963 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
8964
8965 // End-of-paragraph: Enter then typing continues the same paragraph on a
8966 // new line (a soft break), not a fresh paragraph.
8967 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
8968 d.set_line_flow(LineFlow::Preserve);
8969 d.caret = 3;
8970 d.newline();
8971 d.insert("def");
8972 assert_eq!(
8973 d.source, "abc\ndef\n",
8974 "end-of-line Enter + typing is a soft break"
8975 );
8976 }
8977
8978 #[test]
8979 fn preserve_double_enter_still_makes_a_paragraph() {
8980 // Two Enters in a row promote to a real paragraph break: the second lands
8981 // on the blank line the first opened and takes the empty-line branch.
8982 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
8983 d.set_line_flow(LineFlow::Preserve);
8984 d.caret = 3;
8985 d.newline();
8986 d.newline();
8987 d.insert("def");
8988 assert_eq!(
8989 d.source, "abc\n\ndef\n",
8990 "double Enter is a paragraph break"
8991 );
8992 }
8993
8994 #[test]
8995 fn preserve_backspace_joins_across_a_soft_break() {
8996 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
8997 // soft break it deletes the single newline and joins the two lines.
8998 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
8999 d.set_line_flow(LineFlow::Preserve);
9000 d.build_visual(80);
9001 d.caret = 4; // start of "def", just past the soft break
9002 d.backspace();
9003 assert_eq!(
9004 d.source, "abcdef\n",
9005 "Backspace joins across the soft break"
9006 );
9007 assert_eq!(d.caret, 3, "caret lands where the lines meet");
9008 }
9009
9010 #[test]
9011 fn fold_enter_still_starts_a_new_paragraph() {
9012 // The default flow is unchanged: a lone `\n` would render as an invisible
9013 // space, so Enter keeps opening the paragraph break that actually shows.
9014 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9015 d.caret = 3;
9016 d.newline();
9017 assert_eq!(
9018 d.source, "abc\n\ndef\n",
9019 "Fold mid-line Enter is a paragraph break"
9020 );
9021 }
9022
9023 #[test]
9024 fn wysiwyg_one_enter_starts_a_new_paragraph() {
9025 // Regression: one Enter left the caret between the two newlines, so typing
9026 // made a soft break (one paragraph) and you needed a second Enter.
9027 let mut d = wysiwyg_doc("wys_enter", "abc\n");
9028 d.caret = 3;
9029 d.newline();
9030 d.insert("def");
9031 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9032 }
9033
9034 #[test]
9035 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9036 // Regression: Enter at the caret's natural End-of-line resting place
9037 // after a bold run with nothing following it (on screen: right after
9038 // "bold", before the hidden closing "**") spliced the paragraph break
9039 // at that very byte offset — which sits *before* the closing "**" in
9040 // the source, since the delimiter is hidden and emits no glyph of its
9041 // own for `push_row`'s "end of row" fallback to count. That severed the
9042 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9043 // "**" alone on the new line instead of leaving "**bold**" intact with
9044 // a fresh empty paragraph after it.
9045 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9046 d.move_end(false); // the WYSIWYG End key, from caret 0
9047 assert_eq!(
9048 d.caret, 6,
9049 "caret rests right after \"bold\", before the hidden \"**\""
9050 );
9051 d.newline();
9052 assert!(
9053 d.source.starts_with("**bold**"),
9054 "the closing ** must stay attached to \"bold\": got {:?}",
9055 d.source
9056 );
9057 assert_eq!(
9058 d.source, "**bold**\n\n\n",
9059 "a fresh empty paragraph follows the still-intact bold run"
9060 );
9061 }
9062
9063 #[test]
9064 fn source_view_enter_is_a_single_newline() {
9065 let mut d = doc_with("src_enter", "abc\n");
9066 d.caret = 3;
9067 d.newline();
9068 assert_eq!(d.source, "abc\n\n");
9069 }
9070
9071 #[test]
9072 fn heading_applies_at_the_end_of_a_paragraph() {
9073 // The caret at a line end sits at the doc level; set_block must still find
9074 // the block on that line.
9075 let mut d = doc_with("head_end", "abc\n");
9076 d.caret = 3; // end of "abc"
9077 d.toggle_heading(1);
9078 assert_eq!(d.source, "# abc\n");
9079 }
9080
9081 #[test]
9082 fn heading_on_an_empty_new_paragraph_creates_one() {
9083 let mut d = wysiwyg_doc("head_empty", "abc\n");
9084 d.caret = 3;
9085 d.newline(); // caret now on a fresh, empty paragraph
9086 d.toggle_heading(1);
9087 d.insert("Title");
9088 assert!(d.source.contains("# Title"), "got {:?}", d.source);
9089 }
9090
9091 #[test]
9092 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9093 // The reported bug, end to end: click a blank line with another one under
9094 // it, press H1, type. The text landed in the heading and the caret's
9095 // offset was right (the source view drew it there), but the rich view
9096 // drew it two rows lower, on the trailing blank line — the empty `# `
9097 // heading had left every row below it short by the marker's two bytes,
9098 // and the blank line ended up claiming the heading's own end offset.
9099 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9100 d.build_visual_unwrapped();
9101 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9102 d.toggle_heading(1);
9103 for c in "title".chars() {
9104 d.insert(&c.to_string());
9105 d.build_visual_unwrapped(); // as a frontend does, one frame per key
9106 }
9107 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9108 assert_eq!(
9109 d.caret_pos(),
9110 (4, 5),
9111 "the caret draws at the end of the heading"
9112 );
9113 }
9114
9115 #[test]
9116 fn clicking_an_empty_heading_types_after_its_marker() {
9117 // The same anchor from the other side: the empty heading's row is its own
9118 // caret home, so a click on it must land past the hidden `# `. Landing in
9119 // front of the hashes made the first keystroke un-heading the line.
9120 let mut d = wysiwyg_doc("head_click", "# \n");
9121 d.build_visual_unwrapped();
9122 d.caret = d.vmap.offset_of_pos(0, 0);
9123 d.insert("x");
9124 assert_eq!(d.source, "# x\n");
9125 }
9126
9127 #[test]
9128 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9129 let mut d = wysiwyg_doc("head_enter", "# Title\n");
9130 d.caret = 7; // end of the heading
9131 d.newline();
9132 d.insert("body");
9133 assert_eq!(d.source, "# Title\n\nbody\n");
9134 }
9135
9136 #[test]
9137 fn wysiwyg_enter_continues_a_bullet_list() {
9138 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9139 d.caret = 6; // end of "item"
9140 d.newline();
9141 d.insert("two");
9142 assert_eq!(d.source, "- item\n- two\n");
9143 }
9144
9145 #[test]
9146 fn wysiwyg_enter_increments_an_ordered_list() {
9147 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9148 d.caret = 6; // end of "one"
9149 d.newline();
9150 d.insert("two");
9151 assert_eq!(d.source, "1. one\n2. two\n");
9152 }
9153
9154 #[test]
9155 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9156 // Regression for the "extra newline" left between a list and the paragraph
9157 // below it. Enter, Enter leaves the list on a fresh empty paragraph
9158 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9159 // Backspace should then take the caret cleanly back to the end of the list
9160 // item, `- item\n\nnext`, not delete a single newline and strand it on the
9161 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9162 // no caret can land on. The map is rebuilt between keystrokes exactly as a
9163 // frontend does, since Backspace reads the stop table to place the delete.
9164 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9165 d.caret = 6; // end of "item"
9166 d.newline();
9167 d.build_visual(80);
9168 d.newline(); // leave the list onto a fresh empty paragraph
9169 d.build_visual(80);
9170 assert_eq!(
9171 d.source, "- item\n\n\n\nnext\n",
9172 "double-Enter opens the empty paragraph"
9173 );
9174 d.backspace();
9175 assert_eq!(
9176 d.source, "- item\n\nnext\n",
9177 "one Backspace collapses the whole gap"
9178 );
9179 assert_eq!(
9180 d.caret, 6,
9181 "and lands the caret back at the end of the list item"
9182 );
9183 }
9184
9185 #[test]
9186 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9187 // The stop-wise delete must not over-reach when there is no block boundary
9188 // to cross: two blank lines in a row are one caret stop apart, so pressing
9189 // Enter on an empty line and then Backspace removes exactly the one newline
9190 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9191 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9192 d.caret = 5; // the empty paragraph the first Enter already opened
9193 d.build_visual(80);
9194 d.newline();
9195 d.build_visual(80);
9196 assert_eq!(
9197 d.source, "abc\n\n\n\n",
9198 "Enter on the blank line adds one newline"
9199 );
9200 d.backspace();
9201 assert_eq!(
9202 d.source, "abc\n\n\n",
9203 "Backspace takes back exactly that one newline"
9204 );
9205 }
9206
9207 #[test]
9208 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9209 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9210 d.caret = 6; // end of the empty "- " item
9211 d.newline();
9212 d.insert("p");
9213 assert_eq!(d.source, "- a\n\np\n");
9214 }
9215
9216 #[test]
9217 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
9218 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
9219 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
9220 // not take the list-exit path (which would splice the `- ` away as if
9221 // leaving an empty item); the AST guard sends it to a normal break and
9222 // leaves the underline intact.
9223 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
9224 assert!(
9225 d.nodes().iter().any(|n| n.kind == Kind::Heading),
9226 "precondition: twig parses this as a heading, not a list",
9227 );
9228 d.caret = 7; // on the `- ` underline line
9229 d.newline();
9230 assert!(
9231 d.source.contains("- "),
9232 "the setext underline survives, not spliced away as a list item: {:?}",
9233 d.source,
9234 );
9235 }
9236
9237 #[test]
9238 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
9239 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
9240 d.caret = 7; // end of "abc" inside the fence
9241 d.newline();
9242 d.insert("def");
9243 assert_eq!(d.source, "```\nabc\ndef\n```\n");
9244 }
9245
9246 #[test]
9247 fn wysiwyg_enter_continues_a_block_quote() {
9248 // Enter opens a new *paragraph* inside the quote, not a second line of
9249 // the same one. `> quote\n> more` is a soft break, which under
9250 // `LineFlow::Fold` renders as a space — the keystroke would look like it
9251 // did nothing. The quoted blank line is what makes the break visible, and
9252 // it's the same thing Enter does in running prose.
9253 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
9254 d.caret = 7; // end of "quote"
9255 d.newline();
9256 d.insert("more");
9257 assert_eq!(d.source, "> quote\n>\n> more\n");
9258 // Still one quote, now holding two paragraphs — not a quote and a stray
9259 // line that fell out of it.
9260 let quotes = d
9261 .nodes()
9262 .iter()
9263 .filter(|n| n.kind == Kind::BlockQuote)
9264 .count();
9265 assert_eq!(quotes, 1);
9266 }
9267
9268 #[test]
9269 fn set_block_makes_a_heading_at_the_caret() {
9270 let mut d = doc_with("head", "Title\n\nbody\n");
9271 d.caret = 0;
9272 d.set_block(BlockKind::Heading(2));
9273 assert_eq!(d.source, "## Title\n\nbody\n");
9274 d.set_block(BlockKind::Paragraph);
9275 assert_eq!(d.source, "Title\n\nbody\n");
9276 }
9277
9278 // ── block containers (quote / list) ──────────────────────────────────────
9279
9280 #[test]
9281 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
9282 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
9283 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
9284 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
9285 // A caret at a line end sits at the doc level; the block is still found.
9286 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
9287 }
9288
9289 #[test]
9290 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
9291 // Every source line of the paragraph gets its own `> `, so a caret left
9292 // on its old byte offset falls one prefix per line above it too far
9293 // back — inside the markup it just asked for rather than in its word.
9294 assert_eq!(
9295 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
9296 "> aaa\n> b|bb\n> ccc\n"
9297 );
9298 }
9299
9300 #[test]
9301 fn toggle_blockquote_works_in_wysiwyg_view() {
9302 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9303 assert_eq!(
9304 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
9305 "> hel|lo\n"
9306 );
9307 assert_eq!(
9308 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
9309 "hel|lo\n"
9310 );
9311 }
9312
9313 #[test]
9314 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
9315 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
9316 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9317 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9318 // The *other* kind converts in place instead of nesting, which is what
9319 // makes the two buttons one three-state control.
9320 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9321 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9322 // Its own kind, over the only item the list holds, takes it off.
9323 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
9324 }
9325
9326 #[test]
9327 fn toggle_list_works_in_wysiwyg_view() {
9328 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9329 assert_eq!(
9330 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
9331 "1. hel|lo\n"
9332 );
9333 assert_eq!(
9334 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
9335 "- hel|lo\n"
9336 );
9337 assert_eq!(
9338 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
9339 "hel|lo\n"
9340 );
9341 }
9342
9343 #[test]
9344 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
9345 // The selection has to grow with the markup: twig takes a container off
9346 // only a range covering every block it holds, so the second press can
9347 // reverse the first only if the result is what's selected.
9348 let mut d = doc_with("list_sel", "abc\n\ndef\n");
9349 d.select_all();
9350 d.toggle_list(true);
9351 assert_eq!(d.source, "1. abc\n\n2. def\n");
9352 assert_eq!(d.selection(), Some((0, d.source.len())));
9353 d.toggle_list(true);
9354 assert_eq!(d.source, "abc\n\ndef\n");
9355 }
9356
9357 #[test]
9358 fn toggle_blockquote_nests_a_partly_covered_quote() {
9359 // twig's rule: covering only some of a container's blocks nests, because
9360 // taking the quote off would drag its uncovered siblings out with it.
9361 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
9362 d.caret = 2; // in the first quoted paragraph only
9363 d.toggle_blockquote();
9364 assert_eq!(d.source, "> > a\n>\n> b\n");
9365 }
9366
9367 #[test]
9368 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
9369 // A blank line used to be no block for twig to wrap —
9370 // `toggle_block_container` answered `NotFound` — so Quote and the list
9371 // buttons did nothing on the very line the H1 button works on, and leaf
9372 // lent twig a scratch paragraph to wrap and took it back out again.
9373 // twig 3.2.0 opens an empty container there itself, so what is left here
9374 // is where the caret lands: inside the marker that was just written.
9375 let mut d = doc_with("quote_blank", "\nabc\n");
9376 d.caret = 0;
9377 d.toggle_blockquote();
9378 assert_eq!(d.source, "> \nabc\n");
9379 assert_eq!(
9380 d.caret, 2,
9381 "the caret belongs inside the quote it just opened"
9382 );
9383 assert!(d.status.is_none(), "{:?}", d.status);
9384 assert!(d.dirty);
9385
9386 // And the paragraph below is still its own block: an empty container one
9387 // soft break from `abc` would take that paragraph into the quote with it.
9388 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
9389 d.caret = 0;
9390 d.toggle_blockquote();
9391 d.build_visual(80);
9392 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
9393
9394 // The same from the other side: a blank line directly under a paragraph
9395 // earns the blank line an empty block needs, rather than being read as a
9396 // soft break inside that paragraph.
9397 let mut d = doc_with("list_blank_below", "abc\n");
9398 d.caret = 4;
9399 d.toggle_list(false);
9400 assert_eq!(d.source, "abc\n\n- ");
9401 assert_eq!(d.caret, 7);
9402 }
9403
9404 #[test]
9405 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
9406 // The gesture the rendering fix is for. `newline` inside a quote already
9407 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
9408 // spelling — but the two marker lines it adds belonged to no node until
9409 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
9410 // just made drew as plain prose under the quote.
9411 let mut d = wysiwyg_doc("quote_enter", "> a\n");
9412 d.caret = 3; // past `a`, at the end of the quoted line
9413 d.newline();
9414 assert_eq!(d.source, "> a\n>\n> \n");
9415 d.build_visual(80);
9416 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
9417 // And the caret is on the new line, not stranded on the old one.
9418 assert_eq!(d.caret, 8);
9419 }
9420
9421 #[test]
9422 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
9423 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
9424 // now it is twig's single edit. Either way one ⌘z has to put the blank
9425 // line back rather than undoing into a half-built document.
9426 for open in [
9427 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
9428 &|d: &mut Doc| d.toggle_list(false),
9429 &|d: &mut Doc| d.toggle_list(true),
9430 ] {
9431 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
9432 d.caret = 3;
9433 open(&mut d);
9434 assert_ne!(d.source, "a\n\n\n\nb\n");
9435 d.undo();
9436 assert_eq!(d.source, "a\n\n\n\nb\n");
9437 }
9438 }
9439
9440 #[test]
9441 fn a_container_toggle_is_one_undo_step() {
9442 let mut d = doc_with("quote_undo", "hello\n");
9443 d.caret = 3;
9444 d.insert("X"); // a typing run the structural edit must not fold into
9445 d.toggle_blockquote();
9446 assert_eq!(d.source, "> helXlo\n");
9447 d.undo();
9448 assert_eq!(d.source, "helXlo\n");
9449 }
9450
9451 // ── links ────────────────────────────────────────────────────────────────
9452
9453 #[test]
9454 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
9455 let mut d = doc_with("link_sel", "word here\n");
9456 d.anchor = Some(0);
9457 d.caret = 4;
9458 d.insert_link("http://x.dev");
9459 assert_eq!(d.source, "[word](http://x.dev) here\n");
9460 // The text, not the destination — so a second press re-points the link
9461 // the first one made rather than nesting one inside it.
9462 assert_eq!(d.selected_text(), Some("word"));
9463 d.insert_link("http://y.dev");
9464 assert_eq!(d.source, "[word](http://y.dev) here\n");
9465 assert_eq!(d.selected_text(), Some("word"));
9466 }
9467
9468 #[test]
9469 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
9470 let mut d = doc_with("img_caret", "before after\n");
9471 d.caret = 7; // between "before " and "after"
9472 d.insert_image("cat.png", "a cat");
9473 assert_eq!(d.source, "before after\n");
9474 // The caret sits just past the inserted image, nothing selected.
9475 assert_eq!(d.selection(), None);
9476 assert_eq!(d.caret, 7 + "".len());
9477 }
9478
9479 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
9480 /// destination at the first space, so the `format!` this used to be wrote
9481 /// something that was not an image at all — and the reader saw the markup as
9482 /// text. twig owns the spelling now, and moves it into the angle form.
9483 #[test]
9484 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9485 let mut d = doc_with("img_space", "x\n");
9486 d.caret = 0;
9487 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9488 assert_eq!(
9489 d.source,
9490 "x\n"
9491 );
9492 // And it reads back as an image pointing at the unescaped path — the angle
9493 // brackets are spelling, not part of the destination.
9494 d.caret = 2;
9495 assert_eq!(
9496 d.image_destination_at_caret(),
9497 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9498 );
9499 }
9500
9501 /// A `)` in a caption or a filename must not close the image early.
9502 #[test]
9503 fn insert_image_escapes_a_paren_in_either_half() {
9504 let mut d = doc_with("img_paren", "x\n");
9505 d.caret = 0;
9506 d.insert_image("a)b.png", "");
9507 assert_eq!(d.source, "b.png)x\n");
9508 d.caret = 2;
9509 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9510 }
9511
9512 #[test]
9513 fn insert_image_uses_the_selection_as_alt_text() {
9514 let mut d = doc_with("img_sel", "caption here\n");
9515 d.anchor = Some(0);
9516 d.caret = 7; // "caption"
9517 d.insert_image("p.png", "ignored fallback");
9518 assert_eq!(d.source, " here\n");
9519 }
9520
9521 #[test]
9522 fn insert_image_with_no_alt_leaves_empty_brackets() {
9523 let mut d = doc_with("img_noalt", "\n");
9524 d.caret = 0;
9525 d.insert_image("logo.svg", "");
9526 assert_eq!(d.source, "\n");
9527 }
9528
9529 #[test]
9530 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9531 // The round trip is the point: it's no use writing markup the reader
9532 // can't pick up again. This is the pair that only holds from twig 2.5.1
9533 // on — before it, the one-line form went in fine and came back as a
9534 // paragraph of raw tags, publishing no media at all.
9535 let mut d = doc_with("vid_rt", "\n");
9536 d.caret = 0;
9537 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9538 assert_eq!(
9539 d.source,
9540 "<video src=\"clip.mp4\" controls>a clip</video>\n"
9541 );
9542
9543 d.build_visual(80);
9544 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9545 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9546 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9547 assert_eq!(d.vmap.media[0].alt, "a clip");
9548 }
9549
9550 #[test]
9551 fn insert_media_spells_audio_with_its_own_tag() {
9552 let mut d = doc_with("aud_rt", "\n");
9553 d.caret = 0;
9554 d.insert_media(MediaKind::Audio, "take.mp3", "");
9555 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9556 d.build_visual(80);
9557 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9558 }
9559
9560 #[test]
9561 fn insert_media_uses_the_selection_as_fallback_text() {
9562 // The same courtesy `insert_image` does with alt: select a caption,
9563 // insert, and the caption labels the thing rather than being replaced.
9564 let mut d = doc_with("vid_sel", "the talk here\n");
9565 d.anchor = Some(0);
9566 d.caret = 8; // "the talk"
9567 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9568 assert_eq!(
9569 d.source,
9570 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9571 );
9572 }
9573
9574 #[test]
9575 fn insert_media_with_an_image_kind_is_just_insert_image() {
9576 let mut d = doc_with("img_via_media", "\n");
9577 d.caret = 0;
9578 d.insert_media(MediaKind::Image, "logo.svg", "x");
9579 assert_eq!(d.source, "\n");
9580 }
9581
9582 // ── thematic breaks ─────────────────────────────────────────────────────
9583
9584 /// The node the source parses as at `caret` — what confirms an inserted
9585 /// `---` actually reads back as a rule, not stray text or a setext heading.
9586 ///
9587 /// The *narrowest* node covering the offset. Every ancestor covers it too,
9588 /// and since twig 2.8 that includes the `doc` root, which now carries a real
9589 /// span (it reported none before, so taking the first match used to land on
9590 /// the block by luck and now always answers `"doc"`).
9591 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9592 d.nodes()
9593 .into_iter()
9594 .filter(|n| n.span.start <= caret && caret < n.span.end)
9595 .min_by_key(|n| n.span.end - n.span.start)
9596 .map(|n| n.kind)
9597 }
9598
9599 #[test]
9600 fn a_task_box_toggles_at_the_caret_and_reads_back() {
9601 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9602 d.caret = 8; // inside "todo"
9603 assert_eq!(d.task_checked_at_caret(), Some(false));
9604 d.toggle_task_checked();
9605 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9606 assert_eq!(d.task_checked_at_caret(), Some(true));
9607 d.toggle_task_checked();
9608 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9609 }
9610
9611 #[test]
9612 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9613 // The whole reason `toggle_task_at` exists apart from the caret form:
9614 // ticking a box elsewhere must not move the cursor out of what's being
9615 // typed.
9616 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
9617 d.caret = 8; // inside "first"
9618 let second = d.source.find("second").unwrap();
9619 d.toggle_task_at(second);
9620 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
9621 assert_eq!(d.caret, 8, "the caret stayed in the first item");
9622 }
9623
9624 #[test]
9625 fn a_plain_item_gains_and_loses_a_box() {
9626 let mut d = doc_with("task_mint", "- plain\n");
9627 d.caret = 4;
9628 assert_eq!(d.task_checked_at_caret(), None);
9629 d.toggle_task_item();
9630 assert_eq!(d.source, "- [ ] plain\n");
9631 assert_eq!(
9632 d.task_checked_at_caret(),
9633 Some(false),
9634 "a new box arrives unticked"
9635 );
9636 d.toggle_task_item();
9637 assert_eq!(d.source, "- plain\n");
9638 }
9639
9640 #[test]
9641 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
9642 // `set checked` must not silently convert a bullet into a task — that is
9643 // `toggle_task_item`'s job, and twig refuses it here.
9644 let mut d = doc_with("task_none", "- plain\n");
9645 d.caret = 4;
9646 d.toggle_task_checked();
9647 assert_eq!(d.source, "- plain\n", "nothing written");
9648 assert!(
9649 d.status.is_some(),
9650 "the refusal should reach the status line"
9651 );
9652 }
9653
9654 #[test]
9655 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
9656 let mut d = doc_with("task_quote", "> - [ ] nested\n");
9657 d.caret = d.source.find("nested").unwrap();
9658 assert_eq!(d.task_checked_at_caret(), Some(false));
9659 d.toggle_task_checked();
9660 assert_eq!(d.source, "> - [x] nested\n");
9661 }
9662
9663 #[test]
9664 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
9665 // A rule is a block, so twig's `insert_thematic_break` alone lands it
9666 // after the whole paragraph. `split_block` parts the paragraph first and
9667 // the rule is aimed at the *first* half, which is what a rule button is
9668 // understood to do — and what leaf spelled by hand until twig grew both
9669 // halves of the gesture.
9670 let mut d = doc_with("hr_mid", "before after\n");
9671 d.caret = 7; // between "before " and "after"
9672 d.insert_thematic_break();
9673 assert_eq!(d.source, "before \n\n---\n\nafter\n");
9674 assert_eq!(d.selection(), None);
9675 assert_eq!(
9676 kind_at(&mut d, "before \n\n".len()),
9677 Some(Kind::ThematicBreak)
9678 );
9679 }
9680
9681 #[test]
9682 fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
9683 // At the end there is nothing to part, and a split there writes the
9684 // separator anyway — a blank line and the empty slot the next paragraph
9685 // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
9686 // two blank lines nothing fills. Now the rule lands after the paragraph,
9687 // where the split-and-aim was sending it regardless. Both formats, and
9688 // both shapes of a last line — terminated, and still being typed —
9689 // because the two reach the split through different doors: Markdown's
9690 // paragraph span stops before its newline, so `para\n` at 4 never split
9691 // there, but `para` at 4 did.
9692 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9693 for src in ["para\n", "para"] {
9694 let mut d = Doc::from_source(src.into(), fmt).unwrap();
9695 d.caret = 4;
9696 d.insert_thematic_break();
9697 assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
9698 assert_eq!(d.caret, d.source.len());
9699 }
9700 // Mid-document the slot sat between the rule and the next block.
9701 let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
9702 d.caret = 4;
9703 d.insert_thematic_break();
9704 assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
9705 // Trailing whitespace is nothing to part either.
9706 let mut d = Doc::from_source("para \n".into(), fmt).unwrap();
9707 d.caret = 4;
9708 d.insert_thematic_break();
9709 assert_eq!(d.source, format!("para \n\n{rule}\n"), "{fmt:?}");
9710 }
9711 }
9712
9713 #[test]
9714 fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
9715 // The split at the start parts nothing, but it is kept on purpose:
9716 // `|para` becomes `\npara` with the caret on a blank line, and twig
9717 // (3.5.2) writes a rule aimed at a blank line ON that line — the only
9718 // way "before the paragraph" is reachable through a gesture that only
9719 // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
9720 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9721 let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
9722 d.caret = 0;
9723 d.insert_thematic_break();
9724 assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
9725 let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
9726 d.caret = 6;
9727 d.insert_thematic_break();
9728 assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
9729 }
9730 }
9731
9732 #[test]
9733 fn insert_thematic_break_on_a_blank_line_takes_that_line() {
9734 // The gap between two blocks is where a click lands the caret; the
9735 // rule goes on the blank, one blank each side.
9736 let mut d = doc_with("hr_gap", "a\n\nb\n");
9737 d.caret = 2;
9738 d.insert_thematic_break();
9739 assert_eq!(d.source, "a\n\n---\n\nb\n");
9740 }
9741
9742 #[test]
9743 fn insert_table_at_a_paragraph_s_end_splits_nothing() {
9744 // The same door as the rule's, through the placement they share.
9745 let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9746 d.caret = 4;
9747 d.insert_table(1, 1);
9748 assert_eq!(d.source, "para\n\n| |\n|---|\n| |\n");
9749 let mut d = doc_with("table_end_typed", "para");
9750 d.caret = 4;
9751 d.insert_table(1, 1);
9752 assert_eq!(d.source, "para\n\n| |\n| --- |\n| |\n");
9753 assert!(d.caret_in_table());
9754 }
9755
9756 #[test]
9757 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
9758 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
9759 // and leaf wrote the first into both until twig started spelling it.
9760 let mut md = doc_with("hr_md", "para\n");
9761 md.caret = 2;
9762 md.insert_thematic_break();
9763 assert_eq!(md.source, "pa\n\n---\n\nra\n");
9764
9765 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9766 dj.caret = 2;
9767 dj.insert_thematic_break();
9768 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
9769 }
9770
9771 #[test]
9772 fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
9773 // The table goes *at* the caret the way the rule does: the paragraph is
9774 // parted first, and twig writes the grid after its first half. The
9775 // caret then sits in the first header cell — selected, as Tab would
9776 // leave it — so the next keystroke is the heading.
9777 let mut d = doc_with("table_mid", "before after\n");
9778 d.caret = 7;
9779 d.insert_table(2, 3);
9780 assert_eq!(
9781 d.source,
9782 "before \n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nafter\n"
9783 );
9784 assert!(d.caret_in_table());
9785 let first_bar = d.source.find('|').unwrap();
9786 assert!(
9787 d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
9788 "caret {} is not in the header row",
9789 d.caret
9790 );
9791 d.insert("Name");
9792 assert!(d.source.starts_with("before \n\n| Name | | |\n"));
9793 // And the grid the table was written into is one the table keys walk
9794 // (over the map a frontend rebuilds after every edit).
9795 d.build_visual(80);
9796 assert!(d.cell_tab(true));
9797 d.insert("Qty");
9798 assert!(d.source.starts_with("before \n\n| Name | Qty | |\n"));
9799 }
9800
9801 #[test]
9802 fn insert_table_spells_the_grid_the_format_s_own_way() {
9803 // Djot's delimiter row is unpadded, and leaf never has to know that.
9804 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9805 dj.caret = 2;
9806 dj.insert_table(1, 2);
9807 assert_eq!(dj.source, "pa\n\n| | |\n|---|---|\n| | |\n\nra\n");
9808 assert!(dj.caret_in_table());
9809 }
9810
9811 #[test]
9812 fn insert_table_refuses_where_the_format_spells_no_table() {
9813 let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
9814 d.caret = 4;
9815 d.insert_table(1, 1);
9816 assert_eq!(d.source, "<p>ab</p>\n");
9817 assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
9818 assert!(!d.capabilities().table);
9819 }
9820
9821 #[test]
9822 fn insert_table_reports_a_zero_shape_and_writes_nothing() {
9823 let mut d = doc_with("table_zero", "para\n");
9824 d.caret = 2;
9825 d.insert_table(0, 2);
9826 assert_eq!(d.source, "para\n");
9827 assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
9828 }
9829
9830 #[test]
9831 fn clicking_below_a_final_thematic_break_can_type_after_it() {
9832 let mut d = wysiwyg_doc("hr_final_click", "---\n");
9833 d.build_visual(80);
9834 d.click(d.vmap.num_rows() + 2, 0, false);
9835 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
9836 d.insert("after");
9837 assert_eq!(d.source, "---\nafter");
9838 }
9839
9840 #[test]
9841 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
9842 // The same bytes are two documents. In Markdown ` - b` is a nested item
9843 // and the next one belongs beside it, at its indent. In Djot a list
9844 // marker can't interrupt a paragraph, so those bytes are literal text in
9845 // item `a` and there is only one item — writing ` - ` under it would add
9846 // no item at all, just more text, and the new sibling has to go to
9847 // column zero. Both spellings come out of the *enclosing item's* line.
9848 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
9849 md.caret = "- a\n - b".len();
9850 md.newline();
9851 assert_eq!(md.source, "- a\n - b\n - \n");
9852 assert_eq!(list_items(&mut md), 3);
9853
9854 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
9855 dj.view = View::Wysiwyg;
9856 dj.build_visual(80);
9857 dj.caret = "- a\n - b".len();
9858 dj.newline();
9859 assert_eq!(dj.source, "- a\n - b\n- \n");
9860 assert_eq!(list_items(&mut dj), 2);
9861
9862 // Where Djot's nesting is real — opened by a blank line — the indent is
9863 // reproduced there too, and the two formats agree again.
9864 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
9865 dj.view = View::Wysiwyg;
9866 dj.build_visual(80);
9867 dj.caret = "- a\n\n - b".len();
9868 dj.newline();
9869 assert_eq!(dj.source, "- a\n\n - b\n - \n");
9870 assert_eq!(list_items(&mut dj), 3);
9871 }
9872
9873 #[test]
9874 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
9875 // Tab replaces the line's whole prefix with the one twig spells, so the
9876 // quote markers, the parent's indent and an ordered marker's extra
9877 // column are all its answer rather than leaf's arithmetic.
9878 for (name, body, caret, want) in [
9879 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
9880 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
9881 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
9882 // A checkbox is markup the item's own text wraps past, but a nested
9883 // list may only open at the *list* marker's column — four in from
9884 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
9885 // parses as one item, not two.
9886 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
9887 (
9888 "quoted task",
9889 "> - [ ] a\n> - [ ] b\n",
9890 18,
9891 "> - [ ] a\n> - [ ] b\n",
9892 ),
9893 ] {
9894 let mut doc = wysiwyg_doc(name, body);
9895 doc.caret = caret;
9896 doc.indent();
9897 assert_eq!(doc.source, want, "{name}");
9898 // The nesting is real, not just indented text.
9899 assert_eq!(list_items(&mut doc), 2, "{name}");
9900 }
9901 }
9902
9903 #[test]
9904 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
9905 // The same bytes, the two formats disagreeing, and a gesture that used
9906 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
9907 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
9908 // so those bytes are literal text inside item `a` — there is nothing to
9909 // outdent, and treating them as a marker turned one item into two, a
9910 // structural edit from a keystroke that should delete one character.
9911 //
9912 // twig's `line_prefix` is what tells them apart: it reports the marker
9913 // on the Markdown line and nothing on the Djot one, which is a
9914 // continuation. No byte scan can reach that answer.
9915 let src = "- a\n - b\n";
9916 let at = "- a\n - ".len();
9917
9918 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
9919 md.view = View::Wysiwyg;
9920 md.build_visual(80);
9921 md.caret = at;
9922 md.backspace();
9923 assert_eq!(md.source, "- a\n- b\n");
9924 assert_eq!(list_items(&mut md), 2);
9925
9926 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
9927 dj.view = View::Wysiwyg;
9928 dj.build_visual(80);
9929 dj.caret = at;
9930 dj.backspace();
9931 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
9932 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
9933 }
9934
9935 #[test]
9936 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
9937 // Leaf used to spell the next item from the marker bytes it scanned, and
9938 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
9939 // and dropped out of the checklist. twig reproduces the whole
9940 // continuation, and a fresh item is always unticked however the one above
9941 // it stands.
9942 for (name, body, want) in [
9943 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
9944 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
9945 ] {
9946 let mut doc = wysiwyg_doc(name, body);
9947 doc.caret = body.trim_end_matches('\n').len();
9948 doc.newline();
9949 assert_eq!(doc.source, want, "{name}");
9950 // Both items are checklist items — the new one is a box, not the
9951 // plain bullet the old marker scan left behind — and it is unticked
9952 // whichever way the one above it faces.
9953 let boxes: Vec<Option<bool>> = doc
9954 .nodes()
9955 .iter()
9956 .filter(|n| n.kind == Kind::TaskListItem)
9957 .map(|n| n.checked)
9958 .collect();
9959 assert_eq!(boxes.len(), 2, "{name}");
9960 assert_eq!(boxes[1], Some(false), "{name}");
9961 }
9962 }
9963
9964 #[test]
9965 fn a_split_takes_the_space_the_caret_was_in_front_of() {
9966 // Splicing a break at the caret strands the space the words were parted
9967 // at on the head of the second block, where it reads as an indent nobody
9968 // typed. twig's split consumes it.
9969 for (name, body, caret, want) in [
9970 ("para", "one two\n", 3, "one\n\ntwo\n"),
9971 ("item", "- one two\n", 5, "- one\n- two\n"),
9972 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
9973 // A heading takes leaf's own path, which has to match.
9974 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
9975 ] {
9976 let mut doc = wysiwyg_doc(name, body);
9977 doc.caret = caret;
9978 doc.newline();
9979 assert_eq!(doc.source, want, "{name}");
9980 }
9981 }
9982
9983 #[test]
9984 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
9985 // The one place leaf keeps its own break: `split_block` repeats the `#`,
9986 // and Enter after a title is how the body under it is asked for.
9987 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
9988 doc.caret = "# Title".len();
9989 doc.newline();
9990 doc.insert("body");
9991 assert_eq!(doc.source, "# Title\n\nbody\n");
9992 assert_eq!(
9993 doc.nodes()
9994 .iter()
9995 .filter(|n| n.kind == Kind::Heading)
9996 .count(),
9997 1
9998 );
9999 }
10000
10001 #[test]
10002 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
10003 // A quoted item's marker doesn't open its line, so a scan that starts at
10004 // column zero finds a `>` where it wanted a bullet, calls the line "not a
10005 // list" and hands Enter to the plain-quote branch — which writes `> ` and
10006 // drops the list. The next item has to carry the whole prefix.
10007 for (name, body, want) in [
10008 ("flat", "> - a\n", "> - a\n> - \n"),
10009 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10010 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10011 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
10012 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
10013 ] {
10014 let mut doc = wysiwyg_doc(name, body);
10015 doc.caret = body.trim_end_matches('\n').len();
10016 doc.newline();
10017 assert_eq!(doc.source, want, "{name}");
10018 // The marker isn't just spelled right, it parses as an item.
10019 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
10020 }
10021 }
10022
10023 #[test]
10024 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
10025 // Double-Enter exits the list. Unquoted that means a blank line, but a
10026 // *bare* blank line would end the quote too and drop the caret out of it,
10027 // so the separator keeps its `>` and the caret's line keeps its `> `.
10028 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
10029 doc.caret = "> - a\n> - ".len();
10030 doc.newline();
10031 assert_eq!(doc.source, "> - a\n>\n> \n");
10032 assert_eq!(list_items(&mut doc), 1);
10033 // What "still in the quote" means for the next keystroke: the caret sits
10034 // behind the prefix, and what's typed there lands inside the quote as a
10035 // paragraph of its own — not as more of item `a`.
10036 doc.insert("x");
10037 assert_eq!(doc.source, "> - a\n>\n> x\n");
10038 assert!(
10039 doc.editor
10040 .ancestors_at(doc.caret - 1)
10041 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
10042 );
10043 }
10044
10045 #[test]
10046 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
10047 // The marker is hidden block markup, so Backspace over it is structural —
10048 // but only the marker is the list's. Splicing from the line start would
10049 // take the `>` with it and silently unquote the line.
10050 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
10051 doc.caret = "> - ".len();
10052 doc.backspace();
10053 assert_eq!(doc.source, "> a\n");
10054 assert_eq!(list_items(&mut doc), 0);
10055
10056 // A nested one outdents instead, moving the bullet within the quote
10057 // rather than moving the quote.
10058 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
10059 doc.caret = "> - a\n> - ".len();
10060 doc.backspace();
10061 assert_eq!(doc.source, "> - a\n> - b\n");
10062 assert_eq!(list_items(&mut doc), 2);
10063 }
10064
10065 #[test]
10066 fn only_a_bare_paragraph_is_parted_around_the_caret() {
10067 // The split is deliberately narrow. Parting a fenced block would leave
10068 // two fences with a rule between them, and parting a list item would
10069 // mint an item nobody asked for on the way to a rule that lands after
10070 // the list either way — so both keep the whole block intact and take the
10071 // rule after it. A caret in a quote is likewise left alone.
10072 for (name, body, caret, want) in [
10073 (
10074 "code",
10075 "```\nfn x() {}\n```\n",
10076 8,
10077 "```\nfn x() {}\n```\n\n---\n",
10078 ),
10079 ("list", "- one two\n", 6, "- one two\n\n---\n"),
10080 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
10081 ] {
10082 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
10083 d.caret = caret;
10084 d.insert_thematic_break();
10085 assert_eq!(d.source, want, "{name}: the block should stay whole");
10086 }
10087 }
10088
10089 #[test]
10090 fn insert_thematic_break_replaces_the_selection() {
10091 // Now that the rule lands *at* the caret again, replacing the selection
10092 // is coherent once more: the text goes, and the rule takes its place.
10093 // The space the deletion left leading the second half is consumed by the
10094 // split rather than opening the new paragraph with it.
10095 let mut d = doc_with("hr_sel", "one two three\n");
10096 d.anchor = Some(4);
10097 d.caret = 7; // "two"
10098 d.insert_thematic_break();
10099 assert_eq!(d.source, "one \n\n---\n\nthree\n");
10100 assert_eq!(d.selection(), None);
10101 }
10102
10103 #[test]
10104 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
10105 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
10106 // because writing `---` into one is code, not a rule — twig now walks out
10107 // to the block that owns the caret's line, so there is nothing to refuse.
10108 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
10109 code.caret = 5; // inside the fenced code
10110 code.insert_thematic_break();
10111 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
10112 assert_eq!(code.status, None, "no refusal to report any more");
10113
10114 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
10115 table.caret = 3; // in the header row
10116 table.insert_thematic_break();
10117 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
10118 }
10119
10120 #[test]
10121 fn insert_thematic_break_in_a_list_item_ends_the_list() {
10122 // The un-indented rule cannot continue the list, so it closes the list
10123 // and lands at the top level rather than nested inside it.
10124 let mut d = doc_with("hr_list", "- one\n- two\n");
10125 d.caret = "- one\n- tw".len(); // mid "two"
10126 d.insert_thematic_break();
10127 d.build_visual(80);
10128 let rule_at = d.source.find("---").unwrap();
10129 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10130 assert!(
10131 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
10132 && n.span.start <= rule_at
10133 && rule_at < n.span.end),
10134 "the rule must not be nested inside the list"
10135 );
10136 }
10137
10138 #[test]
10139 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
10140 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
10141 // which is the document the gesture was actually asked for.
10142 let mut d = doc_with("hr_quote", "> hello\n");
10143 d.caret = 4; // inside the quoted text
10144 d.insert_thematic_break();
10145 assert_eq!(d.source, "> hello\n>\n> ---\n");
10146 d.build_visual(80);
10147 let rule_at = d.source.find("---").unwrap();
10148 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10149 assert!(
10150 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
10151 && n.span.start <= rule_at
10152 && rule_at < n.span.end),
10153 "the rule belongs to the quote it was asked for"
10154 );
10155 }
10156
10157 // ── typing against a block picture ────────────────────────────────────────
10158
10159 /// A rendered-view document with the caret parked on one of the picture's two
10160 /// stops, and the map already built — the state a frontend is in between
10161 /// drawing a frame and the next keystroke.
10162 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
10163 let mut d = doc_in(View::Wysiwyg, name, src);
10164 d.build_visual_unwrapped();
10165 let start = src.find("".len(),
10169 };
10170 d
10171 }
10172
10173 /// The block media the map publishes, after rebuilding it — "is this still a
10174 /// picture, or has it become a line of text with an image in it?"
10175 fn media_count(d: &mut Doc) -> usize {
10176 d.build_visual_unwrapped();
10177 d.vmap.media.len()
10178 }
10179
10180 #[test]
10181 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
10182 // The accident this prevents: tap the blank page under a photo (which
10183 // lands on the picture's trailing stop), type, and `xy` is a
10184 // paragraph with an *inline* image — the photo stops being drawn.
10185 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
10186 d.insert("xy");
10187 assert_eq!(d.source, "hi\n\n\n\nxy\n");
10188 assert_eq!(media_count(&mut d), 1, "still a picture");
10189 }
10190
10191 #[test]
10192 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
10193 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
10194 d.insert("xy");
10195 assert_eq!(d.source, "hi\n\nxy\n\n\n");
10196 assert_eq!(media_count(&mut d), 1);
10197 }
10198
10199 #[test]
10200 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
10201 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
10202 d.insert("x");
10203 assert_eq!(d.source, "x\n\n\n");
10204 assert_eq!(media_count(&mut d), 1);
10205 }
10206
10207 #[test]
10208 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
10209 // The opened paragraph is part of the keystroke, not an edit the writer
10210 // made — so it undoes with the character, not a step later.
10211 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
10212 d.insert("x");
10213 assert_eq!(d.source, "hi\n\n\n\nx\n");
10214 d.undo();
10215 assert_eq!(d.source, "hi\n\n\n");
10216 }
10217
10218 #[test]
10219 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
10220 // ⌘V dissolves the picture exactly as a keystroke does.
10221 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
10222 d.paste("pasted");
10223 assert_eq!(d.source, "hi\n\n\n\npasted\n");
10224 assert_eq!(media_count(&mut d), 1);
10225 }
10226
10227 #[test]
10228 fn typing_beside_an_inline_image_is_ordinary_editing() {
10229 // An inline image has no placeholder row and no stops of its own. Opening
10230 // a paragraph mid-sentence would be the bug, not the fix.
10231 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
10232 d.build_visual_unwrapped();
10233 d.caret = "see ".len();
10234 d.insert("!");
10235 assert_eq!(d.source, "see ! here\n");
10236 }
10237
10238 #[test]
10239 fn source_view_types_raw_markup_against_an_image_untouched() {
10240 // Source view is for writing the markup itself; a break inserted behind
10241 // the writer's back there would be the editor arguing with them.
10242 let mut d = doc_in(View::Source, "pic_src", "\n");
10243 d.caret = "".len();
10244 d.insert("x");
10245 assert_eq!(d.source, "x\n");
10246 }
10247
10248 #[test]
10249 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
10250 // A selection is replaced, not joined into, so there is nothing to
10251 // protect: the range takes the picture with it.
10252 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
10253 d.anchor = Some(d.caret);
10254 d.caret = d.source.find("".len();
10255 d.insert("x");
10256 assert_eq!(d.source, "hi\n\nx\n");
10257 }
10258
10259 #[test]
10260 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
10261 // What this actually cost: a real vault's photo, to one stray Backspace.
10262 // The caret past `` was deleting the closing paren — invisible
10263 // in the rendered view — and the photo became the text `\n", MediaStop::After);
10265 d.backspace();
10266 assert_eq!(d.source, "hi\n");
10267 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
10268 d.undo();
10269 assert_eq!(
10270 d.source, "hi\n\n\n",
10271 "and comes back in one piece"
10272 );
10273 }
10274
10275 #[test]
10276 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
10277 // Deleting the break here would join the picture to the paragraph above,
10278 // where it is an *inline* image and stops being drawn. Step over the
10279 // boundary; the next press deletes in the paragraph the caret reached.
10280 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
10281 d.backspace();
10282 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
10283 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
10284 d.backspace();
10285 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
10286 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
10287 }
10288
10289 #[test]
10290 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
10291 // The mirror. A byte-step here eats the `!` and leaves a link.
10292 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
10293 d.delete_forward();
10294 assert_eq!(d.source, "hi\n\nbye\n");
10295 assert_eq!(media_count(&mut d), 0);
10296 }
10297
10298 #[test]
10299 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
10300 let mut d = doc_at_picture(
10301 "pic_del_after",
10302 "hi\n\n\n\nbye\n",
10303 MediaStop::After,
10304 );
10305 d.delete_forward();
10306 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
10307 assert_eq!(
10308 d.caret,
10309 d.source.find("bye").unwrap(),
10310 "the caret stepped down to `bye`"
10311 );
10312 }
10313
10314 #[test]
10315 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
10316 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
10317 d.backspace();
10318 assert_eq!(d.source, "\n");
10319 assert_eq!(media_count(&mut d), 0);
10320 }
10321
10322 #[test]
10323 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
10324 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
10325 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
10326 d.delete_word_back();
10327 assert_eq!(d.source, "hi there\n");
10328
10329 // And in front of one it runs *through* the paragraph break into the
10330 // prose above, which merges the picture inline — so it steps out first,
10331 // and the second press deletes the word it was aimed at.
10332 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
10333 d.delete_word_back();
10334 assert_eq!(d.source, "hi there\n\n\n");
10335 d.delete_word_back();
10336 assert_eq!(
10337 d.source, "hi \n\n\n",
10338 "the word above went, the picture stayed"
10339 );
10340 assert_eq!(media_count(&mut d), 1);
10341 }
10342
10343 #[test]
10344 fn source_view_deletes_raw_markup_against_an_image_untouched() {
10345 let mut d = doc_in(View::Source, "pic_src_del", "\n");
10346 d.caret = "".len();
10347 d.backspace();
10348 assert_eq!(d.source, ";
10349 }
10350
10351 #[test]
10352 fn image_destination_at_caret_reads_the_image_under_the_caret() {
10353 let mut d = doc_with("img_read", "\n");
10354 d.caret = 3; // inside the image markup
10355 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
10356 // Past the image, the caret is in no image.
10357 d.caret = "".len();
10358 assert_eq!(d.image_destination_at_caret(), None);
10359 }
10360
10361 #[test]
10362 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
10363 // The image is one placeholder row by default, and `set_media_rows` grows
10364 // it to the height the frontend measured: the label row plus blank
10365 // `decoration` fillers that hold the vertical space a raster is drawn into.
10366 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
10367 assert_eq!(d.vmap.media.len(), 1);
10368 let img_row = d.vmap.media[0].rows_span.start;
10369 assert_eq!(
10370 d.vmap.media[0].rows_span,
10371 img_row..img_row + 1,
10372 "default is one row"
10373 );
10374
10375 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
10376 d.build_visual(80);
10377 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
10378 let span = d.vmap.media[0].rows_span.clone();
10379 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
10380 // The label row carries the mark and its glyphs; the three below are blank
10381 // decoration — drawn, but no caret and no text.
10382 assert!(
10383 d.vmap.rows[span.start].media.is_some(),
10384 "mark rides the first row"
10385 );
10386 for r in (span.start + 1)..span.end {
10387 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
10388 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
10389 assert!(
10390 d.vmap.rows[r].media.is_none(),
10391 "only the first row is marked"
10392 );
10393 }
10394 }
10395
10396 #[test]
10397 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
10398 // The extra rows are pure spacers: the caret's only homes stay the stop in
10399 // front of the image and the one just past it, so walking the document top
10400 // to bottom visits the same offsets whether the image is 1 row or 5.
10401 let body = "ab\n\n\n\ncd\n";
10402 let stops_at = |rows: usize| -> Vec<usize> {
10403 let mut d = wysiwyg_doc("img_stops", body);
10404 if rows > 1 {
10405 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
10406 d.build_visual(80);
10407 }
10408 d.caret = 0;
10409 let mut seen = vec![d.caret];
10410 loop {
10411 d.move_right(false);
10412 if *seen.last().unwrap() == d.caret {
10413 break;
10414 }
10415 seen.push(d.caret);
10416 }
10417 seen
10418 };
10419 assert_eq!(
10420 stops_at(1),
10421 stops_at(5),
10422 "reserving rows must not add stops"
10423 );
10424 }
10425
10426 #[test]
10427 fn insert_link_repoints_the_link_at_a_bare_caret() {
10428 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
10429 d.caret = 3; // in the link's text, nothing selected
10430 d.insert_link("http://y.dev");
10431 assert_eq!(d.source, "[word](http://y.dev)\n");
10432 assert_eq!(d.selected_text(), Some("word"));
10433 }
10434
10435 #[test]
10436 fn insert_link_on_an_empty_range_autolinks_a_url() {
10437 // A link with no text of its own is an autolink, and twig spells it —
10438 // `<…>` is the canonical form and needs no text typed into it, so the
10439 // caret lands after it rather than selecting a finished link.
10440 let mut d = doc_with("link_empty", "\n");
10441 d.caret = 0;
10442 d.insert_link("http://x.dev");
10443 assert_eq!(d.source, "<http://x.dev>\n");
10444 assert_eq!(d.selection(), None);
10445 assert_eq!(d.caret, 14);
10446 }
10447
10448 #[test]
10449 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
10450 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
10451 // in Markdown, so a destination that can't autolink doubles as the text
10452 // instead — which is then selected, ready to be typed over.
10453 let mut d = doc_with("link_rel", "\n");
10454 d.caret = 0;
10455 d.insert_link("./notes.md");
10456 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
10457 assert_eq!(d.selection(), Some((1, 11)));
10458 d.insert("Notes");
10459 assert_eq!(d.source, "[Notes](./notes.md)\n");
10460 }
10461
10462 #[test]
10463 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
10464 // The autolink's text is its URL, so re-pointing replaces the whole
10465 // node — the caret must not splice a second link inside the first.
10466 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
10467 d.caret = 10;
10468 d.insert_link("https://y.dev");
10469 assert_eq!(d.source, "see <https://y.dev> ok\n");
10470 }
10471
10472 #[test]
10473 fn code_language_reads_and_edits_through_the_fence() {
10474 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
10475 d.caret = 10; // inside the code body
10476 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10477 assert!(d.caret_in_fenced_code());
10478
10479 d.set_code_language("python");
10480 assert!(
10481 d.source.starts_with("```python\n"),
10482 "source: {:?}",
10483 d.source
10484 );
10485 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10486
10487 // Clearing it leaves a bare fence and no label.
10488 d.set_code_language("");
10489 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10490 assert_eq!(d.code_language_at_caret(), None);
10491
10492 // A caret outside any code block edits nothing.
10493 let mut p = doc_with("code_lang_none", "just prose\n");
10494 assert!(!p.caret_in_fenced_code());
10495 p.set_code_language("rust");
10496 assert_eq!(p.source, "just prose\n");
10497 }
10498
10499 #[test]
10500 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10501 // Markdown's info string ends at whitespace, so `two words` would write
10502 // a fence that reads back with a different language than the one asked
10503 // for. twig refuses it; leaf reports that and leaves the source alone.
10504 // The old splice trimmed the ends and wrote whatever was left.
10505 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10506 d.caret = 10;
10507 d.set_code_language("two words");
10508 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10509 assert!(d.status.is_some(), "the refusal should be reported");
10510 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10511 }
10512
10513 #[test]
10514 fn link_destination_at_caret_reads_both_spellings() {
10515 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10516 d.caret = 5;
10517 assert_eq!(
10518 d.link_destination_at_caret().as_deref(),
10519 Some("https://x.dev")
10520 );
10521 d.caret = 0;
10522 assert_eq!(d.link_destination_at_caret(), None);
10523
10524 // An autolink has no `destination`; its text is the URL.
10525 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10526 a.caret = 10;
10527 assert_eq!(
10528 a.link_destination_at_caret().as_deref(),
10529 Some("https://x.dev")
10530 );
10531 a.caret = 21;
10532 assert_eq!(a.link_destination_at_caret(), None);
10533 }
10534
10535 #[test]
10536 fn locate_finds_the_block_a_declared_id_names() {
10537 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10538 // verse. The locator has to land on the *verse*, which is the whole
10539 // reason a link carries one.
10540 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10541 {#v2}\nYea, I make a record in the language of my father.\n";
10542 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10543 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10544 assert_eq!(
10545 d.source[v2.start..v2.end].trim_end(),
10546 "Yea, I make a record in the language of my father."
10547 );
10548 // The attribute line is not part of it: `start` is a place to put a
10549 // caret, and `{#v2}` is markup the caret has no business landing in.
10550 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10551 assert_eq!(d.locate("v99"), None);
10552 }
10553
10554 #[test]
10555 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10556 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10557 // Markdown heading is literal text. So `#the-second-part` can only be
10558 // the heading's own words, which is the rule every Markdown renderer
10559 // already follows and therefore the one a link was authored against.
10560 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10561 let mut d = doc_with("locate_md", src);
10562 let hit = d.locate("the-second-part").expect("the heading's slug");
10563 assert!(d.source[hit.start..].starts_with("## The Second Part"));
10564 // Bounded by the next heading that isn't under it, so a peek shows the
10565 // section rather than only its title.
10566 assert_eq!(
10567 &d.source[hit.start..hit.end],
10568 "## The Second Part\n\nbody\n\n"
10569 );
10570
10571 // A subsection does not end its parent: `# Title` runs to `## Third`'s
10572 // sibling only because there is no other `#`, so it covers the lot.
10573 let title = d.locate("title").expect("the top heading");
10574 assert_eq!(title.end, d.source.len());
10575 }
10576
10577 #[test]
10578 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10579 // djot mints `Some-Heading-Here`; a link to it is written
10580 // `#some-heading-here` by nearly everything that writes links. Both
10581 // spellings are one question.
10582 let src = "## Some Heading Here\n\nbody\n";
10583 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10584 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10585 let slugged = d.locate("some-heading-here").expect("the link's spelling");
10586 assert_eq!(exact, slugged);
10587 // The section, not the heading line — there is more to show than a title.
10588 assert_eq!(&d.source[exact.start..exact.end], src);
10589 }
10590
10591 #[test]
10592 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10593 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10594 assert_eq!(d.locate(""), None);
10595 assert_eq!(d.locate(" "), None);
10596 // All punctuation: it names nothing, and must not be read as "match the
10597 // first heading whose slug is also empty".
10598 assert_eq!(d.locate("!!!"), None);
10599 }
10600
10601 #[test]
10602 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10603 // The document's mistake, and the answer every other anchor
10604 // implementation gives — the alternative is for a link to mean whichever
10605 // of the two a walk happened to reach first.
10606 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10607 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10608 let hit = d.locate("dup").expect("the first `{#dup}`");
10609 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10610 }
10611
10612 #[test]
10613 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10614 // The button's whole job: a reference where the caret was, a definition
10615 // to give it meaning, and the caret waiting in the empty note so the
10616 // next keystroke is the note's first word.
10617 let mut d = doc_with("fn_insert", "A claim and more.\n");
10618 d.caret = 7; // just past "A claim"
10619 d.insert_footnote();
10620 assert!(
10621 d.source.starts_with("A claim[^1] and more."),
10622 "{:?}",
10623 d.source
10624 );
10625 assert!(
10626 d.source.contains("[^1]:"),
10627 "the definition too: {:?}",
10628 d.source
10629 );
10630 assert_eq!(d.status, None);
10631
10632 let reference = d.source.find("[^1]").unwrap();
10633 let note = d
10634 .footnote_at(reference + 2)
10635 .expect("the reference just written");
10636 assert_eq!(note.label, "1");
10637 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
10638 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
10639 // …and typing there is typing into the note, not near it.
10640 d.insert("the note");
10641 assert_eq!(
10642 d.footnote_at(reference + 2).and_then(|f| f.text),
10643 Some("the note".to_string())
10644 );
10645 }
10646
10647 #[test]
10648 fn insert_footnote_numbers_past_the_notes_already_written() {
10649 // A second press must not hand back a label somebody else is using: twig
10650 // reuses a defined label rather than appending a rival definition, so a
10651 // repeat of `1` would quietly point the new reference at the old note.
10652 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
10653 d.caret = 7; // past `[^1]`, before " two."
10654 d.insert_footnote();
10655 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
10656 assert_eq!(d.source.matches("[^2]:").count(), 1);
10657 }
10658
10659 #[test]
10660 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
10661 // `[^2]` with no definition is still a 2 that means something to whoever
10662 // wrote it — stepping over it would mint a note for their reference. A
10663 // word label takes no number, so it blocks none.
10664 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
10665 d.caret = d.source.find(" c").unwrap();
10666 d.insert_footnote();
10667 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
10668 assert!(
10669 d.source.starts_with("a[^2] b[^why][^1] c"),
10670 "{:?}",
10671 d.source
10672 );
10673 }
10674
10675 #[test]
10676 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
10677 // A reference annotates the words before it. Consuming the selection —
10678 // which is what an insert normally does — would delete the very claim
10679 // the author selected in order to footnote.
10680 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
10681 d.anchor = Some(2);
10682 d.caret = 7; // "claim" selected
10683 d.insert_footnote();
10684 assert!(
10685 d.source.starts_with("A claim[^1] and more."),
10686 "{:?}",
10687 d.source
10688 );
10689 }
10690
10691 #[test]
10692 fn a_note_just_written_still_knows_where_its_reference_is() {
10693 // The authoring loop in one test: press the button, type the note, ask to
10694 // go back. The caret ends at the note's last byte — which is the *end* of
10695 // the definition's span, the one offset the query used to exclude — so
10696 // this is where the round trip either works or doesn't.
10697 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
10698 d.caret = 7;
10699 d.insert_footnote();
10700 d.insert("the note");
10701 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
10702 let back = d
10703 .footnote_definition_at_caret()
10704 .expect("still in the note we just typed");
10705 assert_eq!(back.label, "1");
10706 // …and following it lands on the reference's label, where a reader's
10707 // return leg lands.
10708 assert_eq!(back.offset, Some(9));
10709 assert_eq!(&d.source[9..10], "1");
10710 }
10711
10712 #[test]
10713 fn insert_footnote_takes_one_undo_for_both_halves() {
10714 // twig writes the pair as a single edit; the point of that is here.
10715 let before = "A claim and more.\n";
10716 let mut d = doc_with("fn_insert_undo", before);
10717 d.caret = 7;
10718 d.insert_footnote();
10719 assert_ne!(d.source, before);
10720 d.undo();
10721 assert_eq!(d.source, before, "one undo takes back both halves");
10722 }
10723
10724 #[test]
10725 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
10726 // HTML is authorable — it spells the inline marks — and has no footnote.
10727 // The refusal says so rather than writing brackets that would render as
10728 // brackets.
10729 let src = "<p>A claim.</p>\n";
10730 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
10731 assert!(!Capabilities::of(Format::Html).footnote);
10732 d.caret = 5;
10733 d.insert_footnote();
10734 assert_eq!(d.source, src, "nothing written");
10735 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
10736 }
10737
10738 #[test]
10739 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
10740 // The empty body is the one place this could go wrong: the definition
10741 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
10742 // byte early would draw up in the paragraph above the note it belongs to.
10743 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
10744 d.place_caret(7, false);
10745 d.insert_footnote();
10746 d.build_visual(80); // the frame a frontend draws after the edit
10747 assert_eq!(
10748 d.vmap.snap_to_stop(d.caret),
10749 d.caret,
10750 "the caret sits on a stop"
10751 );
10752 let (row, _) = d.caret_pos();
10753 assert!(
10754 drawn_rows(&d)[row].contains("[1]"),
10755 "the caret is on the note's row, not above it: {:?}",
10756 drawn_rows(&d)
10757 );
10758 }
10759
10760 #[test]
10761 fn footnote_at_caret_resolves_a_reference_to_its_note() {
10762 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
10763 // blank line, as one has to.
10764 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
10765 d.caret = 9;
10766 let f = d
10767 .footnote_at_caret()
10768 .expect("the caret stands in a reference");
10769 assert_eq!(f.label, "1");
10770 assert_eq!(f.text.as_deref(), Some("the note"));
10771 // The offset points at the note's first word, not at the definition's
10772 // `[` — the marker is decoration with no caret stop on it.
10773 assert_eq!(f.offset, Some(29));
10774 assert_eq!(&d.source[29..37], "the note");
10775 // …and `end` closes the range, so a frontend can ask which rendered rows
10776 // the note occupies rather than re-deriving them from the text.
10777 assert_eq!(f.end, Some(37));
10778 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
10779 }
10780
10781 /// Two definitions in a row: each is its own note, and neither reaches into
10782 /// the other.
10783 ///
10784 /// A djot definition's span used to run past the blank line into the first
10785 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
10786 /// offsets named the *next* note's rows too, showing a reader two footnotes
10787 /// when they had asked about one. twig 3.1 ends the span after the block's
10788 /// own last line; the test outlives the workaround leaf carried for it.
10789 #[test]
10790 fn footnote_at_stops_a_note_at_the_definition_after_it() {
10791 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
10792 for format in [Format::Markdown, Format::Djot] {
10793 let mut d = Doc::from_source(src.to_string(), format).unwrap();
10794 d.caret = 7;
10795 let f = d.footnote_at_caret().expect("a reference");
10796 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
10797 assert_eq!(
10798 &src[f.offset.unwrap()..f.end.unwrap()],
10799 "first note.",
10800 "in {format:?}"
10801 );
10802 }
10803 }
10804
10805 /// The other side of that boundary: a blank line *inside* a definition is
10806 /// interior to it, and the note keeps its second paragraph.
10807 ///
10808 /// This is what the old body scan cost. It stopped at the first line not
10809 /// indented under the note — a blank line is not — so a two-paragraph note
10810 /// came back as its first paragraph, and "go to note" framed half of it.
10811 /// Reading the span twig gives is both simpler and right.
10812 #[test]
10813 fn footnote_at_keeps_a_notes_second_paragraph() {
10814 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
10815 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10816 d.caret = 7;
10817 let f = d.footnote_at_caret().expect("a reference");
10818 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
10819 // And it stops there — `After.` is the next block, not more note.
10820 assert_eq!(
10821 &src[f.offset.unwrap()..f.end.unwrap()],
10822 f.text.as_deref().unwrap()
10823 );
10824 assert!(!f.text.as_deref().unwrap().contains("After"));
10825 }
10826
10827 #[test]
10828 fn footnote_at_bounds_a_note_whose_body_is_empty() {
10829 // `[^1]:` with nothing after it. The range is empty rather than
10830 // inverted, and still points inside the definition — which is what keeps
10831 // a frontend's row lookup from walking off into the block above.
10832 let src = "A claim[^1].\n\n[^1]:\n";
10833 let mut d = doc_with("fn_empty_body", src);
10834 d.caret = 9;
10835 let f = d.footnote_at_caret().expect("a reference");
10836 assert_eq!(f.text.as_deref(), Some(""));
10837 assert_eq!(f.offset, f.end, "an empty note is an empty range");
10838 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
10839 }
10840
10841 #[test]
10842 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
10843 let mut d = doc_with(
10844 "fn_at_caret_none",
10845 "A claim[^1] and more.\n\n[^1]: the note\n",
10846 );
10847 d.caret = 2; // in the prose
10848 assert_eq!(d.footnote_at_caret(), None);
10849 }
10850
10851 #[test]
10852 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
10853 // The two are deliberately separate: a reference names a note in this
10854 // document, a link names somewhere to leave for, and answering one with
10855 // the other is what made a reference click do nothing at all.
10856 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
10857 d.caret = 3; // the `1` of `[^1]`
10858 assert!(d.footnote_at_caret().is_some());
10859 assert_eq!(
10860 d.link_destination_at_caret(),
10861 None,
10862 "a reference is not a link"
10863 );
10864
10865 d.caret = 10; // inside the link's label
10866 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
10867 assert_eq!(
10868 d.link_destination_at_caret().as_deref(),
10869 Some("https://x.dev")
10870 );
10871 }
10872
10873 #[test]
10874 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
10875 // A `[^99]` the document never defines is a real state — a note deleted
10876 // out from under its reference — and the label is what lets a frontend
10877 // say so. `None` here would be indistinguishable from "not on a
10878 // reference", which is the wrong thing to tell a reader.
10879 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
10880 d.caret = 9;
10881 let f = d
10882 .footnote_at_caret()
10883 .expect("the reference is still a reference");
10884 assert_eq!(f.label, "99");
10885 assert_eq!(f.text, None);
10886 assert_eq!(f.offset, None);
10887 }
10888
10889 #[test]
10890 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
10891 // Labels are not always numbers, and a note's body runs past its first
10892 // line — the indented continuation belongs to the note, so it comes back
10893 // with it (source bytes, verbatim, as documented).
10894 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
10895 let mut d = doc_with("fn_word_label", src);
10896 d.caret = 6;
10897 let f = d
10898 .footnote_at_caret()
10899 .expect("the caret stands in a reference");
10900 assert_eq!(f.label, "note");
10901 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
10902 }
10903
10904 #[test]
10905 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
10906 // The point of the offset form: a pointer hovering a reference asks what
10907 // note it names, and must not drag the caret along to ask.
10908 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
10909 d.caret = 0;
10910 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
10911 assert_eq!(f.label, "1");
10912 assert_eq!(f.text.as_deref(), Some("the note"));
10913 assert_eq!(d.caret, 0, "asking must not move the caret");
10914 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
10915 }
10916
10917 #[test]
10918 fn footnote_definition_at_caret_points_back_at_the_reference() {
10919 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
10920 // the caret can rest on — is at 9.
10921 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
10922 d.caret = 30; // inside the note's body
10923 let f = d
10924 .footnote_definition_at_caret()
10925 .expect("the caret stands in a definition");
10926 assert_eq!(f.label, "1");
10927 assert_eq!(f.offset, Some(9));
10928 assert_eq!(&d.source[7..11], "[^1]");
10929 }
10930
10931 #[test]
10932 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
10933 // The two legs have to meet: wherever `footnote_at` sends the caret, the
10934 // definition query must answer for — otherwise arriving at a note leaves
10935 // the reader somewhere the way back isn't offered.
10936 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
10937 let mut d = doc_with("fn_def_marker", src);
10938 let landed = d.footnote_at(9).unwrap().offset.unwrap();
10939 assert_eq!(
10940 d.footnote_definition_at(landed).and_then(|f| f.offset),
10941 Some(9),
10942 "the note a reference sends you to offers the way back"
10943 );
10944 }
10945
10946 #[test]
10947 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
10948 // The two queries answer for disjoint places, which is what lets one
10949 // gesture mean "down to the note" in one and "back up" in the other
10950 // without either having to remember which way the reader is going.
10951 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
10952 d.caret = 2; // prose
10953 assert_eq!(d.footnote_definition_at_caret(), None);
10954 d.caret = 9; // the reference
10955 assert_eq!(d.footnote_definition_at_caret(), None);
10956 assert!(
10957 d.footnote_at_caret().is_some(),
10958 "which is the reference's own query"
10959 );
10960 }
10961
10962 #[test]
10963 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
10964 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
10965 // note", which is false and leaves a frontend unable to explain why the
10966 // way back is missing.
10967 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
10968 let mut d = doc_with("fn_def_orphan", src);
10969 d.caret = src.find("orphan").unwrap();
10970 let f = d
10971 .footnote_definition_at_caret()
10972 .expect("an orphan is still a definition");
10973 assert_eq!(f.label, "2");
10974 assert_eq!(f.offset, None);
10975 }
10976
10977 #[test]
10978 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
10979 // One label, cited twice. The first is where the reader most likely came
10980 // from, and the only answer that doesn't depend on how they got here.
10981 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
10982 let mut d = doc_with("fn_def_repeat", src);
10983 d.caret = src.find("the note").unwrap();
10984 let f = d.footnote_definition_at_caret().expect("a definition");
10985 assert_eq!(
10986 f.offset,
10987 Some(5),
10988 "the first `[^a]`'s label, not the second's"
10989 );
10990 assert_eq!(&src[3..7], "[^a]");
10991 }
10992
10993 #[test]
10994 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
10995 // Down and back up, each leg found from the document rather than from a
10996 // memory of the other — so it still works for a reader who scrolled to
10997 // the notes instead of jumping there.
10998 //
10999 // `place_caret` rather than assigning `caret`, because that is what a
11000 // frontend calls: it snaps to a real caret stop, and a jump that lands
11001 // on a byte the caret can't rest on would arrive somewhere the return
11002 // leg no longer answers for. `build_map` first, since snapping is a
11003 // no-op until the map exists — which is exactly how this went unnoticed
11004 // when the offsets pointed at the `[^` markers.
11005 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
11006 d.build_map(None);
11007 d.place_caret(9, false);
11008 let down = d
11009 .footnote_at_caret()
11010 .expect("a reference")
11011 .offset
11012 .expect("a note");
11013 d.place_caret(down, false);
11014 let up = d
11015 .footnote_definition_at_caret()
11016 .expect("a definition")
11017 .offset
11018 .expect("a reference");
11019 d.place_caret(up, false);
11020 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
11021 assert_eq!(
11022 d.footnote_at_caret().expect("back on the reference").label,
11023 "1"
11024 );
11025 }
11026
11027 #[test]
11028 fn insert_link_hands_the_destination_to_twig_raw() {
11029 // Escaping is twig's, and format-specific: Markdown ends a destination
11030 // at the first space and needs the `<…>` form, where djot would read
11031 // those angle brackets as part of the URL.
11032 let mut d = doc_with("link_space", "word\n");
11033 d.anchor = Some(0);
11034 d.caret = 4;
11035 d.insert_link("a b");
11036 assert_eq!(d.source, "[word](<a b>)\n");
11037 }
11038
11039 #[test]
11040 fn insert_link_reports_a_destination_no_format_can_carry() {
11041 let mut d = doc_with("link_bad", "word\n");
11042 d.anchor = Some(0);
11043 d.caret = 4;
11044 d.insert_link("a\nb");
11045 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
11046 assert!(
11047 d.status.is_some(),
11048 "InvalidArgument should reach the status line"
11049 );
11050 assert!(!d.dirty);
11051 }
11052
11053 #[test]
11054 fn insert_link_works_in_wysiwyg_view() {
11055 let mut d = wysiwyg_doc("link_wys", "word here\n");
11056 d.anchor = Some(0);
11057 d.caret = 4;
11058 d.insert_link("http://x.dev");
11059 assert_eq!(d.source, "[word](http://x.dev) here\n");
11060 assert_eq!(d.selected_text(), Some("word"));
11061 // The map the caret has to keep riding is rebuilt each frame; motion
11062 // over the fresh one must still land on a real stop (the debug_assert).
11063 d.build_visual(80);
11064 d.move_right(false);
11065 d.move_left(false);
11066 }
11067
11068 #[test]
11069 fn click_maps_a_row_col_to_a_byte_offset() {
11070 let mut d = doc_with("click", "ab\ncd\n");
11071 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
11072 assert_eq!(d.caret, 4);
11073 }
11074
11075 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
11076 // stop just as the `(row, col)` click path does, so the caret can never come
11077 // to rest in the blank gap between two paragraphs — where it would draw in one
11078 // place and type in another.
11079 #[test]
11080 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
11081 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
11082 // caret stop (stops are 0,1,3,4).
11083 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
11084 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
11085 d.place_caret(2, false);
11086 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11087 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
11088 }
11089
11090 #[test]
11091 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
11092 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
11093 d.place_caret(0, false); // anchor at the start of "A"
11094 d.place_caret(2, true); // drag into the gap
11095 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11096 let (s, e) = d.selection().expect("a selection");
11097 assert!(
11098 d.vmap.is_stop(s) && d.vmap.is_stop(e),
11099 "selection {s}..{e} off a stop"
11100 );
11101 }
11102
11103 #[test]
11104 fn place_caret_on_a_real_stop_is_left_untouched() {
11105 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
11106 d.place_caret(3, false); // the start of "B" — a genuine stop
11107 assert_eq!(d.caret, 3);
11108 }
11109
11110 // An *empty paragraph* (two blank lines, an intentional blank line the user
11111 // opened) is a real caret stop, unlike the gap — a click into it must stay.
11112 #[test]
11113 fn place_caret_rests_in_an_empty_paragraph() {
11114 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
11115 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
11116 assert!(d.vmap.is_stop(empty));
11117 d.place_caret(empty, false);
11118 assert_eq!(d.caret, empty);
11119 }
11120
11121 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
11122 // a drag over the word `bold` ends there, and a caret placed there stays.
11123 #[test]
11124 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
11125 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
11126 let mut d = wysiwyg_doc("place_mark_end", src);
11127 let start = src.find("bold").unwrap();
11128 d.place_caret(start, false);
11129 d.place_caret(start + 4, true);
11130 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
11131 d.toggle(InlineKind::Strong);
11132 assert_eq!(d.source, src.replace("**bold**", "bold"));
11133 }
11134
11135 #[test]
11136 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
11137 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
11138 d.caret = 7; // before the `d`
11139 d.move_right(false);
11140 assert_eq!(d.caret, 8, "onto the end of the bold");
11141 assert!(d.active_inline_marks().contains(InlineKind::Strong));
11142 d.move_right(false);
11143 assert_eq!(d.caret, 10, "past the closing `**`");
11144 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
11145 d.move_left(false);
11146 assert_eq!(d.caret, 8);
11147 d.move_left(false);
11148 assert_eq!(d.caret, 7);
11149 // Typing at the inner home extends the bold.
11150 d.caret = 8;
11151 d.insert("!");
11152 assert_eq!(d.source, "a **bold!** b");
11153 }
11154
11155 #[test]
11156 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
11157 // The splice path shifts the home with the block it is in, and the
11158 // re-rendered block finds its own again.
11159 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
11160 d.build_visual_unwrapped();
11161 d.edit(0, 0, "zz");
11162 d.build_visual_unwrapped();
11163 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
11164 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11165 let at = d.source.find("bold").unwrap();
11166 d.edit(at, at, "very ");
11167 d.build_visual_unwrapped();
11168 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
11169 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11170 }
11171
11172 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
11173 doc_in(View::Wysiwyg, name, body)
11174 }
11175
11176 /// How many list items the source actually parses into — the check that a
11177 /// marker Leaf wrote is a marker the format agrees is one.
11178 fn list_items(doc: &mut Doc) -> usize {
11179 doc.editor
11180 .nodes()
11181 .unwrap()
11182 .iter()
11183 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
11184 .count()
11185 }
11186
11187 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
11188 /// incremental (`build_spliced` / `build_cached`) path must always match.
11189 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
11190 reference_map_revealing(source, None)
11191 }
11192
11193 /// [`reference_map`] with a reveal line — the ground truth for the
11194 /// `MarkupMode::Full` builds, where the map is a function of the caret's
11195 /// line as well as the text.
11196 fn reference_map_revealing(
11197 source: &str,
11198 reveal: Option<Range<usize>>,
11199 ) -> crate::wysiwyg::VisualMap {
11200 // The same parse `Doc` uses. With twig's plain defaults instead, the two
11201 // sides disagree on what the *document* is before the renderer is even
11202 // reached — a bare `:word` is a text directive to one and prose to the
11203 // other — and the mismatch reads as a splice bug that isn't one.
11204 let mut ed =
11205 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
11206 let nodes = ed.nodes().unwrap();
11207 crate::wysiwyg::build(
11208 &nodes,
11209 source,
11210 None,
11211 false,
11212 &std::collections::HashMap::new(),
11213 reveal,
11214 )
11215 }
11216
11217 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
11218 if a.rows.len() != b.rows.len() {
11219 return true;
11220 }
11221 for (ra, rb) in a.rows.iter().zip(&b.rows) {
11222 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
11223 return true;
11224 }
11225 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
11226 if ga.ch != gb.ch || ga.src != gb.src {
11227 return true;
11228 }
11229 }
11230 }
11231 false
11232 }
11233
11234 #[test]
11235 fn incremental_build_matches_a_fresh_build_across_edits() {
11236 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
11237 // fast path, gated on twig's `dirty_range`) or falls back to
11238 // `build_cached`. After each edit the map must be byte-identical to a
11239 // from-scratch build — this is the correctness net under the splice.
11240 let docs = [
11241 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
11242 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
11243 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
11244 // A footnote definition is a root beside `doc`, merged back into the
11245 // top-level list by `wysiwyg::top_blocks`. The random edits below
11246 // make and unmake definitions as they go (a deleted `:` turns one
11247 // back into a paragraph, and vice versa), which is exactly the
11248 // structural churn the splice path has to notice and bail out of.
11249 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
11250 // A comment is a top-level block that draws no rows — a layout entry
11251 // at zero rows either side of blocks that do. The edits below type
11252 // into the blocks around it (a splice past a hidden block), and
11253 // break the comment open into prose and back (a structural change).
11254 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
11255 // Link reference definitions: a hidden block that an edit can turn
11256 // into a paragraph (a deleted `:`) and back, and whose own bytes an
11257 // edit can land in.
11258 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
11259 ];
11260 // A deterministic mix: mostly single characters (which stay inside one
11261 // block → splice), plus edits that reshape structure (a paragraph break,
11262 // a heading marker, a code fence → fallback), so both paths are exercised.
11263 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
11264 for src in docs {
11265 let mut d = wysiwyg_doc("diff", src);
11266 d.build_visual_unwrapped();
11267 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
11268
11269 for step in 0..60usize {
11270 let len = d.source.len();
11271 let raw = (step * 13 + 5) % (len + 1);
11272 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11273 let pre = d.source.clone();
11274 let action;
11275 if step % 3 == 0 && pos < len {
11276 let end = (pos + 1..=len)
11277 .find(|&i| d.source.is_char_boundary(i))
11278 .unwrap();
11279 action = format!("delete [{pos},{end})");
11280 d.edit(pos, end, "");
11281 } else {
11282 let ins = inserts[step % inserts.len()];
11283 action = format!("insert {ins:?} @ {pos}");
11284 d.edit(pos, pos, ins);
11285 }
11286 d.build_visual_unwrapped();
11287 if maps_differ(&d.vmap, &reference_map(&d.source)) {
11288 panic!(
11289 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
11290 d.source
11291 );
11292 }
11293 }
11294 }
11295 }
11296
11297 /// A frontend is handed [`Doc::vmap`] and may present it differently:
11298 /// leaf-ratatui splices blank filler rows under an oversized heading so the
11299 /// raster it paints there has somewhere to stand, and leaves them in the map
11300 /// because the caret and the mouse both read it between frames. The splice
11301 /// path addresses that map by *row index*, against the block layout the last
11302 /// build recorded — so handed a map with rows in it that no block owns, it
11303 /// laid the re-rendered block over one of the fillers and carried the rows
11304 /// the block really occupied into the suffix. One stranded copy of the
11305 /// edited line, and everything below it a row further down, per keystroke.
11306 ///
11307 /// A map that isn't the one the layout describes is a map this path can't
11308 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
11309 #[test]
11310 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
11311 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
11312 d.build_visual_unwrapped();
11313
11314 // Stand in for the heading filler rows: two blank rows past the heading
11315 // that no block accounts for. Cloning a real row keeps every field
11316 // plausible — it is the row *count* the splice can't survive.
11317 let filler = d.vmap.rows[0].clone();
11318 d.vmap.rows.insert(1, filler.clone());
11319 d.vmap.rows.insert(1, filler);
11320
11321 // An edit inside the last block: the single-block case the splice path
11322 // is for, and the one the frontend hits on every keystroke.
11323 let at = d.source.len() - 1;
11324 d.edit(at, at, "!");
11325 d.build_visual_unwrapped();
11326
11327 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
11328 }
11329
11330 /// A glyph's [`FaceId`] has to mean the same thing however its row was
11331 /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
11332 /// cloned at a shifted offset, and a previous map's rows a splice kept
11333 /// untouched — and only the first of those walks a `data-font` at all. An
11334 /// index into a per-build table would have had the same glyph naming two
11335 /// families the moment a second one appeared; the id is the name's own
11336 /// hash, so nothing is remapped and the table is merged rather than rebuilt.
11337 ///
11338 /// Two families, because one cannot tell a wrong id from a right one.
11339 ///
11340 /// [`FaceId`]: crate::style::FaceId
11341 /// [`BlockCache`]: crate::wysiwyg::BlockCache
11342 #[test]
11343 fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
11344 use crate::style::{FaceId, FaceRef};
11345 let garamond = FaceId::of("Garamond");
11346 let futura = FaceId::of("Futura");
11347 let mut d = wysiwyg_doc(
11348 "two_faces",
11349 "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
11350 );
11351 d.build_visual_unwrapped();
11352
11353 // What the map has to keep saying, whichever path built it.
11354 let check = |d: &Doc, ctx: &str| {
11355 let face_of = |ch: char| {
11356 d.vmap
11357 .rows
11358 .iter()
11359 .flat_map(|r| r.glyphs.iter())
11360 .find(|g| g.ch == ch)
11361 .map(|g| g.style.font)
11362 };
11363 assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
11364 assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
11365 assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
11366 assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
11367 assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
11368 };
11369 check(&d, "fresh");
11370
11371 // An edit inside the second block: the single-block case the splice
11372 // path is for. The first block's rows are carried over untouched, so
11373 // its glyphs' ids are the previous build's and the table has to be too.
11374 let at = d.source.find("beta").unwrap();
11375 d.edit(at, at, "z");
11376 d.build_visual_unwrapped();
11377 check(&d, "after an edit in the second block");
11378 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
11379
11380 // And the other way round, so the block that was kept is the one that
11381 // is now re-rendered.
11382 let at = d.source.find("alpha").unwrap();
11383 d.edit(at, at, "z");
11384 d.build_visual_unwrapped();
11385 check(&d, "after an edit in the first block");
11386 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
11387
11388 // A structural edit is one the splice bails out of, so the map is
11389 // reassembled by `build_cached` — where an untouched block is a *cache
11390 // hit* and its rows are cloned without a `data-font` being walked
11391 // again. The names the entry stored are what keeps the table honest
11392 // there.
11393 let at = d.source.find("\n\ny ").unwrap();
11394 d.edit(at, at, "\n\nmiddle");
11395 d.build_visual_unwrapped();
11396 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
11397 // Twice, because that first `build_cached` is what stores the entries:
11398 // this one is the build where the Garamond block is a *hit*, its rows
11399 // cloned with their ids and no attribute walked to explain them.
11400 let at = d.source.len() - 1;
11401 d.edit(at, at, "\n\ntail");
11402 d.build_visual_unwrapped();
11403 check(&d, "after a structural edit, through the block cache");
11404 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
11405
11406 // A family the edit took the last glyph of leaves the glyphs with no
11407 // face and the table with a name nothing asks for — harmless, and the
11408 // price of not walking the rows the splice exists to avoid walking.
11409 let span = d.source.find("<span data-font=\"Futura\">").unwrap();
11410 let end = d.source.rfind("</span>").unwrap() + "</span>".len();
11411 d.edit(span, end, "beta");
11412 d.build_visual_unwrapped();
11413 assert!(!d.source.contains("Futura"), "{:?}", d.source);
11414 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
11415 }
11416
11417 #[test]
11418 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
11419 // The same correctness net as `incremental_build_matches_a_fresh_build_
11420 // across_edits`, under `MarkupMode::Full` — where the map depends on
11421 // the caret's *line* as well as the text, so the two caches have a new
11422 // way to be wrong. Both are exercised: the block cache can hand back
11423 // rows built for a line that is no longer the revealed one, and the
11424 // splice path can reuse a suffix that still has yesterday's line raw.
11425 //
11426 // Caret motion is interleaved with the edits deliberately, because a
11427 // caret that only ever moved with the edit would never cross a line
11428 // without also dirtying it — the case where a stale reveal survives.
11429 let docs = [
11430 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
11431 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
11432 ];
11433 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
11434 for src in docs {
11435 let mut d = wysiwyg_doc("reveal_diff", src);
11436 d.set_markup_mode(MarkupMode::Full);
11437
11438 for step in 0..60usize {
11439 let len = d.source.len();
11440 let raw = (step * 13 + 5) % (len + 1);
11441 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11442 let pre = d.source.clone();
11443 let action;
11444 if step % 3 == 0 && pos < len {
11445 let end = (pos + 1..=len)
11446 .find(|&i| d.source.is_char_boundary(i))
11447 .unwrap();
11448 action = format!("delete [{pos},{end})");
11449 d.edit(pos, end, "");
11450 } else {
11451 let ins = inserts[step % inserts.len()];
11452 action = format!("insert {ins:?} @ {pos}");
11453 d.edit(pos, pos, ins);
11454 }
11455 // Walk the caret somewhere else in the document, independently
11456 // of where the edit landed.
11457 let want = (step * 29 + 11) % (d.source.len() + 1);
11458 d.caret = (want..=d.source.len())
11459 .find(|&i| d.source.is_char_boundary(i))
11460 .unwrap();
11461 d.build_visual_unwrapped();
11462
11463 let want = reference_map_revealing(&d.source, d.reveal_line());
11464 if maps_differ(&d.vmap, &want) {
11465 panic!(
11466 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
11467 d.caret, d.source
11468 );
11469 }
11470 }
11471 }
11472 }
11473
11474 #[test]
11475 fn caret_motion_across_lines_rebuilds_only_under_full() {
11476 // The cache-key change has to earn its keep in both directions: `Full`
11477 // must rebuild when the caret changes line (or the reveal would never
11478 // move), and the hidden modes must *not* (or every arrow key would pay
11479 // for a feature they don't use). The existing `cache_motion` test pins
11480 // the second for the default mode; this pins the pair against a mode
11481 // change alone.
11482 let body = "*one* here\n\n*two* there\n";
11483
11484 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
11485 full.set_markup_mode(MarkupMode::Full);
11486 caret_at(&mut full, "one");
11487 let before = full.revision();
11488 caret_at(&mut full, "two");
11489 assert_eq!(full.revision(), before, "motion is not an edit");
11490 assert!(
11491 drawn_rows(&full).iter().any(|r| r == "*two* there"),
11492 "the map followed the caret: {:?}",
11493 drawn_rows(&full)
11494 );
11495
11496 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
11497 caret_at(&mut hidden, "one");
11498 let key = hidden.vmap_key.clone();
11499 caret_at(&mut hidden, "two");
11500 assert_eq!(
11501 hidden.vmap_key, key,
11502 "a hidden mode rebuilds nothing on motion"
11503 );
11504 }
11505
11506 #[test]
11507 fn wysiwyg_down_crosses_a_paragraph_boundary() {
11508 // Regression: the blank separator row used to share the previous
11509 // paragraph's end offset, so Down got pinned at the boundary (while Up
11510 // still crossed). Both directions must step through it symmetrically.
11511 //
11512 // It's now stepped *over* rather than onto: the blank line between two
11513 // paragraphs is the boundary being drawn, not a line of the document, so
11514 // one press of Down crosses it. The goal column survives the crossing —
11515 // col 3 at the end of "abc" is col 3 at the end of "def".
11516 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
11517 d.caret = 3; // end of "abc" (row 0)
11518 d.move_down(false);
11519 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
11520 assert_eq!(d.caret, 8); // end of "def", col 3 kept
11521 d.move_up(false);
11522 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
11523 assert_eq!(d.caret, 3);
11524 }
11525
11526 #[test]
11527 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
11528 // The second Up and the second Down here run off the ends of the
11529 // document, which is no longer a place a press is swallowed: they carry
11530 // the caret to the start and the end of the text. The claim in the
11531 // middle — that a Down retraces the Up that crossed the paragraph gap —
11532 // is the one this test is for, and it is asserted where it is made.
11533 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
11534 d.caret = 5; // start of "def"
11535 let start = d.caret_pos();
11536 d.move_up(false);
11537 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
11538 d.move_up(false);
11539 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
11540 d.move_down(false);
11541 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
11542 d.move_down(false);
11543 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
11544 }
11545
11546 #[test]
11547 fn wysiwyg_new_paragraph_shows_before_typing() {
11548 // Regression: two Enters at the end of a paragraph produced trailing
11549 // newlines with no AST node, so the caret appeared stuck on the old line
11550 // until a character was typed. It must ride down onto the new line now.
11551 let mut d = doc_with("wys_newpara", "abc\n");
11552 d.view = View::Wysiwyg;
11553 d.caret = 3;
11554 d.insert("\n");
11555 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
11556 assert_eq!(d.source, "abc\n\n\n");
11557 d.build_visual(80);
11558 let (row, _) = d.caret_pos();
11559 assert!(
11560 row >= 2,
11561 "caret should have moved down to the new line, got row {row}"
11562 );
11563 assert!(
11564 d.vmap.num_rows() >= 3,
11565 "the blank lines should render as rows"
11566 );
11567 }
11568
11569 #[test]
11570 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11571 // The reported bug: Enter at the end of a paragraph that has another
11572 // paragraph below put the caret at the *start of the next paragraph* —
11573 // the empty paragraph it opened had no row, so the caret snapped onto
11574 // "World". It must now sit on its own empty line, with a blank spacer
11575 // above it (the paragraph gap).
11576 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11577 d.caret = 5; // end of "Hello"
11578 d.newline();
11579 d.build_visual(80);
11580 let (row, col) = d.caret_pos();
11581 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11582 assert_eq!(
11583 d.vmap.row_width(row),
11584 0,
11585 "caret's row must be empty, not 'World'"
11586 );
11587 assert!(
11588 row >= 2,
11589 "a blank spacer row should sit above the caret, got row {row}"
11590 );
11591 // The row above the caret is a real (empty) gap, and "Hello" stays put.
11592 assert_eq!(
11593 d.vmap.row_width(row - 1),
11594 0,
11595 "the row above the caret is a gap"
11596 );
11597 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11598 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11599 }
11600
11601 #[test]
11602 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11603 // At the document end a single Enter must also show the paragraph gap —
11604 // a blank spacer row above the caret — so the layout already matches how
11605 // it will look once the new paragraph has text.
11606 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11607 d.caret = 5; // end of "Hello", no trailing newline
11608 d.newline(); // source becomes "Hello\n\n"
11609 d.build_visual(80);
11610 let (row, col) = d.caret_pos();
11611 assert_eq!(col, 0);
11612 assert!(
11613 row >= 2,
11614 "caret should sit below a blank spacer, got row {row}"
11615 );
11616 assert_eq!(
11617 d.vmap.row_width(row - 1),
11618 0,
11619 "the row above the caret is a gap"
11620 );
11621 }
11622
11623 #[test]
11624 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
11625 // The spacer is view-only: typing the new paragraph must not reflow the
11626 // caret onto a different row — the transient view already matched the
11627 // settled one.
11628 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
11629 d.caret = 5;
11630 d.newline();
11631 d.build_visual(80);
11632 let before = d.caret_pos();
11633 d.insert("New");
11634 d.build_visual(80);
11635 let after = d.caret_pos();
11636 assert_eq!(
11637 after.0, before.0,
11638 "typing must not move the caret to another row ({before:?} -> {after:?})"
11639 );
11640 }
11641
11642 #[test]
11643 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
11644 // Return at the end of the block's last line writes an empty line the
11645 // map used to drop, so the caret landed on `after` and the next
11646 // keystroke went into the paragraph below instead of into the code.
11647 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
11648 d.caret = d.source.find("beta").unwrap() + "beta".len();
11649 d.build_visual(80);
11650 let before = d.caret_pos().0;
11651
11652 d.newline();
11653 d.build_visual(80);
11654 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
11655
11656 let (row, col) = d.caret_pos();
11657 assert_eq!(row, before + 1, "the caret moves down one row");
11658 assert_eq!(col, 0, "onto the head of the empty line");
11659 let span = d.vmap.code_blocks[0].rows_span.clone();
11660 assert!(
11661 span.contains(&row),
11662 "caret row {row} is outside the block's rows {span:?}"
11663 );
11664
11665 // The whole point: what is typed next is code.
11666 d.insert("gamma");
11667 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
11668 }
11669
11670 #[test]
11671 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
11672 let fm = "---\ntitle: hi\n---\n";
11673 let body = format!("{fm}# leaf\n\nbody\n");
11674 let mut d = wysiwyg_doc("wys_fm", &body);
11675 // Opening lifts the caret out of the now-hidden frontmatter.
11676 assert_eq!(
11677 d.caret,
11678 fm.len(),
11679 "caret should start at the first real block"
11680 );
11681 // Left at the content start can't step back into frontmatter.
11682 d.move_left(false);
11683 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
11684 // Doc-start lands on the content floor, not offset 0.
11685 d.move_doc_start(false);
11686 assert_eq!(d.caret, fm.len());
11687 // Select-all + copy never include the frontmatter bytes.
11688 d.select_all();
11689 let sel = d.selected_text().unwrap().to_string();
11690 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
11691 assert!(
11692 sel.starts_with("# leaf"),
11693 "selection should begin at content: {sel:?}"
11694 );
11695 }
11696
11697 #[test]
11698 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
11699 // A fresh note is frontmatter and nothing else. With no rendered block
11700 // to floor the caret it opened at offset 0 — before the opening `---` —
11701 // so the first keystroke wrote itself in front of the metadata and the
11702 // file came out as `This---\ntitle: …`.
11703 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
11704 let mut d = wysiwyg_doc("wys_fm_only", fm);
11705 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
11706 // Nothing is rendered, so the caret draws at the origin of an empty view
11707 // — the same place an empty document puts it.
11708 assert_eq!(d.caret_pos(), (0, 0));
11709 d.insert("This");
11710 assert_eq!(d.source, format!("{fm}This"));
11711 }
11712
11713 /// `select_range` is the verb for a range a host already knows the bytes of,
11714 /// so it must not snap — and must still hold every invariant `place_caret`
11715 /// holds, the frontmatter floor above all.
11716 #[test]
11717 fn select_range_takes_the_range_as_given_but_still_floors_it() {
11718 let fm = "---\ntitle: foo\n---\n\n";
11719 let body = format!("{fm}body foo here\n");
11720 let mut d = wysiwyg_doc("wys_select_range", &body);
11721
11722 // The `foo` in the body: taken exactly, not snapped to a caret stop.
11723 let at = body.rfind("foo").unwrap();
11724 d.select_range(at, at + 3);
11725 assert_eq!(d.selection(), Some((at, at + 3)));
11726 assert_eq!(d.selected_text(), Some("foo"));
11727
11728 // The `foo` in the hidden frontmatter: below the floor, so both ends
11729 // come up to it rather than parking the caret in the metadata, where a
11730 // later keystroke would rewrite `title:`.
11731 let hidden = body.find("foo").unwrap();
11732 assert!(hidden < d.vmap.content_start);
11733 d.select_range(hidden, hidden + 3);
11734 assert!(
11735 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
11736 "a range under the floor must not leave the caret in the frontmatter"
11737 );
11738
11739 // Past the end, and mid-character, are both brought back to something
11740 // sliceable rather than panicking the next reader of the range.
11741 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
11742 let mut d = multi;
11743 d.select_range(2, 9_999);
11744 assert_eq!(d.caret, d.source.len());
11745 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
11746 assert!(d.source.is_char_boundary(d.caret));
11747 }
11748
11749 /// The bug `select_range` exists for: a match butting up against a hidden
11750 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
11751 /// the one before the `**`.
11752 #[test]
11753 fn select_range_does_not_snap_off_a_hidden_delimiter() {
11754 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
11755 let at = d.source.find("needle").unwrap();
11756 d.select_range(at, at + 6);
11757 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
11758 }
11759
11760 #[test]
11761 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
11762 // Backspace deletes `prev_boundary..caret` directly; at the first real
11763 // block that boundary is inside the hidden frontmatter, so it must be a
11764 // no-op rather than eating the closing `---`.
11765 let fm = "---\ntitle: hi\n---\n";
11766 let body = format!("{fm}leaf\n");
11767 let mut d = wysiwyg_doc("wys_fm_bs", &body);
11768 assert_eq!(d.caret, fm.len());
11769 d.backspace();
11770 assert_eq!(d.source, body, "backspace must not touch frontmatter");
11771 d.delete_word_back();
11772 assert_eq!(
11773 d.source, body,
11774 "word-delete must not touch frontmatter either"
11775 );
11776 }
11777
11778 #[test]
11779 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
11780 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
11781 // fenced div, really, since core parses these on for every document
11782 // now (`parse_extensions`). The container is a `directive` node, an
11783 // `is_block_container` kind like `block_quote`, so the caret works
11784 // inside its child paragraph exactly as it would inside a quote: typing
11785 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
11786 // untouched.
11787 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
11788 let mut d = wysiwyg_doc("wys_vis", body);
11789 d.caret = body.find("hello").unwrap() + "hello".len();
11790 d.insert("!");
11791 assert_eq!(
11792 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
11793 "typing inside the block edits its content in place"
11794 );
11795 assert!(
11796 d.source.contains(":::vis{.public .family}"),
11797 "opening fence survives"
11798 );
11799 assert!(d.source.contains(":::\nafter"), "closing fence survives");
11800 }
11801
11802 #[test]
11803 fn source_view_still_reaches_frontmatter() {
11804 // The metadata is only *hidden*, never lost: the source view edits and
11805 // selects it in full, and it's always preserved on save.
11806 let fm = "---\ntitle: hi\n---\n";
11807 let body = format!("{fm}# leaf\n");
11808 let mut d = doc_with("src_fm", &body);
11809 d.select_all();
11810 let sel = d.selected_text().unwrap();
11811 assert!(
11812 sel.contains("title"),
11813 "source view should select everything"
11814 );
11815 d.move_doc_start(false);
11816 assert_eq!(d.caret, 0, "source view can reach offset 0");
11817 }
11818
11819 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
11820
11821 #[test]
11822 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
11823 // The border and padding between two cells all share one source offset,
11824 // so a column-stepping caret would sit on `│` and then stall there
11825 // forever. Right must step: end of "Name" -> start of "Qty".
11826 let mut d = wysiwyg_doc("tbl_right", TABLE);
11827 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
11828 d.move_right(false);
11829 assert_eq!(
11830 d.caret,
11831 TABLE.find("Qty").unwrap(),
11832 "should land in the next cell"
11833 );
11834 let (r, c) = d.caret_pos();
11835 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
11836 }
11837
11838 #[test]
11839 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
11840 let mut d = wysiwyg_doc("tbl_left", TABLE);
11841 d.caret = TABLE.find("Qty").unwrap();
11842 d.move_left(false);
11843 assert_eq!(
11844 d.caret,
11845 TABLE.find("Name").unwrap() + 4,
11846 "end of the previous cell"
11847 );
11848 }
11849
11850 #[test]
11851 fn wysiwyg_down_steps_over_a_table_rule() {
11852 // Between the header and the first body row sits a `├───┼───┤` rule.
11853 // It's drawn but holds no caret, so one Down must reach "Pear".
11854 let mut d = wysiwyg_doc("tbl_down", TABLE);
11855 d.caret = TABLE.find("Name").unwrap();
11856 d.move_down(false);
11857 assert_eq!(
11858 d.caret,
11859 TABLE.find("Pear").unwrap(),
11860 "one Down reaches the body row"
11861 );
11862 d.move_down(false);
11863 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
11864 }
11865
11866 #[test]
11867 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
11868 let mut d = wysiwyg_doc("tbl_tab", TABLE);
11869 d.caret = TABLE.find("Name").unwrap();
11870 // A hop lands with the destination cell's whole content selected, the
11871 // caret at its end — so typing replaces the cell like a form field.
11872 assert!(d.cell_hop(true));
11873 assert_eq!(
11874 d.selected_text(),
11875 Some("Qty"),
11876 "the target cell comes up selected"
11877 );
11878 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
11879 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
11880 assert_eq!(d.selected_text(), Some("Pear"));
11881 assert!(d.cell_hop(false));
11882 assert_eq!(d.selected_text(), Some("Qty"));
11883 }
11884
11885 #[test]
11886 fn tab_outside_a_table_is_not_a_cell_hop() {
11887 // `cell_hop` reports false so the frontend can indent as usual.
11888 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
11889 d.caret = 4;
11890 assert!(!d.cell_hop(true));
11891 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
11892 }
11893
11894 #[test]
11895 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
11896 let mut d = wysiwyg_doc("tbl_edge", TABLE);
11897 d.caret = TABLE.rfind("12").unwrap(); // the final cell
11898 assert!(!d.cell_hop(true), "no cell after the last one");
11899 d.caret = TABLE.find("Name").unwrap();
11900 assert!(!d.cell_hop(false), "no cell before the first one");
11901 }
11902
11903 #[test]
11904 fn wysiwyg_vertical_cell_motion_holds_the_column() {
11905 // Down/Up step to the cell above/below in the *same column*, not back to
11906 // the top-left the way a naive row/col motion over the picture would.
11907 let mut d = wysiwyg_doc("tbl_vert", TABLE);
11908 d.caret = TABLE.find("Qty").unwrap();
11909 // Each vertical hop selects the destination cell, holding the column.
11910 assert!(d.cell_move_vertical(true));
11911 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
11912 assert!(d.cell_move_vertical(true));
11913 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
11914 assert!(!d.cell_move_vertical(true), "no row below the last");
11915 assert!(d.cell_move_vertical(false));
11916 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
11917 assert!(d.cell_move_vertical(false));
11918 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
11919 assert!(!d.cell_move_vertical(false), "no row above the header");
11920 }
11921
11922 #[test]
11923 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
11924 let mut d = wysiwyg_doc("tbl_grow", TABLE);
11925 d.caret = TABLE.rfind("12").unwrap();
11926 let rows_before = d.source.matches('\n').count();
11927 assert!(d.cell_tab(true), "acts as a table key");
11928 assert_eq!(
11929 d.source.matches('\n').count(),
11930 rows_before + 1,
11931 "a fresh row was appended"
11932 );
11933 assert!(d.caret_in_table(), "the caret entered the new row");
11934 // The caret sits in the new row's first cell — past the old last cell.
11935 assert!(d.caret > TABLE.rfind("12").unwrap());
11936 }
11937
11938 #[test]
11939 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
11940 let mut d = wysiwyg_doc("tbl_ret", TABLE);
11941 d.caret = TABLE.find("Name").unwrap();
11942 assert!(d.cell_return(), "acts as a table key");
11943 assert_eq!(
11944 d.selected_text(),
11945 Some("Pear"),
11946 "Return drops one cell, selecting it"
11947 );
11948 // From the last row, Return appends a row and enters it.
11949 d.caret = TABLE.rfind("Fig").unwrap();
11950 let rows_before = d.source.matches('\n').count();
11951 assert!(d.cell_return());
11952 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
11953 assert!(d.caret_in_table());
11954 }
11955
11956 #[test]
11957 fn return_and_tab_outside_a_table_decline() {
11958 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
11959 d.caret = 4;
11960 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
11961 assert!(!d.cell_tab(true), "no table: the frontend indents");
11962 assert!(
11963 !d.cell_line_break(),
11964 "no table: the frontend breaks the line"
11965 );
11966 }
11967
11968 #[test]
11969 fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
11970 // A document that ends in a table used to end *inside* it: nothing
11971 // past the last cell was a caret stop, so a click in the blank space
11972 // under the grid snapped back into the table and there was no way to
11973 // write a line after it. The bottom border's end is that stop now.
11974 let mut d = wysiwyg_doc("tbl_trail", TABLE);
11975 let rows = d.vmap.num_rows();
11976 d.click(rows + 3, 0, false);
11977 let end = TABLE.trim_end_matches('\n').len();
11978 assert_eq!(d.caret, end, "the caret stands just past the table");
11979 assert!(!d.caret_in_table(), "past the table is outside it");
11980 assert!(!d.cell_return(), "Return there is the frontend's newline");
11981 d.newline();
11982 d.insert("after");
11983 assert_eq!(
11984 d.source,
11985 format!("{TABLE}\nafter\n"),
11986 "Enter opens a paragraph under the table"
11987 );
11988 }
11989
11990 #[test]
11991 fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
11992 // The stop sits at the end of the table's last source line, and a
11993 // line glued under a table is a row of it — `| Fig | 12 |x` would be a
11994 // three-cell row. So the text gets a paragraph of its own, as it does
11995 // beside a block picture.
11996 let mut d = wysiwyg_doc("tbl_type", TABLE);
11997 d.caret = TABLE.trim_end_matches('\n').len();
11998 d.insert("x");
11999 assert_eq!(d.source, format!("{TABLE}\nx\n"));
12000 assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
12001 // And a paste, which joins the block exactly as typing would.
12002 let mut d = wysiwyg_doc("tbl_paste", TABLE);
12003 d.caret = TABLE.trim_end_matches('\n').len();
12004 d.paste("pasted");
12005 assert_eq!(d.source, format!("{TABLE}\npasted\n"));
12006 }
12007
12008 #[test]
12009 fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
12010 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12011 let last_cell_end = TABLE.rfind("12").unwrap() + 2;
12012 let end = TABLE.trim_end_matches('\n').len();
12013 d.caret = last_cell_end;
12014 d.move_right(false);
12015 assert_eq!(d.caret, end, "Right from the last cell leaves the table");
12016 // Backspace there takes no byte: the one behind the caret is the row's
12017 // closing `|`, which the rich view never drew. It steps back instead.
12018 d.backspace();
12019 assert_eq!(d.source, TABLE, "nothing deleted");
12020 assert_eq!(d.caret, last_cell_end, "back into the last cell");
12021 // Down from the last row lands on the same stop, and Up returns.
12022 d.move_down(false);
12023 assert_eq!(d.caret, end, "Down from the last row leaves the table");
12024 d.move_up(false);
12025 assert_eq!(d.caret, last_cell_end);
12026 }
12027
12028 #[test]
12029 fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
12030 // With prose under the table, the stop is one hop between the last
12031 // cell and the paragraph — the shape a block picture's second stop has.
12032 let src = format!("{TABLE}\nafter\n");
12033 let mut d = wysiwyg_doc("tbl_mid", &src);
12034 d.caret = TABLE.rfind("12").unwrap() + 2;
12035 d.move_right(false);
12036 assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
12037 d.move_right(false);
12038 assert_eq!(d.caret, src.find("after").unwrap());
12039 // Typing at the stop still opens a paragraph, and the text below keeps
12040 // its own.
12041 d.move_left(false);
12042 d.insert("x");
12043 assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
12044 }
12045
12046 #[test]
12047 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
12048 let mut d = wysiwyg_doc("tbl_break", TABLE);
12049 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
12050 assert!(d.cell_line_break(), "acts as a table key");
12051 assert!(
12052 d.source.contains("Pear<br>"),
12053 "spelled as an inline <br>: {}",
12054 d.source
12055 );
12056 assert!(d.caret_in_table(), "still in the cell, past the break");
12057 // The break renders as a real line: the "Pear" cell now draws two lines,
12058 // so the table's picture is one row taller than a single-line table.
12059 d.build_visual(80);
12060 let table = &d.vmap.tables[0];
12061 let cell = &table.grid[1].cells[0]; // first body row, first column
12062 assert!(
12063 cell.glyphs.iter().any(|g| g.ch == '\n'),
12064 "the cell carries the break as a newline glyph for the frontend to split"
12065 );
12066 }
12067
12068 #[test]
12069 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
12070 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
12071 // back as structure — the whole point of routing through insert_line_break
12072 // instead of splicing raw `<br>` bytes.
12073 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
12074 d.caret = TABLE.find("Pear").unwrap() + 4;
12075 assert!(d.cell_line_break());
12076 let kinds: Vec<Kind> = d
12077 .editor
12078 .nodes()
12079 .unwrap()
12080 .iter()
12081 .map(|n| n.kind.clone())
12082 .collect();
12083 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
12084 assert!(
12085 !kinds.contains(&Kind::RawInline),
12086 "still raw HTML: {kinds:?}"
12087 );
12088 }
12089
12090 #[test]
12091 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
12092 // The `<br>` draws as one newline glyph, so Backspace over it must take
12093 // all four bytes — a one-byte delete would strand a visible `<br` in the
12094 // cell (the reported bug).
12095 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
12096 d.caret = TABLE.find("Pear").unwrap() + 4;
12097 assert!(d.cell_line_break());
12098 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
12099 d.backspace(); // caret sits just past the break
12100 assert!(
12101 !d.source.contains("<br"),
12102 "no half-deleted <br left: {}",
12103 d.source
12104 );
12105 assert!(
12106 d.source.contains("| Pear |"),
12107 "the cell is back to one line: {}",
12108 d.source
12109 );
12110 }
12111
12112 #[test]
12113 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
12114 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
12115 d.caret = TABLE.find("Pear").unwrap() + 4;
12116 assert!(d.cell_line_break());
12117 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
12118 d.delete_forward();
12119 assert!(
12120 !d.source.contains("<br"),
12121 "no half-deleted <br: {}",
12122 d.source
12123 );
12124 assert!(
12125 d.source.contains("| Pear |"),
12126 "cell back to one line: {}",
12127 d.source
12128 );
12129 }
12130
12131 #[test]
12132 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
12133 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
12134 // still consumed (a real newline would split the one-line row), but the
12135 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
12136 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
12137 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12138 d.caret = src.find("Pear").unwrap() + 4;
12139 assert!(d.caret_in_table(), "caret should be inside the djot table");
12140 assert!(
12141 d.cell_line_break(),
12142 "the key is consumed, not passed to the frontend"
12143 );
12144 assert_eq!(d.source, src, "the djot cell is left untouched");
12145 assert!(
12146 !d.source.contains("<br>"),
12147 "no non-idiomatic <br> spliced into djot"
12148 );
12149 assert!(
12150 d.status.is_some(),
12151 "the refusal is surfaced on the status line"
12152 );
12153 }
12154
12155 #[test]
12156 fn typing_in_a_cell_edits_that_cell() {
12157 // Editing comes free once offsets map correctly: the caret is a source
12158 // offset, so a normal splice lands inside the pipe table.
12159 let mut d = wysiwyg_doc("tbl_type", TABLE);
12160 d.caret = TABLE.find("Pear").unwrap() + 4;
12161 d.insert("s");
12162 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
12163 }
12164
12165 #[test]
12166 fn motion_and_delete_treat_an_emoji_as_one_character() {
12167 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
12168 // bytes, several codepoints. Right-arrow must clear it in one step, and
12169 // backspace must remove the whole cluster, not a stray joiner.
12170 let family = "👨👩👧";
12171 let mut d = doc_with("emoji", &format!("a{family}b\n"));
12172 d.caret = 1; // just after 'a', before the emoji
12173 d.move_right(false);
12174 assert_eq!(
12175 d.caret,
12176 1 + family.len(),
12177 "one step clears the whole cluster"
12178 );
12179 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
12180
12181 d.backspace(); // delete the emoji as a unit
12182 assert_eq!(d.source, "ab\n");
12183 assert_eq!(d.caret, 1);
12184 }
12185
12186 #[test]
12187 fn motion_handles_a_combining_accent_as_one_character() {
12188 // "e" + U+0301 (combining acute) renders as one é.
12189 let mut d = doc_with("combining", "e\u{0301}x\n");
12190 d.caret = 0;
12191 d.move_right(false);
12192 assert_eq!(
12193 d.caret,
12194 "e\u{0301}".len(),
12195 "steps past base + combining mark"
12196 );
12197 }
12198
12199 #[test]
12200 fn undo_then_redo_round_trips_an_edit() {
12201 let mut d = doc_with("undo", "hello\n");
12202 d.caret = 5;
12203 d.insert("!");
12204 assert_eq!(d.source, "hello!\n");
12205 d.undo();
12206 assert_eq!(d.source, "hello\n");
12207 assert_eq!(d.caret, 5, "undo restores the caret");
12208 d.redo();
12209 assert_eq!(d.source, "hello!\n");
12210 }
12211
12212 #[test]
12213 fn a_run_of_typing_undoes_as_one_step() {
12214 let mut d = doc_with("coalesce", "\n");
12215 d.caret = 0;
12216 d.insert("a");
12217 d.insert("b");
12218 d.insert("c");
12219 assert_eq!(d.source, "abc\n");
12220 d.undo(); // the whole typed run, not just "c"
12221 assert_eq!(d.source, "\n");
12222 d.undo(); // nothing left — the run was one step
12223 assert_eq!(d.source, "\n");
12224 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12225 }
12226
12227 // ── IME composition ──────────────────────────────────────────────────────
12228
12229 #[test]
12230 fn a_composition_run_undoes_as_one_step() {
12231 let mut d = doc_with("compose", "\n");
12232 d.caret = 0;
12233 // What an IME does: each step replaces the last one's provisional bytes.
12234 d.edit_composing(0, 0, "k");
12235 d.edit_composing(0, 1, "か");
12236 d.edit_composing(0, 3, "かん");
12237 d.edit_composing(0, 6, "感"); // the commit
12238 d.end_composition();
12239 assert_eq!(d.source, "感\n");
12240 d.undo(); // the whole composition, not its last keystroke
12241 assert_eq!(d.source, "\n");
12242 assert_eq!(d.status.as_deref(), None, "the run was a single step");
12243 }
12244
12245 #[test]
12246 fn two_compositions_are_two_undo_steps() {
12247 let mut d = doc_with("compose_two", "\n");
12248 d.caret = 0;
12249 d.edit_composing(0, 0, "か");
12250 d.edit_composing(0, 3, "蚊");
12251 d.end_composition();
12252 d.edit_composing(3, 3, "き");
12253 d.edit_composing(3, 6, "木");
12254 d.end_composition();
12255 assert_eq!(d.source, "蚊木\n");
12256 d.undo();
12257 assert_eq!(d.source, "蚊\n", "only the second composition");
12258 d.undo();
12259 assert_eq!(d.source, "\n");
12260 }
12261
12262 #[test]
12263 fn a_composition_does_not_fold_into_the_typing_around_it() {
12264 let mut d = doc_with("compose_typing", "\n");
12265 d.caret = 0;
12266 d.insert("a");
12267 d.insert("b");
12268 d.edit_composing(2, 2, "か");
12269 d.edit_composing(2, 5, "蚊");
12270 d.end_composition();
12271 d.insert("c");
12272 assert_eq!(d.source, "ab蚊c\n");
12273 d.undo();
12274 assert_eq!(d.source, "ab蚊\n");
12275 d.undo();
12276 assert_eq!(d.source, "ab\n");
12277 d.undo();
12278 assert_eq!(d.source, "\n");
12279 }
12280
12281 #[test]
12282 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
12283 let mut d = doc_with("compose_spurious", "\n");
12284 d.caret = 0;
12285 d.insert("a");
12286 d.end_composition(); // an IME unmarking unprompted
12287 d.insert("b");
12288 assert_eq!(d.source, "ab\n");
12289 d.undo();
12290 assert_eq!(d.source, "\n", "still one typed run");
12291 }
12292
12293 // ── the clipboard's rich flavor ──────────────────────────────────────────
12294
12295 #[test]
12296 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
12297 let mut d = doc_with("sel_inline", "a **bold** c\n");
12298 d.anchor = Some(2);
12299 d.caret = 10; // `**bold**`, inside the paragraph
12300 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
12301 }
12302
12303 #[test]
12304 fn a_whole_block_selection_keeps_its_paragraph() {
12305 let mut d = doc_with("sel_block", "a **bold** c\n");
12306 d.anchor = Some(0);
12307 d.caret = 12; // the entire paragraph
12308 assert_eq!(
12309 d.selection_html().as_deref(),
12310 Some("<p>a <strong>bold</strong> c</p>")
12311 );
12312 }
12313
12314 #[test]
12315 fn a_multi_block_selection_keeps_its_structure() {
12316 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
12317 d.select_all();
12318 let html = d.selection_html().expect("renders");
12319 assert!(html.contains("<p>para</p>"), "{html:?}");
12320 assert!(html.contains("<li>one</li>"), "{html:?}");
12321 }
12322
12323 #[test]
12324 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
12325 // The fragment `Head` is a paragraph standalone; the *document* says it
12326 // sits inside one block, so the wrapper is an artifact either way.
12327 let mut d = doc_with("sel_heading", "# Head line\n");
12328 d.anchor = Some(2);
12329 d.caret = 6;
12330 assert_eq!(d.selection_html().as_deref(), Some("Head"));
12331 }
12332
12333 #[test]
12334 fn no_selection_publishes_no_html() {
12335 let mut d = doc_with("sel_none", "a b\n");
12336 d.caret = 1;
12337 assert_eq!(d.selection_html(), None);
12338 }
12339
12340 #[test]
12341 fn pasting_html_converts_it_and_is_one_undo_step() {
12342 let mut d = doc_with("paste_html", "x\n");
12343 d.caret = 1;
12344 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
12345 assert_eq!(d.source, "xa **b** c\n");
12346 d.undo();
12347 assert_eq!(d.source, "x\n", "the whole paste, in one step");
12348 }
12349
12350 #[test]
12351 fn pasting_html_replaces_the_selection() {
12352 let mut d = doc_with("paste_html_sel", "keep drop\n");
12353 d.anchor = Some(5);
12354 d.caret = 9;
12355 assert!(d.paste_html("<em>new</em>"));
12356 assert_eq!(d.source, "keep *new*\n");
12357 }
12358
12359 #[test]
12360 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
12361 let mut d = doc_with("paste_html_bad", "x\n");
12362 d.caret = 1;
12363 // twig builds no table from HTML; raw `<table>` in prose is worse than
12364 // the plain flavor the caller still holds.
12365 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
12366 assert_eq!(d.source, "x\n", "declined edits nothing");
12367 }
12368
12369 #[test]
12370 fn copy_then_paste_round_trips_through_the_html_flavor() {
12371 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
12372 d.select_all();
12373 let html = d.selection_html().expect("renders");
12374 let mut into = doc_with("clip_round_dst", "\n");
12375 into.caret = 0;
12376 assert!(into.paste_html(&html));
12377 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
12378 }
12379
12380 #[test]
12381 fn moving_the_caret_starts_a_new_undo_group() {
12382 let mut d = doc_with("break", "\n");
12383 d.caret = 0;
12384 d.insert("a");
12385 d.insert("b"); // "ab\n", caret at 2
12386 d.move_left(false); // breaks the run
12387 d.insert("X"); // "aXb\n"
12388 assert_eq!(d.source, "aXb\n");
12389 d.undo();
12390 assert_eq!(
12391 d.source, "ab\n",
12392 "first undo removes only the post-move insert"
12393 );
12394 d.undo();
12395 assert_eq!(d.source, "\n", "second undo removes the earlier run");
12396 }
12397
12398 #[test]
12399 fn undo_reverses_a_format_toggle() {
12400 let mut d = doc_with("fmt_undo", "a word b\n");
12401 d.anchor = Some(2);
12402 d.caret = 6;
12403 d.toggle(InlineKind::Strong);
12404 assert_eq!(d.source, "a **word** b\n");
12405 d.undo();
12406 assert_eq!(d.source, "a word b\n");
12407 }
12408
12409 #[test]
12410 fn undo_back_to_the_saved_state_clears_dirty() {
12411 let mut d = doc_with("dirty_undo", "hello\n");
12412 assert!(!d.dirty);
12413 d.caret = 5;
12414 d.insert("!");
12415 assert!(d.dirty);
12416 d.undo();
12417 assert!(
12418 !d.dirty,
12419 "undoing to the saved source is not a modification"
12420 );
12421 }
12422
12423 #[test]
12424 fn a_new_edit_invalidates_redo() {
12425 let mut d = doc_with("redo_inv", "\n");
12426 d.caret = 0;
12427 d.insert("a");
12428 d.undo();
12429 d.insert("b"); // diverges — the redo of "a" is now gone
12430 d.redo();
12431 assert_eq!(d.source, "b\n");
12432 }
12433
12434 #[test]
12435 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
12436 let mut d = doc_with("can_undo", "hello\n");
12437 assert!(
12438 !d.can_undo() && !d.can_redo(),
12439 "a fresh document has no history"
12440 );
12441 d.caret = 5;
12442 d.insert("!");
12443 assert!(
12444 d.can_undo() && !d.can_redo(),
12445 "an edit is a step to take back"
12446 );
12447 d.undo();
12448 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
12449 d.redo();
12450 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
12451 d.undo();
12452 d.insert("?");
12453 assert!(
12454 d.can_undo() && !d.can_redo(),
12455 "a fresh edit ends the redo chain"
12456 );
12457 // A coalesced run over-counts steps — the bound is what a menu needs,
12458 // and it reconciles the moment twig reports the history empty.
12459 d.insert("a");
12460 d.insert("b");
12461 while d.can_undo() {
12462 d.undo();
12463 }
12464 assert_eq!(d.source, "hello\n");
12465 assert!(!d.can_undo());
12466 // A reading surface has nothing to undo, whatever the history holds.
12467 d.redo();
12468 d.set_read_only(true);
12469 assert!(!d.can_undo() && !d.can_redo());
12470 }
12471
12472 #[test]
12473 fn undo_on_empty_history_is_a_no_op() {
12474 let mut d = doc_with("undo_empty", "hi\n");
12475 d.undo();
12476 assert_eq!(d.source, "hi\n");
12477 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12478 }
12479
12480 #[test]
12481 fn a_one_character_paste_is_its_own_undo_step() {
12482 for view in [View::Source, View::Wysiwyg] {
12483 let mut d = doc_in(view, "paste_step", "ab\n");
12484 d.caret = 0;
12485 d.insert("x");
12486 d.insert("y"); // a run of typing
12487 d.paste("z"); // one character, but pasted — not part of that run
12488 assert_eq!(d.source, "xyzab\n");
12489 d.undo();
12490 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
12491 assert_eq!(d.caret, 2, "and hands back the caret it found");
12492 d.undo();
12493 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
12494 }
12495 }
12496
12497 #[test]
12498 fn the_same_character_typed_still_joins_the_run() {
12499 // The other half of the pair: `z` is a keystroke here and a paste above,
12500 // and the two undo differently. Nothing about the *string* says which —
12501 // which is why provenance has to come from the door the caller uses.
12502 for view in [View::Source, View::Wysiwyg] {
12503 let mut d = doc_in(view, "typed_run", "ab\n");
12504 d.caret = 0;
12505 d.insert("x");
12506 d.insert("y");
12507 d.insert("z");
12508 d.undo();
12509 assert_eq!(d.source, "ab\n", "one run, one step");
12510 }
12511 }
12512
12513 #[test]
12514 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
12515 for view in [View::Source, View::Wysiwyg] {
12516 let mut d = doc_in(view, "undo_caret", "hello world\n");
12517 d.caret = 11; // standing at the end of "world", away from the edit
12518 d.edit(0, 5, "goodbye");
12519 assert_eq!(d.source, "goodbye world\n");
12520 d.undo();
12521 assert_eq!(d.source, "hello world\n");
12522 // The undone edit ends at offset 5; the user was at 11.
12523 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
12524 }
12525 }
12526
12527 #[test]
12528 fn undo_restores_the_selection_the_edit_replaced() {
12529 for view in [View::Source, View::Wysiwyg] {
12530 let mut d = doc_in(view, "undo_sel", "a word b\n");
12531 d.anchor = Some(2);
12532 d.caret = 6; // "word" selected
12533 d.insert("X");
12534 assert_eq!(d.source, "a X b\n");
12535 d.undo();
12536 assert_eq!(d.source, "a word b\n");
12537 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
12538 }
12539 }
12540
12541 #[test]
12542 fn redo_restores_the_caret_the_edit_left_behind() {
12543 for view in [View::Source, View::Wysiwyg] {
12544 let mut d = doc_in(view, "redo_caret", "hello world\n");
12545 d.caret = 11;
12546 d.edit(0, 5, "goodbye");
12547 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
12548 d.undo();
12549 d.redo();
12550 assert_eq!(d.source, "goodbye world\n");
12551 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
12552 }
12553 }
12554
12555 #[test]
12556 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
12557 for view in [View::Source, View::Wysiwyg] {
12558 let mut d = doc_in(view, "run_caret", "hi\n");
12559 d.caret = 2;
12560 d.insert("a");
12561 d.insert("b");
12562 d.insert("c");
12563 assert_eq!(d.source, "hiabc\n");
12564 d.undo();
12565 assert_eq!(d.source, "hi\n");
12566 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
12567 d.redo();
12568 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
12569 }
12570 }
12571
12572 #[test]
12573 fn undo_restores_the_caret_across_a_format_toggle() {
12574 // A toggle reaches twig without going through `splice`, so it has to
12575 // record its own step — miss it and every stack depth below it is off by
12576 // one, and undo starts handing back another edit's caret.
12577 for view in [View::Source, View::Wysiwyg] {
12578 let mut d = doc_in(view, "fmt_caret", "a word b\n");
12579 d.caret = 8;
12580 d.anchor = Some(2);
12581 d.caret = 6;
12582 d.toggle(InlineKind::Strong);
12583 assert_eq!(d.source, "a **word** b\n");
12584 d.undo();
12585 assert_eq!(d.source, "a word b\n");
12586 assert_eq!(
12587 d.selection(),
12588 Some((2, 6)),
12589 "the toggled selection comes back"
12590 );
12591 }
12592 }
12593
12594 #[test]
12595 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12596 // The drift that would never announce itself: twig drops its redo stack
12597 // on any fresh edit, so a leaf redo entry that outlives it would restore
12598 // a caret from the timeline that edit abandoned.
12599 for view in [View::Source, View::Wysiwyg] {
12600 let mut d = doc_in(view, "redo_trunc", "hello world\n");
12601 d.caret = 11;
12602 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12603 d.undo();
12604 assert_eq!(d.caret, 11);
12605 d.caret = 0;
12606 d.insert("X"); // diverges: A's redo is gone from twig
12607 assert_eq!(d.source, "Xhello world\n");
12608
12609 d.redo();
12610 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12611 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12612 d.undo();
12613 assert_eq!(d.source, "hello world\n");
12614 assert_eq!(
12615 d.caret, 0,
12616 "the surviving step's caret, not the dropped one"
12617 );
12618 }
12619 }
12620
12621 #[test]
12622 fn indent_and_outdent_move_the_caret_line_with_its_text() {
12623 for view in [View::Source, View::Wysiwyg] {
12624 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
12625 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
12626 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12627 // Indentation the caret is standing *in* collapses to the line start
12628 // rather than dragging the caret into the text.
12629 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
12630 // A line with none to give back is left exactly as it was.
12631 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
12632 // Less than a full level gives back what it has.
12633 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12634 // A tab is one level however many spaces it isn't.
12635 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
12636 }
12637 }
12638
12639 #[test]
12640 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
12641 // Why the level is two spaces and not the four both frontends type
12642 // today. Four is markdown's indented-code-block marker, so a Tab on a
12643 // paragraph would silently restyle it as code — a width that changes
12644 // what the document *means* isn't an indent. Pinned because the number
12645 // is the kind of thing a later list-aware pass would reach for.
12646 let mut d = doc_with("indent_kind", "hello\n");
12647 d.caret = 2;
12648 d.indent();
12649 assert_eq!(d.source, " hello\n");
12650 assert!(
12651 d.nodes().iter().any(|n| n.kind == Kind::Para),
12652 "still prose after a Tab"
12653 );
12654 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
12655
12656 // The four-space level this replaces, for contrast: same text, and twig
12657 // reparses the paragraph into a code block.
12658 let mut wide = doc_with("indent_kind_4", " hello\n");
12659 wide.build_visual(80);
12660 assert!(
12661 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
12662 "four spaces is a code block, not an indented paragraph"
12663 );
12664 }
12665
12666 #[test]
12667 fn indent_nests_a_list_item_under_its_parent() {
12668 // Tab indents a list item by its own marker width, landing its marker at
12669 // the parent's content column so twig reparses it as a nested list.
12670 for view in [View::Source, View::Wysiwyg] {
12671 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
12672 d.caret = 6; // on the second item
12673 d.indent();
12674 assert_eq!(d.source, "- a\n - b\n");
12675 let lists = d
12676 .nodes()
12677 .iter()
12678 .filter(|n| n.kind == Kind::BulletList)
12679 .count();
12680 assert_eq!(lists, 2, "the indented item is a nested list");
12681 }
12682 }
12683
12684 #[test]
12685 fn indent_nests_an_ordered_item_at_its_marker_width() {
12686 // An ordered marker `1. ` is three columns wide, so a two-space step
12687 // (which nests a bullet) leaves it flat. Regression: Tab must use the
12688 // marker width, three, so the item actually nests — and the source
12689 // renumbers so the sub-list restarts at 1 and the outer list resumes.
12690 for view in [View::Source, View::Wysiwyg] {
12691 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
12692 d.caret = d.source.find('b').unwrap();
12693 d.indent();
12694 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
12695 let lists = d
12696 .nodes()
12697 .iter()
12698 .filter(|n| n.kind == Kind::OrderedList)
12699 .count();
12700 assert_eq!(lists, 2, "the indented item is a nested ordered list");
12701 }
12702 }
12703
12704 #[test]
12705 fn indent_leaves_a_lists_first_item_put() {
12706 // The first item of a list has no sibling above it to nest under, so Tab
12707 // is a no-op there — the marker stays at column zero rather than being
12708 // shoved into indentation twig can't read as a sub-list.
12709 for view in [View::Source, View::Wysiwyg] {
12710 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
12711 d.caret = 1; // on the FIRST item
12712 d.indent();
12713 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
12714 // The sibling below still nests, proving the guard is per-item.
12715 d.caret = d.source.find('b').unwrap();
12716 d.indent();
12717 assert_eq!(d.source, "- a\n - b\n");
12718 }
12719 }
12720
12721 #[test]
12722 fn hidden_mode_keeps_typed_markup_literal() {
12723 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
12724 // twig escapes what would open markup, so the source is `\*hi\*` and the
12725 // AST is a plain string. Formatting is the commands' job in this mode.
12726 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
12727 d.insert("*hi*");
12728 assert_eq!(d.source, "\\*hi\\*");
12729 assert!(
12730 d.nodes()
12731 .iter()
12732 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
12733 );
12734 }
12735
12736 #[test]
12737 fn hidden_mode_escapes_a_line_start_block_marker() {
12738 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
12739 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
12740 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
12741 d.insert("# hi");
12742 assert_eq!(d.source, "\\# hi");
12743 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
12744 }
12745
12746 #[test]
12747 fn authoring_modes_keep_typed_markup_live() {
12748 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
12749 // (no escape), the same as source view — escaping is `None`'s alone, and
12750 // it's the axis, not the reveal, that decides.
12751 for (view, mode) in [
12752 (View::Wysiwyg, MarkupMode::Shortcuts),
12753 (View::Wysiwyg, MarkupMode::Full),
12754 (View::Source, MarkupMode::None),
12755 ] {
12756 let mut d = doc_in(view, "live_markup", "");
12757 d.set_markup_mode(mode);
12758 d.insert("*hi*");
12759 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
12760 }
12761 }
12762
12763 #[test]
12764 fn hidden_mode_overwrite_undoes_in_one_step() {
12765 // Typing over a selection escapes the replacement *and* stays a single
12766 // undo — the selection-delete and the literal insert fold together, so
12767 // one undo brings the whole selection back, like a plain overwrite.
12768 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
12769 d.anchor = Some(2);
12770 d.caret = 6; // "word"
12771 d.insert("*");
12772 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
12773 d.undo();
12774 assert_eq!(d.source, "a word b\n");
12775 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
12776 }
12777
12778 #[test]
12779 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
12780 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
12781 // the whole visual character, never stranding the hidden `\`.
12782 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
12783 d.insert("*");
12784 assert_eq!(d.source, "\\*");
12785 d.backspace();
12786 assert_eq!(d.source, "", "the escape backslash went with the *");
12787 // A *literal* backslash (source view, no escape) is an ordinary char.
12788 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
12789 s.caret = 3; // after `b`
12790 s.backspace();
12791 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
12792 }
12793
12794 #[test]
12795 fn hidden_mode_leaves_structural_markup_alone() {
12796 // Enter continues a bullet list by writing a real `- ` marker (an
12797 // `insert_raw`, not the typing path), so Hidden mode's escaping never
12798 // touches it — the list keeps working.
12799 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
12800 d.caret = 6;
12801 d.newline();
12802 d.insert("two");
12803 assert_eq!(d.source, "- item\n- two\n");
12804 }
12805
12806 #[test]
12807 fn markup_mode_defaults_to_none_and_round_trips() {
12808 // Diaryx's default is the clean `None` surface; a markup-fluent
12809 // frontend can climb the ladder, and the choice sticks.
12810 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
12811 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
12812 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
12813 d.set_markup_mode(mode);
12814 assert_eq!(d.markup_mode(), mode);
12815 }
12816 }
12817
12818 #[test]
12819 fn full_mode_reveals_only_the_caret_line() {
12820 // The mode's whole claim: the caret's line shows its raw delimiters and
12821 // every other line stays resolved. Two paragraphs with identical markup
12822 // so the only difference between the rows is where the caret is.
12823 let mut d = doc_in(
12824 View::Wysiwyg,
12825 "reveal_caret_line",
12826 "*one* here\n\n*two* there\n",
12827 );
12828 d.set_markup_mode(MarkupMode::Full);
12829
12830 caret_at(&mut d, "one");
12831 let rows = drawn_rows(&d);
12832 assert!(
12833 rows.iter().any(|r| r == "*one* here"),
12834 "caret's line raw: {rows:?}"
12835 );
12836 assert!(
12837 rows.iter().any(|r| r == "two there"),
12838 "other line resolved: {rows:?}"
12839 );
12840
12841 // Move to the other paragraph: the reveal follows, and the line just
12842 // left goes back to being resolved.
12843 caret_at(&mut d, "two");
12844 let rows = drawn_rows(&d);
12845 assert!(
12846 rows.iter().any(|r| r == "*two* there"),
12847 "caret's line raw: {rows:?}"
12848 );
12849 assert!(
12850 rows.iter().any(|r| r == "one here"),
12851 "left line resolved: {rows:?}"
12852 );
12853 }
12854
12855 #[test]
12856 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
12857 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
12858 // the same treatment as an emphasis's `*`: hidden while the caret is
12859 // elsewhere, shown in full where the caret lands. That falls out of
12860 // `delims` reading the bytes between the mark's span and its content
12861 // span, which is exactly `==🔴 ` and `==`, rather than from a table
12862 // of spellings — so the no-space form `==🟢green==` reveals right too.
12863 let mut d = doc_in(
12864 View::Wysiwyg,
12865 "reveal_coloured_mark",
12866 "a ==🔴 red== one\n\nb ==plain== two\n",
12867 );
12868 d.set_markup_mode(MarkupMode::Full);
12869
12870 caret_at(&mut d, "red");
12871 let rows = drawn_rows(&d);
12872 assert!(
12873 rows.iter().any(|r| r == "a ==🔴 red== one"),
12874 "the caret's line shows the colour it was written with: {rows:?}"
12875 );
12876 assert!(
12877 rows.iter().any(|r| r == "b plain two"),
12878 "and every other line stays resolved: {rows:?}"
12879 );
12880
12881 // Away from it, the emoji goes back to being markup — the reader sees
12882 // the words and the wash.
12883 caret_at(&mut d, "two");
12884 let rows = drawn_rows(&d);
12885 assert!(
12886 rows.iter().any(|r| r == "a red one"),
12887 "resolved again: {rows:?}"
12888 );
12889 }
12890
12891 #[test]
12892 fn hidden_modes_never_reveal_wherever_the_caret_is() {
12893 // The two rungs below `Full` share a rendering: delimiters stay hidden
12894 // even under the caret. `Shortcuts` differing from `None` only in what
12895 // typing does is exactly the point of splitting the axes.
12896 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
12897 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
12898 d.set_markup_mode(mode);
12899 caret_at(&mut d, "one");
12900 let rows = drawn_rows(&d);
12901 assert!(
12902 rows.iter().any(|r| r == "one here"),
12903 "{mode:?} hides: {rows:?}"
12904 );
12905 assert!(
12906 !rows.iter().any(|r| r.contains('*')),
12907 "{mode:?} shows no `*`: {rows:?}"
12908 );
12909 }
12910 }
12911
12912 #[test]
12913 fn revealed_delimiters_are_the_authors_own_spelling() {
12914 // Delimiters are re-read from the source rather than synthesized per
12915 // kind, so a line comes back spelled the way it was written: `_em_` does
12916 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
12917 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
12918 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
12919 d.set_markup_mode(MarkupMode::Full);
12920 caret_at(&mut d, "em");
12921 let rows = drawn_rows(&d);
12922 assert!(
12923 rows.iter().any(|r| r == body.trim_end()),
12924 "the revealed line is its own source: {rows:?}"
12925 );
12926 }
12927
12928 #[test]
12929 fn revealed_heading_shows_its_hashes() {
12930 // The `# ` marker is a block-level prefix, not an inline delimiter, so
12931 // it takes its own path — but it reveals on the same rule.
12932 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
12933 d.set_markup_mode(MarkupMode::Full);
12934
12935 caret_at(&mut d, "Title");
12936 assert!(
12937 drawn_rows(&d).iter().any(|r| r == "# Title"),
12938 "{:?}",
12939 drawn_rows(&d)
12940 );
12941
12942 caret_at(&mut d, "body");
12943 let rows = drawn_rows(&d);
12944 assert!(
12945 rows.iter().any(|r| r == "Title"),
12946 "hashes hidden again: {rows:?}"
12947 );
12948 }
12949
12950 #[test]
12951 fn revealed_delimiters_are_caret_stops() {
12952 // A delimiter that is drawn but can't be reached is worse than one
12953 // that's hidden: the mode exists so the markup can be *edited*. Every
12954 // revealed byte must be somewhere the caret can stand.
12955 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
12956 d.set_markup_mode(MarkupMode::Full);
12957 caret_at(&mut d, "em");
12958 let opener = d.source.find('*').unwrap();
12959 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
12960 assert!(
12961 d.vmap.is_stop(opener + 3),
12962 "the closing `*` is a caret stop"
12963 );
12964 }
12965
12966 #[test]
12967 fn setext_heading_reveals_nothing_across_its_newline() {
12968 // A setext heading's underline is on another line, so it is not the
12969 // caret line's to reveal — and emitting it would inject a `\n` glyph
12970 // that splits the row where the author wrote no break.
12971 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
12972 d.set_markup_mode(MarkupMode::Full);
12973 caret_at(&mut d, "Title");
12974 let rows = drawn_rows(&d);
12975 assert!(
12976 rows.iter().any(|r| r == "Title"),
12977 "title renders alone: {rows:?}"
12978 );
12979 assert!(
12980 !rows.iter().any(|r| r.contains('=')),
12981 "no underline leaks in: {rows:?}"
12982 );
12983 }
12984
12985 #[test]
12986 fn markup_mode_axes_split_the_ladder() {
12987 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
12988 // that authors markup but still hides it, and it's the only rung where
12989 // the two axes disagree.
12990 assert!(!MarkupMode::None.authors());
12991 assert!(!MarkupMode::None.reveals_caret_line());
12992 assert!(MarkupMode::Shortcuts.authors());
12993 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
12994 assert!(MarkupMode::Full.authors());
12995 assert!(MarkupMode::Full.reveals_caret_line());
12996 }
12997
12998 #[test]
12999 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
13000 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
13001 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
13002 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
13003 // nested bullet and `hello` stays prose: the file round-trips instead of
13004 // hiding a heading the user never asked for.
13005 for view in [View::Source, View::Wysiwyg] {
13006 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
13007 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
13008 d.indent();
13009 assert_eq!(d.source, "- hello\n * \n");
13010 assert!(
13011 d.nodes().iter().all(|n| n.kind != Kind::Heading),
13012 "no heading"
13013 );
13014 // And it's genuinely a nested list, not a flat one.
13015 assert_eq!(
13016 d.nodes()
13017 .iter()
13018 .filter(|n| n.kind == Kind::BulletList)
13019 .count(),
13020 2
13021 );
13022 }
13023 }
13024
13025 #[test]
13026 fn indenting_a_dash_item_with_content_keeps_its_dash() {
13027 // With content, `- x` can't be a setext underline, so there's nothing to
13028 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
13029 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
13030 d.caret = d.source.find('x').unwrap();
13031 d.indent();
13032 assert_eq!(d.source, "- hello\n - x\n");
13033 }
13034
13035 #[test]
13036 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
13037 // The dash→`*` repair coalesces into the Tab, so a single undo restores
13038 // the whole pre-Tab state rather than stranding a half-collapsed doc.
13039 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
13040 d.caret = d.source.find("- \n").unwrap() + 2;
13041 d.indent();
13042 assert_eq!(d.source, "- hello\n * \n");
13043 d.undo();
13044 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
13045 }
13046
13047 #[test]
13048 fn indent_leaves_a_nested_lists_first_item_put_too() {
13049 // The guard is about siblings, not depth: the first item of an *inner*
13050 // list (already nested under `a`) still has nothing before it at its own
13051 // level, so Tab can't take it deeper.
13052 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
13053 d.caret = d.source.find('b').unwrap();
13054 d.indent();
13055 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
13056 // But `c` (a sibling of `b`) nests under `b`.
13057 d.caret = d.source.find('c').unwrap();
13058 d.indent();
13059 assert_eq!(d.source, "- a\n - b\n - c\n");
13060 }
13061
13062 #[test]
13063 fn backspace_at_a_nested_item_start_outdents_it() {
13064 // Backspace with the caret right after a nested item's marker gives back
13065 // one level of nesting, the mirror of Tab — and renumbers the flattened
13066 // ordered list back to a clean run.
13067 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
13068 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
13069 d.backspace();
13070 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13071 }
13072
13073 #[test]
13074 fn backspace_at_a_top_level_item_start_strips_the_marker() {
13075 // At the outermost level there's no nesting left to give back, so the same
13076 // keystroke drops the bullet and leaves a plain paragraph.
13077 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
13078 d.caret = d.source.find('b').unwrap(); // right after `- `
13079 d.backspace();
13080 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
13081 }
13082
13083 #[test]
13084 fn backspace_mid_item_still_deletes_a_character() {
13085 // The list behaviour is armed only at the item's content start; anywhere
13086 // else Backspace is the ordinary character delete.
13087 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
13088 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
13089 d.backspace();
13090 assert_eq!(d.source, "- b\n");
13091 }
13092
13093 #[test]
13094 fn backspace_at_a_heading_start_strips_the_marker() {
13095 // The `# ` is markup the rich view hides, so Backspace over it takes the
13096 // whole marker and leaves a paragraph. Deleting a byte of it instead left
13097 // `#Title` — no longer a heading, with the hash now literal text the user
13098 // never typed and has to delete again.
13099 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
13100 d.caret = d.source.find('T').unwrap(); // right after `## `
13101 d.backspace();
13102 assert_eq!(d.source, "Title\n");
13103 assert_eq!(
13104 d.caret, 0,
13105 "the caret stays with the text it was in front of"
13106 );
13107 }
13108
13109 #[test]
13110 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
13111 // Only the heading's own marker goes — the quote (or list) it sits in is
13112 // untouched, exactly as un-heading it should be.
13113 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
13114 d.caret = d.source.find('T').unwrap();
13115 d.backspace();
13116 assert_eq!(d.source, "> Title\n");
13117 }
13118
13119 #[test]
13120 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
13121 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
13122 // behind would surface the same stray hash the marker delete just avoided.
13123 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
13124 d.caret = d.source.find('T').unwrap();
13125 d.backspace();
13126 assert_eq!(d.source, "Title\n");
13127 // And it's one edit: a single undo puts the whole heading back.
13128 d.undo();
13129 assert_eq!(d.source, "# Title #\n");
13130 }
13131
13132 #[test]
13133 fn backspace_mid_heading_still_deletes_a_character() {
13134 // The heading behaviour is armed only at the content's start; anywhere
13135 // else Backspace is the ordinary character delete.
13136 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
13137 d.caret = d.source.find('b').unwrap();
13138 d.backspace();
13139 assert_eq!(d.source, "# b\n");
13140 }
13141
13142 #[test]
13143 fn source_view_backspace_still_edits_the_heading_marker_literally() {
13144 // In source view the `# ` is text on the screen the user is deleting a
13145 // byte of, so it keeps its literal meaning — the same split the list
13146 // ladder and Enter draw between the two views.
13147 let mut d = doc_with("bsp_head_src", "# Title\n");
13148 d.caret = d.source.find('T').unwrap();
13149 d.backspace();
13150 assert_eq!(d.source, "#Title\n");
13151 }
13152
13153 #[test]
13154 fn outdent_unnests_an_ordered_item_in_one_press() {
13155 // Shift+Tab gives back exactly the marker width the indent added, so a
13156 // nested ordered item unnests in a single press, and the flattened list
13157 // renumbers back to a clean 1, 2, 3.
13158 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
13159 d.caret = d.source.find('b').unwrap();
13160 d.outdent();
13161 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13162 let lists = d
13163 .nodes()
13164 .iter()
13165 .filter(|n| n.kind == Kind::OrderedList)
13166 .count();
13167 assert_eq!(lists, 1, "back to one flat list");
13168 }
13169
13170 #[test]
13171 fn table_insert_row_adds_a_row_below_the_caret() {
13172 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13173 d.caret = d.source.find('1').unwrap(); // in the body row
13174 d.table_insert_row(true);
13175 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
13176 }
13177
13178 #[test]
13179 fn table_insert_and_delete_column_at_the_caret() {
13180 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13181 d.caret = d.source.find('a').unwrap(); // column 0
13182 d.table_insert_column(true); // add a column to the right of `a`
13183 assert_eq!(
13184 d.source,
13185 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
13186 );
13187 d.caret = d.source.find('b').unwrap(); // now the third column
13188 d.table_delete_column();
13189 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
13190 }
13191
13192 // ── ragged formats ───────────────────────────────────────────────────────
13193 // No format spells every gesture. HTML writes the inline marks as a tag pair
13194 // and no heading, list, quote or link; Markdown spells five of the eight
13195 // marks — the highlight only because leaf parses with `highlight`, which is
13196 // why the question is asked with the extensions; djot spells all eight and
13197 // no in-cell break. leaf asks twig per
13198 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
13199 // op discover the fact on its own — one of them didn't.
13200
13201 /// An HTML document in the rich view, ready for a gesture.
13202 fn html_doc(body: &str) -> Doc {
13203 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
13204 d.view = View::Wysiwyg;
13205 d.build_visual(80);
13206 d
13207 }
13208
13209 #[test]
13210 fn a_table_gesture_leaves_an_html_table_alone() {
13211 // The regression this guard exists for. twig's table editor consults no
13212 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
13213 // HTML `<table>` as a *pipe table* and reported success: the whole
13214 // element replaced by `| a | b |`, silently, on one press of a toolbar
13215 // button. Every grid op went the same way.
13216 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
13217 // A table of named operations, which is what it looks like.
13218 #[allow(clippy::type_complexity)]
13219 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
13220 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
13221 ("delete row", &|d: &mut Doc| d.table_delete_row()),
13222 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
13223 ("delete column", &|d: &mut Doc| d.table_delete_column()),
13224 ("align", &|d: &mut Doc| {
13225 d.table_set_alignment(Alignment::Right)
13226 }),
13227 ("move row", &|d: &mut Doc| d.table_move_row(true)),
13228 ("move column", &|d: &mut Doc| d.table_move_column(true)),
13229 ];
13230 for (name, op) in ops {
13231 let mut d = html_doc(src);
13232 d.caret = d.source.find('a').unwrap();
13233 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
13234 op(&mut d);
13235 assert_eq!(d.source, src, "{name} rewrote an HTML table");
13236 assert!(
13237 !d.dirty,
13238 "{name} marked the document dirty without editing it"
13239 );
13240 assert!(d.status.is_some(), "{name} refused without saying why");
13241 }
13242 }
13243
13244 #[test]
13245 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
13246 // A task box is a form control in HTML and a footnote has no native
13247 // spelling at all — the two gestures twig 3.5 still spells nothing
13248 // for, now that a quote, a list, a link and an image print through
13249 // its renderer (see the test below).
13250 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13251 // A table of named operations, which is what it looks like.
13252 #[allow(clippy::type_complexity)]
13253 let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
13254 ("task item", &|d: &mut Doc| d.toggle_task_item()),
13255 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
13256 ("footnote", &|d: &mut Doc| d.insert_footnote()),
13257 ];
13258 for (name, op) in ops {
13259 let mut d = html_doc(src);
13260 let at = d.source.find("Hello").unwrap();
13261 d.caret = at;
13262 d.anchor = Some(at + 5); // a selection, for the ops that want one
13263 op(&mut d);
13264 assert_eq!(d.source, src, "{name} edited an HTML document");
13265 assert!(
13266 !d.dirty,
13267 "{name} marked the document dirty without editing it"
13268 );
13269 let status = d.status.as_deref().unwrap_or("");
13270 assert!(
13271 status.contains("html"),
13272 "{name}: the refusal should name the format, got {status:?}"
13273 );
13274 }
13275 }
13276
13277 #[test]
13278 fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
13279 // twig 3.5: where HTML has no marker alphabet it prints the fresh
13280 // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
13281 // the paragraph as its item, an `<a>` or `<img>` over the selection.
13282 // Until then every one of these was a refusal; now each is a real
13283 // edit, which is what the toolbar's capability flags say too.
13284 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13285 #[allow(clippy::type_complexity)]
13286 let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
13287 (
13288 "quote",
13289 &|d: &mut Doc| d.toggle_blockquote(),
13290 "<blockquote>",
13291 ),
13292 ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
13293 (
13294 "ordered list",
13295 &|d: &mut Doc| d.toggle_list(true),
13296 "<ol>\n<li>",
13297 ),
13298 (
13299 "link",
13300 &|d: &mut Doc| d.insert_link("https://example.dev"),
13301 "<a href=\"https://example.dev\">Hello</a>",
13302 ),
13303 (
13304 "image",
13305 &|d: &mut Doc| d.insert_image("pic.png", "alt"),
13306 "<img alt=\"Hello\" src=\"pic.png\">",
13307 ),
13308 ];
13309 for (name, op, expect) in ops {
13310 let mut d = html_doc(src);
13311 let at = d.source.find("Hello").unwrap();
13312 d.caret = at;
13313 d.anchor = Some(at + 5);
13314 op(&mut d);
13315 assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
13316 assert!(d.dirty, "{name}: a real edit");
13317 assert_eq!(
13318 d.status, None,
13319 "{name}: a supported gesture reports nothing"
13320 );
13321 }
13322 }
13323
13324 #[test]
13325 fn html_spells_a_heading_as_its_tag_pair() {
13326 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
13327 // along — the one block gesture whose HTML shape it can write. So ⌘2
13328 // in an HTML document is a real edit, and ⌘0 takes it back.
13329 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
13330 let mut d = html_doc(src);
13331 d.caret = d.source.find("Hello").unwrap();
13332 d.toggle_heading(2);
13333 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
13334 assert!(d.dirty);
13335 assert_eq!(d.status, None, "a supported gesture reports nothing");
13336 d.toggle_heading(2);
13337 assert_eq!(d.source, src, "the same level again is back to a paragraph");
13338 }
13339
13340 #[test]
13341 fn html_spells_the_inline_marks_and_the_rule() {
13342 // The other half, and why one per-document flag stopped being enough:
13343 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
13344 // already emits and the parser reads straight back as the same mark —
13345 // and the rule button writes an `<hr>`. Refusing these on the old
13346 // "HTML is parse-only" reading would now be leaf's own limitation.
13347 let mut d = html_doc("<p>Hello world</p>\n");
13348 let at = d.source.find("world").unwrap();
13349 d.caret = at;
13350 d.anchor = Some(at + 5);
13351 d.toggle(InlineKind::Strong);
13352 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
13353 assert!(d.dirty);
13354 assert_eq!(d.status, None, "a supported gesture reports nothing");
13355
13356 // And off again — the toggle reverses, which is the property that makes
13357 // authoring in HTML worth offering rather than a one-way trip.
13358 d.toggle(InlineKind::Strong);
13359 assert_eq!(d.source, "<p>Hello world</p>\n");
13360
13361 let mut d = html_doc("<p>Hello world</p>\n");
13362 d.caret = d.source.find("world").unwrap();
13363 d.insert_thematic_break();
13364 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
13365 }
13366
13367 #[test]
13368 fn a_mark_the_format_cannot_spell_arms_nothing() {
13369 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
13370 // sticky mark for the next text typed. Guarding only the twig call
13371 // leaves that path live, promising a mark the gesture will not write and
13372 // then swallowing the error inside `insert`.
13373 //
13374 // Markdown carries this, on the superscript now rather than on the
13375 // highlight: `^x^` is text there in any configuration, whereas twig
13376 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
13377 // which every leaf document does.
13378 let mut d = doc_with("mark", "Hello world\n");
13379 d.view = View::Wysiwyg;
13380 d.build_visual(80);
13381 d.caret = d.source.find("world").unwrap();
13382 d.toggle(InlineKind::Superscript);
13383 assert!(d.pending_marks.is_empty(), "no mark should be armed");
13384 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
13385 d.insert("X");
13386 assert_eq!(d.source, "Hello Xworld\n");
13387 }
13388
13389 #[test]
13390 fn markdown_authors_a_highlight_and_a_strikethrough() {
13391 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
13392 // write. `==x==` is authorable because leaf's own `parse_extensions`
13393 // turns `highlight` on — twig will only mint bytes this editor's reparse
13394 // reads back — and `~~x~~` because GFM strikethrough is parsed by
13395 // default, so the refusal there was never right for any leaf document.
13396 for (kind, marked) in [
13397 (InlineKind::Mark, "a ==word== b\n"),
13398 (InlineKind::Delete, "a ~~word~~ b\n"),
13399 ] {
13400 let mut d = doc_with("author_mark", "a word b\n");
13401 d.anchor = Some(2);
13402 d.caret = 6;
13403 d.toggle(kind);
13404 assert_eq!(d.source, marked, "{kind:?}");
13405 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
13406 assert!(d.dirty, "{kind:?}");
13407 // The region stays selected, so the second press reverses it — the
13408 // property that separates authoring from a one-way trip.
13409 d.toggle(kind);
13410 assert_eq!(d.source, "a word b\n", "{kind:?}");
13411 }
13412 }
13413
13414 #[test]
13415 fn an_authored_highlight_reads_back_as_a_mark() {
13416 // The round trip the extension gate exists to protect: what the toggle
13417 // writes, the reparse must read back as a `mark` rather than as two
13418 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
13419 // rebuilt map rather than from the source text.
13420 let mut d = doc_with("mark_roundtrip", "a word b\n");
13421 d.view = View::Wysiwyg;
13422 d.build_visual(80);
13423 d.anchor = Some(2);
13424 d.caret = 6;
13425 d.toggle(InlineKind::Mark);
13426 assert_eq!(d.source, "a ==word== b\n");
13427 d.build_visual(80);
13428 let w = d
13429 .vmap
13430 .rows
13431 .iter()
13432 .flat_map(|r| r.glyphs.iter())
13433 .find(|g| g.ch == 'w')
13434 .expect("the highlighted word");
13435 assert_eq!(w.style.role, crate::Role::Mark(None));
13436 }
13437
13438 #[test]
13439 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
13440 // The three states of one gesture, in the order a palette is pressed:
13441 // an uncoloured highlight takes the prefix, a coloured one has it
13442 // replaced, and `None` takes it away with the space that was part of the
13443 // spelling.
13444 let mut d = doc_with("mark_colour", "a ==word== b\n");
13445 d.caret = d.source.find("word").unwrap();
13446 d.set_mark_color(Some(MarkColor::Red));
13447 assert_eq!(d.source, "a ==🔴 word== b\n");
13448 assert_eq!(d.status, None);
13449 assert!(d.dirty);
13450
13451 d.set_mark_color(Some(MarkColor::Blue));
13452 assert_eq!(d.source, "a ==🔵 word== b\n");
13453
13454 d.set_mark_color(None);
13455 assert_eq!(d.source, "a ==word== b\n");
13456 }
13457
13458 #[test]
13459 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
13460 // The prefix is written *before* the word, so an offset in the word has
13461 // to ride its width — a caret that stayed put would be a caret that
13462 // walked backwards through the text it was standing in.
13463 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
13464 let word = d.source.find("word").unwrap();
13465 d.caret = word + 2; // between `wo` and `rd`
13466 d.set_mark_color(Some(MarkColor::Red));
13467 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
13468
13469 // And back the other way when the prefix goes.
13470 d.set_mark_color(None);
13471 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
13472 }
13473
13474 #[test]
13475 fn the_colour_at_the_caret_is_what_the_palette_lights() {
13476 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
13477 d.caret = d.source.find("red").unwrap();
13478 assert!(d.caret_in_mark());
13479 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
13480
13481 d.caret = d.source.find("plain").unwrap();
13482 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
13483 assert_eq!(d.mark_color_at_caret(), None);
13484
13485 d.caret = d.source.find(" and ").unwrap() + 2;
13486 assert!(!d.caret_in_mark());
13487 assert_eq!(d.mark_color_at_caret(), None);
13488 }
13489
13490 #[test]
13491 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
13492 // The gesture colours a highlight that exists; it does not make one.
13493 // Two presses is the price of a coloured highlight from bare text, and
13494 // the reason is undo — one press that spliced twice would take two
13495 // presses to take back.
13496 let mut d = doc_with("mark_colour_none", "a word b\n");
13497 d.caret = d.source.find("word").unwrap();
13498 d.set_mark_color(Some(MarkColor::Red));
13499 assert_eq!(d.source, "a word b\n");
13500 assert!(d.status.is_some(), "it should say why");
13501 assert!(!d.dirty);
13502
13503 // Clearing where there is nothing to clear is the same refusal, not a
13504 // quiet success — the caret is in no highlight either way.
13505 d.status = None;
13506 d.set_mark_color(None);
13507 assert_eq!(d.source, "a word b\n");
13508 assert!(d.status.is_some());
13509 }
13510
13511 #[test]
13512 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
13513 // twig answers this one *successfully* with a `Change` describing some
13514 // earlier edit, so a caller that trusted the change would jump the caret
13515 // to wherever that was. Core answers it before asking.
13516 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
13517 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
13518 d.caret = d.source.find("word").unwrap();
13519 let (source, caret) = (d.source.clone(), d.caret);
13520 d.set_mark_color(None);
13521 assert_eq!(d.source, source);
13522 assert_eq!(
13523 d.caret, caret,
13524 "the caret must not ride a change that isn't one"
13525 );
13526 assert_eq!(d.status, None, "and it is not an error either");
13527 }
13528
13529 #[test]
13530 fn djot_spells_the_highlight_and_not_its_colour() {
13531 // The reason the palette is its own capability rather than the Highlight
13532 // button's: `{=word=}` is a highlight djot writes happily, and there is
13533 // no djot spelling for a colour on it.
13534 assert!(Capabilities::of(Format::Djot).mark);
13535 assert!(!Capabilities::of(Format::Djot).mark_color);
13536 assert!(Capabilities::of(Format::Markdown).mark_color);
13537
13538 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
13539 d.caret = d.source.find("word").unwrap();
13540 assert!(
13541 d.caret_in_mark(),
13542 "the caret is in a highlight all the same"
13543 );
13544 d.set_mark_color(Some(MarkColor::Red));
13545 assert_eq!(d.source, "a {=word=} b\n");
13546 assert!(
13547 d.status.as_deref().unwrap_or("").contains("djot"),
13548 "and the refusal names the document's format: {:?}",
13549 d.status
13550 );
13551 }
13552
13553 #[test]
13554 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
13555 // The round trip that matters for a palette: the bytes twig writes are
13556 // bytes its own reparse reads back as a colour, so the swatch that was
13557 // pressed is the swatch that lights afterwards.
13558 let mut d = doc_with("mark_colour_undo", "a word b\n");
13559 d.anchor = Some(2);
13560 d.caret = 6;
13561 d.toggle(InlineKind::Mark);
13562 d.caret = d.source.find("word").unwrap();
13563 d.set_mark_color(Some(MarkColor::Green));
13564 assert_eq!(d.source, "a ==🟢 word== b\n");
13565 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
13566
13567 // One splice, one step: the colour comes off and the highlight stays.
13568 d.undo();
13569 assert_eq!(d.source, "a ==word== b\n");
13570 d.undo();
13571 assert_eq!(d.source, "a word b\n");
13572 }
13573
13574 #[test]
13575 fn every_colour_leaf_names_is_one_twig_writes() {
13576 // The two enums are one vocabulary, and this is what says so: each of
13577 // leaf's colours writes an emoji twig's reparse reads back as *that*
13578 // colour, so `twig_mark_color`'s table cannot quietly pair red with
13579 // orange.
13580 for color in MarkColor::ALL {
13581 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13582 d.caret = d.source.find("word").unwrap();
13583 d.set_mark_color(Some(color));
13584 assert_eq!(d.status, None, "{color:?}");
13585 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13586 }
13587 }
13588
13589 #[test]
13590 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13591 // The two presses a coloured highlight is made of, in the state the
13592 // first one leaves: `toggle` selects the whole `==word==` and puts the
13593 // caret one past the closing `==`, which is *not* in the mark. Asking at
13594 // the caret alone would refuse to colour the highlight just written —
13595 // the selection's start is what answers.
13596 let mut d = doc_with("mark_colour_fresh", "a word b\n");
13597 d.anchor = Some(2);
13598 d.caret = 6;
13599 d.toggle(InlineKind::Mark);
13600 assert_eq!(d.source, "a ==word== b\n");
13601 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13602
13603 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13604 d.set_mark_color(Some(MarkColor::Yellow));
13605 assert_eq!(d.source, "a ==🟡 word== b\n");
13606 assert_eq!(d.status, None);
13607 }
13608
13609 #[test]
13610 fn one_press_highlights_a_selection_and_colours_it() {
13611 // What a toolbar swatch means over a plain selection, and the undo it
13612 // has to have: one press, one step. Two steps would leave an uncoloured
13613 // highlight behind on the way back, which is a state the author never
13614 // asked for and never saw.
13615 let mut d = doc_with("highlight_one", "a word b\n");
13616 d.anchor = Some(2);
13617 d.caret = 6;
13618 d.highlight(Some(MarkColor::Purple));
13619 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
13620 assert_eq!(d.status, None);
13621
13622 d.undo();
13623 assert_eq!(d.source, "a word b\n", "one press, one undo");
13624 }
13625
13626 #[test]
13627 fn one_press_on_an_existing_highlight_only_recolours_it() {
13628 // The other half: inside a highlight there is nothing to make, so the
13629 // compound is the plain gesture and the text is untouched.
13630 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
13631 d.caret = d.source.find("word").unwrap();
13632 d.highlight(Some(MarkColor::Blue));
13633 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
13634 d.undo();
13635 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
13636 }
13637
13638 #[test]
13639 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
13640 // `None` means "no colour", and over bare text that is the Highlight
13641 // button's own job. The fold must not happen here — there is no second
13642 // splice, and folding would take the *previous* edit into this one.
13643 let mut d = doc_with("highlight_none", "a word b and more\n");
13644 d.caret = d.source.find("more").unwrap() + 4; // after "more"
13645 d.insert("!"); // an earlier edit for a wrong fold to swallow
13646 d.anchor = Some(2);
13647 d.caret = 6;
13648 d.highlight(None);
13649 assert_eq!(d.source, "a ==word== b and more!\n");
13650
13651 d.undo();
13652 assert_eq!(
13653 d.source, "a word b and more!\n",
13654 "only the highlight came off"
13655 );
13656 d.undo();
13657 assert_eq!(
13658 d.source, "a word b and more\n",
13659 "and the edit before it survived"
13660 );
13661 }
13662
13663 #[test]
13664 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
13665 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
13666 // a colour cannot be armed with it — so the compound declines rather
13667 // than leaving half a promise.
13668 let mut d = doc_with("highlight_bare", "a word b\n");
13669 d.caret = 4;
13670 d.highlight(Some(MarkColor::Red));
13671 assert_eq!(d.source, "a word b\n");
13672 assert!(d.pending_marks.is_empty(), "and nothing armed");
13673 assert!(d.status.is_some());
13674 }
13675
13676 #[test]
13677 fn a_read_only_document_takes_no_colour() {
13678 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
13679 d.caret = d.source.find("word").unwrap();
13680 d.set_read_only(true);
13681 d.set_mark_color(Some(MarkColor::Red));
13682 assert_eq!(d.source, "a ==word== b\n");
13683 }
13684
13685 #[test]
13686 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
13687 // The other door into `toggle`: no selection, so nothing reaches twig
13688 // until `insert` realises the armed mark. It is armed now — the guard
13689 // above asks `Doc::supports`, which asks with the extensions — and what
13690 // it writes is the same `==…==`.
13691 let mut d = doc_with("sticky_mark", "xy\n");
13692 d.caret = 1;
13693 d.toggle(InlineKind::Mark);
13694 assert!(d.pending_marks.contains(InlineKind::Mark));
13695 d.insert("Z");
13696 assert_eq!(d.source, "x==Z==y\n");
13697 }
13698
13699 #[test]
13700 fn html_documents_still_take_typed_text() {
13701 // The guard covers *markup* gestures and must not touch plain editing:
13702 // twig's splicer is language-neutral, and typing into an HTML document
13703 // is the thing that does work today.
13704 let mut d = html_doc("<p>Hello world</p>\n");
13705 d.caret = d.source.find("world").unwrap();
13706 d.insert("big ");
13707 assert_eq!(d.source, "<p>Hello big world</p>\n");
13708 assert!(d.dirty);
13709 d.backspace();
13710 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
13711 d.undo();
13712 d.undo();
13713 assert_eq!(d.source, "<p>Hello world</p>\n");
13714 }
13715
13716 #[test]
13717 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
13718 // `authorable` only separates "there is a door in" from "there is not",
13719 // and HTML is on the near side of that line — which is exactly why a
13720 // toolbar must not be built from it.
13721 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
13722 assert!(html.authorable());
13723 assert!(
13724 !Doc::from_source("<r>x</r>".into(), Format::Xml)
13725 .unwrap()
13726 .authorable()
13727 );
13728
13729 let caps = html.capabilities();
13730 assert!(caps.bold && caps.italic && caps.code && caps.mark);
13731 assert!(caps.thematic_break && caps.cell_line_break);
13732 // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
13733 // quote, a list, a code block's language, a link and an image — each
13734 // printed as a fresh node where HTML has no marker to rewrite. A task
13735 // box is a form control and a footnote has no spelling, so those two
13736 // are what keeps the record ragged.
13737 assert!(caps.heading && caps.blockquote && caps.bullet_list);
13738 assert!(caps.link && caps.image && caps.code_language);
13739 assert!(!caps.task && !caps.footnote);
13740 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
13741 // twig's table editor would happily re-emit as `| a | b |`.
13742 assert!(!caps.table);
13743
13744 // The two lightweight formats spell everything leaf offers — and still
13745 // differ from each other, which is the other half of why one boolean
13746 // can't serve.
13747 for fmt in [Format::Markdown, Format::Djot] {
13748 let caps = Capabilities::of(fmt);
13749 assert!(
13750 caps.heading && caps.blockquote && caps.ordered_list,
13751 "{fmt:?}"
13752 );
13753 assert!(
13754 caps.task && caps.link && caps.image && caps.table,
13755 "{fmt:?}"
13756 );
13757 }
13758 // Both spell the highlight and the strikethrough: djot natively, and
13759 // Markdown because `Capabilities` asks with `parse_extensions` rather
13760 // than with twig's defaults — `==x==` is text under those, and a mark
13761 // under the `highlight` leaf always parses with.
13762 for fmt in [Format::Markdown, Format::Djot] {
13763 let caps = Capabilities::of(fmt);
13764 assert!(caps.mark && caps.strike, "{fmt:?}");
13765 }
13766 // What still separates them, now that the highlight doesn't: djot has
13767 // no in-cell break, and Markdown spells neither of the scripts.
13768 assert!(Capabilities::of(Format::Djot).superscript);
13769 assert!(!Capabilities::of(Format::Markdown).superscript);
13770 assert!(Capabilities::of(Format::Markdown).cell_line_break);
13771 assert!(!Capabilities::of(Format::Djot).cell_line_break);
13772
13773 // A parse-only format answers no to every one of them, so the coarse
13774 // predicate and the record agree there.
13775 let caps = Capabilities::of(Format::Xml);
13776 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
13777 }
13778
13779 #[test]
13780 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
13781 // The guard exists to name the *document's* format rather than twig's
13782 // internals, so the message has to survive being one leaf writes itself.
13783 // Checked against a gesture twig also refuses, since that is the pair
13784 // most at risk of drifting apart — the task box, once the code
13785 // language stopped being one (twig 3.5).
13786 let mut d = html_doc("<p>Hello</p>\n");
13787 d.caret = d.source.find("Hello").unwrap();
13788 d.toggle_task_item();
13789 assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
13790 assert!(!d.dirty);
13791 }
13792
13793 #[test]
13794 fn table_set_alignment_respells_the_delimiter() {
13795 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13796 d.caret = d.source.find('b').unwrap();
13797 d.table_set_alignment(Alignment::Right);
13798 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
13799 }
13800
13801 #[test]
13802 fn each_empty_table_cell_has_its_own_editable_home() {
13803 // Regression: an empty cell has no twig content_span, so both cells of a
13804 // `| | |` row collapsed onto the row's start (before the first `│`).
13805 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
13806 // tell the cells apart. Each empty cell must now have a distinct home
13807 // inside it.
13808 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
13809 let (c0, c1) = {
13810 let cells = &d.vmap.tables[0].grid[1].cells;
13811 (cells[0].start, cells[1].start)
13812 };
13813 assert!(
13814 c0 < c1,
13815 "the two empty cells have distinct homes: {c0} < {c1}"
13816 );
13817 d.caret = c0;
13818 d.insert("x");
13819 assert_eq!(
13820 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
13821 "typed inside the cell"
13822 );
13823 }
13824
13825 #[test]
13826 fn arrows_step_into_each_empty_table_cell() {
13827 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
13828 let (c0, c1) = {
13829 let cells = &d.vmap.tables[0].grid[1].cells;
13830 (cells[0].start, cells[1].start)
13831 };
13832 d.caret = d.source.find('b').unwrap(); // in the header's second cell
13833 let mut seen = std::collections::HashSet::new();
13834 for _ in 0..6 {
13835 d.move_right(false);
13836 seen.insert(d.caret);
13837 }
13838 assert!(
13839 seen.contains(&c0),
13840 "right arrow reaches the first empty cell"
13841 );
13842 assert!(
13843 seen.contains(&c1),
13844 "right arrow reaches the second empty cell"
13845 );
13846 }
13847
13848 #[test]
13849 fn table_op_off_a_table_is_a_no_op_with_a_status() {
13850 let mut d = doc_with("tbl_none", "just text\n");
13851 d.caret = 3;
13852 d.table_insert_row(true);
13853 assert_eq!(d.source, "just text\n", "nothing changed");
13854 assert!(d.status.is_some(), "a status explains why");
13855 assert!(!d.caret_in_table());
13856 }
13857
13858 #[test]
13859 fn enter_in_an_ordered_list_renumbers_the_following_items() {
13860 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
13861 // the renumber pass keeps them sequential, matching what the view draws.
13862 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
13863 d.caret = d.source.find('a').unwrap() + 1; // end of item a
13864 d.newline();
13865 d.insert("x");
13866 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
13867 }
13868
13869 #[test]
13870 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
13871 for view in [View::Source, View::Wysiwyg] {
13872 let mut d = doc_in(view, "outdent_noop", "hello\n");
13873 d.caret = 2;
13874 d.outdent();
13875 assert_eq!(d.source, "hello\n");
13876 assert!(!d.dirty, "a no-op is not a modification");
13877 d.undo();
13878 assert_eq!(
13879 d.status.as_deref(),
13880 Some("nothing to undo"),
13881 "spends no undo step"
13882 );
13883 assert_eq!(d.source, "hello\n");
13884 }
13885 }
13886
13887 #[test]
13888 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
13889 for view in [View::Source, View::Wysiwyg] {
13890 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
13891 d.anchor = Some(0);
13892 d.caret = 7; // through "two"
13893 d.indent();
13894 assert_eq!(
13895 d.source, " one\n\n two\n",
13896 "the blank line keeps no trailing pad"
13897 );
13898 // Selected, so a second Tab lands on the same lines rather than on
13899 // whatever the shifted offsets now cover.
13900 assert_eq!(d.selection(), Some((0, 12)));
13901 d.indent();
13902 assert_eq!(d.source, " one\n\n two\n");
13903 }
13904 }
13905
13906 #[test]
13907 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
13908 for view in [View::Source, View::Wysiwyg] {
13909 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
13910 d.anchor = Some(0);
13911 d.caret = 15;
13912 d.outdent();
13913 assert_eq!(d.source, "two\none\nnone\n");
13914 }
13915 }
13916
13917 #[test]
13918 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
13919 for view in [View::Source, View::Wysiwyg] {
13920 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
13921 d.anchor = Some(0);
13922 d.caret = 7;
13923 d.indent();
13924 assert_eq!(d.source, " one\n\n two\n");
13925 d.undo();
13926 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
13927 assert_eq!(
13928 d.selection(),
13929 Some((0, 7)),
13930 "with the selection it was aimed at"
13931 );
13932 d.redo();
13933 assert_eq!(d.source, " one\n\n two\n");
13934 assert_eq!(
13935 d.selection(),
13936 Some((0, 12)),
13937 "redo replays the caret the indent placed, not the one splice left"
13938 );
13939 }
13940 }
13941
13942 #[test]
13943 fn vertical_motion_keeps_the_column() {
13944 let mut d = doc_with("move", "abcd\nef\n");
13945 d.caret = 3; // "abc|d" on row 0, col 3
13946 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
13947 assert_eq!(d.caret, 7); // just after "ef"
13948 }
13949
13950 // ── goal column ──────────────────────────────────────────────────────────
13951
13952 #[test]
13953 fn vertical_motion_goal_column_survives_a_short_line() {
13954 // Regression: re-deriving the column from the clamped position on
13955 // every step permanently forgets it once a short line clamps it.
13956 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
13957 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
13958 assert_eq!(
13959 g("abcd|ef\nxy\nghijkl\n", |d| {
13960 d.move_down(false); // clamps to end of "xy"
13961 d.move_down(false); // restores col 4 on the long line
13962 }),
13963 "abcdef\nxy\nghij|kl\n"
13964 );
13965 }
13966
13967 #[test]
13968 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
13969 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
13970 assert_eq!(d.goal_col, None);
13971 d.caret = 4; // row 0, col 4
13972 d.move_down(false); // clamps into "xy"; goal stays the original col
13973 assert_eq!(d.goal_col, Some(4));
13974 assert_eq!(d.caret_pos(), (1, 2));
13975
13976 // A horizontal motion drops the goal column...
13977 d.move_left(false);
13978 assert_eq!(d.goal_col, None);
13979
13980 // ...so the next vertical motion picks up the *new* column (1), not
13981 // the stale one (4).
13982 d.move_down(false);
13983 assert_eq!(d.goal_col, Some(1));
13984 assert_eq!(d.caret_pos(), (2, 1));
13985 }
13986
13987 #[test]
13988 fn editing_clears_the_goal_column() {
13989 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
13990 d.caret = 4;
13991 d.move_down(false);
13992 assert_eq!(d.goal_col, Some(4));
13993 d.insert("Z");
13994 assert_eq!(d.goal_col, None);
13995 }
13996
13997 #[test]
13998 fn vertical_motion_on_an_empty_document_is_a_no_op() {
13999 let mut d = doc_with("empty_vert", "");
14000 d.move_down(false);
14001 assert_eq!(d.caret, 0);
14002 d.move_up(false);
14003 assert_eq!(d.caret, 0);
14004 }
14005
14006 // ── the document's edges ─────────────────────────────────────────────────
14007
14008 #[test]
14009 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
14010 // The reproduction, and the disagreement: Down on the last line ran to
14011 // the end of the document in the source view — by accident, an
14012 // out-of-range row clamping to the end of the string — and did nothing
14013 // whatever in the view leaf opens in. One rule now, in both.
14014 for (view, tag) in VIEWS {
14015 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
14016 d.caret = 1;
14017 d.move_down(false);
14018 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
14019 d.move_up(false);
14020 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
14021 }
14022 }
14023
14024 #[test]
14025 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
14026 // Down off the bottom is a motion like any other, so it latches a goal
14027 // column — and Up comes back to the column the caret left, not to the
14028 // one the document's end happened to be in.
14029 for (view, tag) in VIEWS {
14030 let gap = if view == View::Source { "\n" } else { "\n\n" };
14031 let src = format!("abcdef{gap}ghijkl");
14032 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
14033 d.caret = 2; // row 0, col 2
14034 d.move_down(false);
14035 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
14036 d.move_down(false);
14037 assert_eq!(
14038 d.caret,
14039 src.len(),
14040 "{tag}: Down off the bottom reaches the end"
14041 );
14042 d.move_up(false);
14043 assert_eq!(
14044 d.caret_pos().1,
14045 2,
14046 "{tag}: Up returns to the column Down left"
14047 );
14048 }
14049 }
14050
14051 #[test]
14052 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
14053 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
14054 // Up that did nothing still armed a goal column, and the next Down aimed
14055 // at a column the caret had never been in.
14056 for (view, tag) in VIEWS {
14057 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
14058 d.caret = 0;
14059 d.move_up(false);
14060 assert_eq!(d.caret, 0, "{tag}: already at the start");
14061 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
14062
14063 d.caret = d.source.len();
14064 d.move_down(false);
14065 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
14066 assert_eq!(
14067 d.goal_col, None,
14068 "{tag}: a no-op Down latched a goal column"
14069 );
14070 }
14071 }
14072
14073 // ── soft wrap ────────────────────────────────────────────────────────────
14074 // Every other test here builds the map at 80 columns, where no fixture is
14075 // long enough to fold. A wrap is where one offset belongs to two rows at
14076 // once, and it broke everything that asks the caret what row it is on.
14077
14078 /// The wrapped fixture these cases share, folded at 12 columns into
14079 /// `one two ` / `three four ` / `five six ` / `seven eight`.
14080 fn wrapped_doc(name: &str) -> Doc {
14081 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
14082 d.build_visual(12);
14083 d
14084 }
14085
14086 #[test]
14087 fn home_and_end_work_from_a_wrapped_row() {
14088 // The reproduction: offset 19 is the `f` of "five", the first character
14089 // of the third row — and also the offset the second row ends at. It
14090 // resolved to the *second* row, so End aimed at a place the caret was
14091 // already in and did nothing, while Home walked backwards onto a row the
14092 // caret had left.
14093 let mut d = wrapped_doc("wrap_home_end");
14094 d.caret = 19;
14095 assert_eq!(
14096 d.caret_pos(),
14097 (2, 0),
14098 "the wrap boundary opens the third row"
14099 );
14100 d.move_end(false);
14101 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
14102 d.move_home(false);
14103 assert_eq!(d.caret, 19, "Home left the row the caret was on");
14104 }
14105
14106 #[test]
14107 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
14108 // The row's end is the last offset that is only ever its own: the offset
14109 // past it opens the row below, and aiming there would send a second
14110 // press on to *that* row's end, and a third to the next — End walking
14111 // down the paragraph rather than sitting where it landed.
14112 let mut d = wrapped_doc("wrap_end_twice");
14113 d.caret = 12; // inside "three", on the second row
14114 d.move_end(false);
14115 assert_eq!(
14116 d.caret, 18,
14117 "the end of `three four`, before the space the wrap ate"
14118 );
14119 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
14120 d.move_end(false);
14121 assert_eq!(d.caret, 18, "a second End moved the caret");
14122 d.move_home(false);
14123 assert_eq!(d.caret, 8, "Home takes the row's own start");
14124 }
14125
14126 #[test]
14127 fn vertical_motion_crosses_a_soft_wrap() {
14128 // Down aimed at the row below's column 0, an offset that resolved *up*
14129 // to the row above's end — so it landed on the offset it already had and
14130 // the caret could never leave a paragraph's first row.
14131 let mut d = wrapped_doc("wrap_down");
14132 d.caret = 0;
14133 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
14134 d.move_down(false);
14135 assert_eq!(d.caret, want, "Down stalled");
14136 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
14137 }
14138 d.move_down(false);
14139 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
14140
14141 // ...and back up, one row per press. The goal column is the end of the
14142 // last row, past every other row's width, so each press clamps to the
14143 // row's own last offset rather than to the one that opens the next.
14144 let mut d = wrapped_doc("wrap_up");
14145 d.caret = 39;
14146 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
14147 d.move_up(false);
14148 assert_eq!(d.caret, want, "Up stalled");
14149 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
14150 }
14151 }
14152
14153 #[test]
14154 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
14155 // The kills take the same line Home and End do, so in WYSIWYG they take
14156 // the visual row — and a soft wrap has no newline in it to delete, so
14157 // nothing is joined by reaching the end of one.
14158 let mut d = wrapped_doc("wrap_kill");
14159 d.caret = 19; // the `f` of "five", opening the third row
14160 d.delete_to_line_end();
14161 // The space the wrap ate goes with the row it was drawn on: sparing it
14162 // would leave "four seven", two spaces where the row had been.
14163 assert_eq!(d.source, "one two three four seven eight");
14164
14165 // Backwards from the row's last caret position — which is *before* that
14166 // space, so this one survives, being on the far side of the caret.
14167 let mut d = wrapped_doc("wrap_kill_back");
14168 d.caret = 27;
14169 d.delete_to_line_start();
14170 assert_eq!(d.source, "one two three four seven eight");
14171 }
14172
14173 // ── document start / end ────────────────────────────────────────────────
14174
14175 #[test]
14176 fn move_doc_start_and_end_jump_to_the_edges() {
14177 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
14178 assert_eq!(
14179 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
14180 "|hello\nworld\n"
14181 );
14182 assert_eq!(
14183 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
14184 "hello\nworld\n|"
14185 );
14186 // Already at the edge: a no-op.
14187 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
14188 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
14189 }
14190
14191 #[test]
14192 fn move_doc_start_and_end_extend_the_selection() {
14193 assert_eq!(
14194 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
14195 .move_doc_end(true)),
14196 "hello wor[ld\n|]"
14197 );
14198 assert_eq!(
14199 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
14200 .move_doc_start(true)),
14201 "[|hello wor]ld\n"
14202 );
14203 }
14204
14205 #[test]
14206 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
14207 let mut d = doc_with("empty_edges", "");
14208 d.move_doc_end(false);
14209 assert_eq!(d.caret, 0);
14210 d.move_doc_start(false);
14211 assert_eq!(d.caret, 0);
14212 }
14213
14214 // ── arrow collapses an active selection ─────────────────────────────────
14215
14216 #[test]
14217 fn arrow_collapses_selection_to_its_near_edge() {
14218 let mut d = doc_with("collapse", "hello world\n");
14219
14220 // Forward selection (anchor before caret): Right -> end, Left -> start.
14221 d.anchor = Some(2);
14222 d.caret = 7;
14223 d.move_right(false);
14224 assert_eq!((d.caret, d.anchor), (7, None));
14225
14226 d.anchor = Some(2);
14227 d.caret = 7;
14228 d.move_left(false);
14229 assert_eq!((d.caret, d.anchor), (2, None));
14230
14231 // Backward selection (anchor after caret): edges are the same
14232 // regardless of which end the caret started on.
14233 d.anchor = Some(7);
14234 d.caret = 2;
14235 d.move_right(false);
14236 assert_eq!((d.caret, d.anchor), (7, None));
14237
14238 d.anchor = Some(7);
14239 d.caret = 2;
14240 d.move_left(false);
14241 assert_eq!((d.caret, d.anchor), (2, None));
14242 }
14243
14244 #[test]
14245 fn arrow_with_extend_keeps_growing_the_selection() {
14246 let mut d = doc_with("collapse_extend", "hello world\n");
14247 d.anchor = Some(2);
14248 d.caret = 7;
14249 d.move_right(true); // extend: no collapse, caret steps one further
14250 assert_eq!((d.caret, d.anchor), (8, Some(2)));
14251 }
14252
14253 #[test]
14254 fn arrow_without_a_selection_moves_one_character_as_before() {
14255 let mut d = doc_with("no_collapse", "hello\n");
14256 d.caret = 2;
14257 d.move_right(false);
14258 assert_eq!(d.caret, 3);
14259 d.move_left(false);
14260 assert_eq!(d.caret, 2);
14261 }
14262
14263 /// Press Right until it stops, collecting the offsets walked through. Every
14264 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
14265 /// two stops sharing one source offset can't be moved between, so the caret
14266 /// stalls on the first of them and the walk never reaches the rest.
14267 fn walk_right(d: &mut Doc) -> Vec<usize> {
14268 let mut seen = vec![d.caret];
14269 for _ in 0..2000 {
14270 let before = d.caret;
14271 d.move_right(false);
14272 if d.caret == before {
14273 break;
14274 }
14275 seen.push(d.caret);
14276 }
14277 seen
14278 }
14279
14280 #[test]
14281 fn the_caret_crosses_a_soft_break() {
14282 // A newline inside a paragraph is a `soft_break`, which twig gives no
14283 // span of its own — the space it renders as used to borrow the offset of
14284 // the character before it, and a caret can't move without changing
14285 // offset. Right must walk clean off the end of the first line.
14286 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
14287 d.caret = 0;
14288 let seen = walk_right(&mut d);
14289 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14290 }
14291
14292 #[test]
14293 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
14294 // The paragraph holds one soft break. Folded (the default) it lays out as
14295 // a single reflowed row; Preserve re-lays it as a row per source line.
14296 // The setter must invalidate the cached map for the change to show, and
14297 // again on the way back — so a round trip returns to the folded layout.
14298 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
14299 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
14300 d.build_visual(80);
14301 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
14302
14303 d.set_line_flow(LineFlow::Preserve);
14304 d.build_visual(80);
14305 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
14306
14307 d.set_line_flow(LineFlow::Fold);
14308 d.build_visual(80);
14309 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
14310 }
14311
14312 #[test]
14313 fn the_caret_still_crosses_a_preserved_soft_break() {
14314 // Preserve renders the soft break as a row boundary rather than a space,
14315 // but the caret must still reach every offset — the break's own offset is
14316 // the first row's end stop, so Right walks clean off the end of line one
14317 // onto line two, exactly as it does when the break is folded.
14318 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
14319 d.set_line_flow(LineFlow::Preserve);
14320 d.build_visual(80);
14321 d.caret = 0;
14322 let seen = walk_right(&mut d);
14323 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14324 }
14325
14326 #[test]
14327 fn the_caret_walks_a_code_block() {
14328 // Every glyph of a code block used to map to the block's start, so the
14329 // whole block was a single offset and the caret couldn't move inside it.
14330 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
14331 let mut d = wysiwyg_doc("code_walk", src);
14332 d.caret = 0;
14333 let seen = walk_right(&mut d);
14334 // The fences are markup: hidden, and no caret stop. The code between
14335 // them is reached a character at a time.
14336 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
14337 for off in code.clone() {
14338 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
14339 }
14340 assert!(seen.contains(&code.end), "no stop after the last line");
14341 }
14342
14343 #[test]
14344 fn the_caret_walks_an_indented_code_block() {
14345 // An indented block's text has the four-space indent stripped, so it
14346 // isn't a verbatim slice and its lines have to be re-found. The caret
14347 // lands on the code, never in the indent.
14348 let src = " indented\n code\n";
14349 let mut d = wysiwyg_doc("indent_code_walk", src);
14350 d.caret = 0;
14351 let seen = walk_right(&mut d);
14352 assert!(seen.contains(&src.find("indented").unwrap()));
14353 assert!(seen.contains(&src.find("code").unwrap()));
14354 assert!(
14355 !seen.contains(&0) || seen[0] == 0,
14356 "the caret starts where it was put"
14357 );
14358 // Nothing in the stripped indent is a stop.
14359 for off in [1, 2, 3] {
14360 assert!(!seen.contains(&off), "landed in the indent at {off}");
14361 }
14362 }
14363
14364 #[test]
14365 fn the_caret_leaves_a_tight_heading() {
14366 // "# H" with text directly under it: the heading row's end and the
14367 // separator row's end are the same offset. Right used to find the
14368 // separator's copy, set the caret to where it already was, and stop.
14369 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
14370 d.caret = 2; // the "H"
14371 let seen = walk_right(&mut d);
14372 assert!(
14373 seen.len() > 2,
14374 "Right stalled at the heading's end: {seen:?}"
14375 );
14376 assert!(
14377 seen.contains(&8),
14378 "never reached the end of \"text\": {seen:?}"
14379 );
14380 }
14381
14382 #[test]
14383 fn the_caret_skips_the_gap_between_two_paragraphs() {
14384 // The blank line between two paragraphs is the boundary itself. The
14385 // caret used to be able to sit on it, and typing there landed in the
14386 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
14387 // soft break, so the text visibly snapped back up.
14388 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
14389 d.caret = 1; // the end of "A"
14390 d.move_right(false);
14391 assert_eq!(d.caret, 3, "Right stopped in the gap");
14392 d.insert("x");
14393 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
14394 }
14395
14396 #[test]
14397 fn down_from_a_paragraph_lands_on_the_next_one() {
14398 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
14399 d.caret = 0;
14400 d.move_down(false);
14401 assert_eq!(d.caret, 3, "Down stopped in the gap");
14402 }
14403
14404 #[test]
14405 fn clicking_the_gap_lands_on_real_text() {
14406 // A click can still *reach* the gap — it's drawn, so it's clickable.
14407 // It has to resolve to somewhere the caret can be.
14408 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
14409 d.click(1, 0, false); // the gap row
14410 assert!(
14411 d.caret == 1 || d.caret == 3,
14412 "click left the caret in the gap at {}",
14413 d.caret
14414 );
14415 d.insert("x");
14416 // Either edge of the boundary is a fair place to land; inside it isn't.
14417 assert!(
14418 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
14419 "click in the gap typed into the boundary: {:?}",
14420 d.source
14421 );
14422 }
14423
14424 #[test]
14425 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
14426 // Enter inserts a paragraph break, which leaves a blank line spare on
14427 // either side of a new one. That middle line is a real empty paragraph:
14428 // the caret lands there, and typing makes a paragraph rather than
14429 // extending a neighbour.
14430 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
14431 d.caret = 1;
14432 d.newline();
14433 assert_eq!(d.source, "A\n\n\n\nB\n");
14434 d.build_visual(80);
14435 let (row, _) = d.caret_pos();
14436 assert!(
14437 d.vmap.row_is_navigable(row),
14438 "the caret landed on a gap row"
14439 );
14440 d.insert("x");
14441 assert_eq!(
14442 d.source, "A\n\nx\n\nB\n",
14443 "the new paragraph merged into a neighbour"
14444 );
14445 }
14446
14447 #[test]
14448 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
14449 let mut d = wysiwyg_doc("gap_eof", "A\n");
14450 d.caret = 1;
14451 d.newline();
14452 d.build_visual(80);
14453 let (row, _) = d.caret_pos();
14454 assert!(
14455 d.vmap.row_is_navigable(row),
14456 "the caret landed on a gap row"
14457 );
14458 d.insert("x");
14459 assert!(
14460 d.source.starts_with("A\n\n") && d.source.contains('x'),
14461 "typing at the end merged into A: {:?}",
14462 d.source
14463 );
14464 }
14465
14466 #[test]
14467 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
14468 // A paragraph broken over two source lines is one paragraph. Selecting
14469 // it must not stop at the newline inside it — that newline is markup the
14470 // rich-text view exists to hide.
14471 let src = "one two\nthree four\n\nnext\n";
14472 let mut d = wysiwyg_doc("triple_para", src);
14473 d.select_block_at(2);
14474 assert_eq!(
14475 d.selected_text(),
14476 Some("one two\nthree four"),
14477 "stopped at the soft break"
14478 );
14479 }
14480
14481 #[test]
14482 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
14483 // The reader scrolls down past the caret's row. Nothing moved the
14484 // caret, so the view must stay where it was put — the old code revealed
14485 // the caret every frame, which dragged the view straight back and made
14486 // the document unscrollable past the caret.
14487 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
14488 d.caret = 0;
14489 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
14490 d.scroll = 4; // the wheel
14491 d.follow_caret(0, 3, 9);
14492 assert_eq!(
14493 d.scroll, 4,
14494 "the wheel was overruled by a caret that never moved"
14495 );
14496 }
14497
14498 #[test]
14499 fn moving_the_caret_brings_the_view_back_to_it() {
14500 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
14501 d.caret = 0;
14502 d.follow_caret(0, 3, 9);
14503 d.scroll = 6; // scrolled away
14504 d.move_right(false); // ...and now the caret moves
14505 let (row, _) = d.caret_pos();
14506 d.follow_caret(row, 3, 9);
14507 assert!(
14508 d.scroll <= row && row < d.scroll + 3,
14509 "caret row {row} off screen at scroll {}",
14510 d.scroll
14511 );
14512 }
14513
14514 #[test]
14515 fn scrolling_stops_at_the_last_row() {
14516 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
14517 d.caret = 0;
14518 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
14519 d.scroll = 999; // the wheel, spun hard
14520 d.follow_caret(0, 3, 3);
14521 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
14522 }
14523
14524 #[test]
14525 fn every_cell_of_a_wide_table_is_reachable() {
14526 // A table whose cells are far wider than the surface: the columns are
14527 // cut to fit and the text wraps inside them, so no cell hangs off the
14528 // right edge where the caret can never go.
14529 let src = "| Ingredient | Notes |\n|---|---|\n\
14530 | flour milled coarse | sift it twice before folding it in |\n";
14531 let mut d = wysiwyg_doc("wide_table_walk", src);
14532 d.build_visual(30);
14533 d.caret = 0;
14534 let seen = walk_right(&mut d);
14535 for word in ["Ingredient", "Notes", "coarse", "folding"] {
14536 let at = src.find(word).unwrap();
14537 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
14538 }
14539 }
14540
14541 // ── view parity ──────────────────────────────────────────────────────────
14542 // `doc_with` pins the source view, so everything above tests a view users
14543 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
14544 // deletion golden cases through *both*, plus the WYSIWYG cases the two
14545 // can't share: where the source carries markup the rendered text is a
14546 // different string, and the views agreeing would itself be the bug.
14547
14548 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
14549
14550 /// Run `action` in both views on one `|`-marked fixture and assert they
14551 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
14552 /// source verbatim, so the two views are looking at the same text and any
14553 /// disagreement is one of them having lost the plot.
14554 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
14555 let (src, caret) = parse_caret(marked);
14556 let run = |view: View, tag: &str| {
14557 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
14558 d.caret = caret;
14559 action(&mut d);
14560 render_caret(&d)
14561 };
14562 let source = run(VIEWS[0].0, VIEWS[0].1);
14563 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
14564 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
14565 source
14566 }
14567
14568 #[test]
14569 fn word_motion_agrees_across_the_views_on_plain_prose() {
14570 let g = both_views;
14571 assert_eq!(
14572 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
14573 "hello |world"
14574 );
14575 assert_eq!(
14576 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
14577 "|hello world"
14578 );
14579 assert_eq!(
14580 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
14581 "hello| world"
14582 );
14583 assert_eq!(
14584 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
14585 "hello world|"
14586 );
14587 assert_eq!(
14588 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
14589 "foo|.bar"
14590 );
14591 assert_eq!(
14592 g("par_ext", "hello |world", |d| d.move_word_right(true)),
14593 "hello [world|]"
14594 );
14595 }
14596
14597 #[test]
14598 fn word_deletion_agrees_across_the_views_on_plain_prose() {
14599 let g = both_views;
14600 assert_eq!(
14601 g("par_db", "hello world|", |d| d.delete_word_back()),
14602 "hello |"
14603 );
14604 assert_eq!(
14605 g("par_df", "hello |world", |d| d.delete_word_forward()),
14606 "hello |"
14607 );
14608 assert_eq!(
14609 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
14610 "|bar baz"
14611 );
14612 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
14613 }
14614
14615 #[test]
14616 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
14617 let g = both_views;
14618 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
14619 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
14620 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
14621 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
14622 }
14623
14624 #[test]
14625 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
14626 // The reproduction: the stop table was built one stop per `char`, so
14627 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
14628 // source view, which steps by grapheme, can't reach and backspace can't
14629 // survive. The two views must land on the same offset.
14630 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
14631 for (view, tag) in VIEWS {
14632 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
14633 d.caret = 1;
14634 d.move_right(false);
14635 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
14636
14637 // ...and the edit that used to sever a joiner off the front of it.
14638 d.backspace();
14639 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
14640 assert_eq!(d.caret, 1);
14641 }
14642 }
14643
14644 #[test]
14645 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
14646 for (view, tag) in VIEWS {
14647 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
14648 d.caret = 0;
14649 d.move_right(false);
14650 assert_eq!(
14651 d.caret,
14652 "e\u{0301}".len(),
14653 "{tag} stopped on the combining mark"
14654 );
14655 }
14656 }
14657
14658 #[test]
14659 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
14660 // The general form: whatever route the caret takes through a document
14661 // full of clusters, it never lands between the codepoints of one — so no
14662 // motion-then-backspace sequence can leave a dangling joiner behind.
14663 use unicode_segmentation::UnicodeSegmentation;
14664
14665 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
14666 let mut d = wysiwyg_doc("cluster_walk", src);
14667 d.caret = 0;
14668 let boundaries: Vec<usize> = src
14669 .grapheme_indices(true)
14670 .map(|(i, _)| i)
14671 .chain(std::iter::once(src.len()))
14672 .collect();
14673 for off in walk_right(&mut d) {
14674 assert!(
14675 boundaries.contains(&off),
14676 "Right stopped at {off}, inside a grapheme cluster"
14677 );
14678 }
14679 }
14680
14681 #[test]
14682 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
14683 // The reproduction: ⌥→ from inside the opening `**` computed its
14684 // boundary over the raw source and landed on byte 8 — inside the
14685 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
14686 // "bold". The caret drew past the bold word and sat inside it.
14687 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
14688 d.caret = 2;
14689 d.move_word_right(false);
14690 assert!(
14691 d.vmap.is_stop(d.caret),
14692 "landed at {}, not a caret stop",
14693 d.caret
14694 );
14695 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
14696 // The rendered row is "a bold c": column 6 is the space just past "bold",
14697 // and now the caret is really there rather than only drawn there.
14698 assert_eq!(d.caret_pos(), (0, 6));
14699
14700 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
14701 d.move_word_left(false);
14702 assert_eq!(d.caret, 4);
14703 assert_eq!(d.caret_pos(), (0, 2));
14704 }
14705
14706 #[test]
14707 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
14708 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
14709 // inside the closing `**`, and left "a ** c\n" — delimiters with no
14710 // opener. Glyph space covers the word alone, which would leave
14711 // "a **** c": markup wrapped around nothing. The word and the styling
14712 // that was only ever the word's go together.
14713 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
14714 d.caret = 10;
14715 d.delete_word_back();
14716 assert_eq!(d.source, "a c\n");
14717 assert_eq!(d.caret, 2);
14718
14719 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
14720 d.caret = 4; // the "b"
14721 d.delete_word_forward();
14722 assert_eq!(d.source, "a c\n");
14723 }
14724
14725 #[test]
14726 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
14727 let src = "a ***bold*** c\n";
14728 let mut d = wysiwyg_doc("wys_word_del_nest", src);
14729 d.caret = src.find(" c").unwrap();
14730 d.delete_word_back();
14731 assert_eq!(
14732 d.source, "a c\n",
14733 "the emph inside the strong empties it too"
14734 );
14735
14736 let src = "a `code` c\n";
14737 let mut d = wysiwyg_doc("wys_word_del_code", src);
14738 d.caret = src.find(" c").unwrap();
14739 d.delete_word_back();
14740 assert_eq!(d.source, "a c\n");
14741 }
14742
14743 #[test]
14744 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
14745 // Only an *emptied* node goes. Take one word of two and the `**` still
14746 // has a job to do — over the word that's left, with the space the delete
14747 // pushed against the opening delimiter moved out in front of it, or the
14748 // run would be no run at all (`** words**` is literal asterisks — see
14749 // the mark-edge rule on `splice`).
14750 let src = "a **two words** c\n";
14751 let mut d = wysiwyg_doc("wys_word_del_partial", src);
14752 d.caret = src.find(" words").unwrap();
14753 d.delete_word_back();
14754 assert_eq!(d.source, "a **words** c\n");
14755 }
14756
14757 #[test]
14758 fn source_view_word_motion_still_walks_the_markup() {
14759 // The other half of the decision: in the source view the `**` are
14760 // characters like any other — they're on the screen, so word motion has
14761 // to stop at them and a word-delete has to leave them behind. Only
14762 // WYSIWYG hides them, so only WYSIWYG steps over them.
14763 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
14764 assert_eq!(
14765 g("src_word_motion", "a |**bold** c\n", |d| d
14766 .move_word_right(false)),
14767 "a **bold|** c\n"
14768 );
14769 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
14770 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
14771 assert_eq!(
14772 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
14773 "a **| c\n"
14774 );
14775 }
14776
14777 #[test]
14778 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
14779 // The single invariant both bugs violated: the caret draws and edits at
14780 // the same place only when it's on a stop. `debug_assert_on_a_stop`
14781 // makes the same claim in-place; this pins it from the outside, over a
14782 // document with every kind of thing the map has to be careful about.
14783 // At two widths: the wide one every other test builds at, where no
14784 // fixture folds, and one narrow enough that they all do. A soft wrap is
14785 // where an offset stops being on exactly one row, and testing only the
14786 // width that never wraps is how the caret came to be pinned at the first
14787 // one Down reached.
14788 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
14789 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
14790 // A table of named operations, which is what it looks like.
14791 #[allow(clippy::type_complexity)]
14792 let motions: [(&str, fn(&mut Doc)); 8] = [
14793 ("right", |d| d.move_right(false)),
14794 ("left", |d| d.move_left(false)),
14795 ("word_right", |d| d.move_word_right(false)),
14796 ("word_left", |d| d.move_word_left(false)),
14797 ("down", |d| d.move_down(false)),
14798 ("up", |d| d.move_up(false)),
14799 ("home", |d| d.move_home(false)),
14800 ("end", |d| d.move_end(false)),
14801 ];
14802 for width in [80, 12] {
14803 let mut d = wysiwyg_doc("stop_invariant", src);
14804 d.build_visual(width);
14805 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14806 assert!(stops.len() > 20, "fixture should have plenty of stops");
14807 for start in stops {
14808 for (name, motion) in &motions {
14809 d.caret = start;
14810 d.anchor = None;
14811 motion(&mut d);
14812 assert!(
14813 d.vmap.is_stop(d.caret),
14814 "{name} from {start} at width {width} landed at {} — not a caret stop",
14815 d.caret
14816 );
14817 }
14818 }
14819 }
14820 }
14821
14822 #[test]
14823 fn no_wysiwyg_motion_is_a_dead_end() {
14824 // Down held to the bottom of a document reaches the bottom, and Up held
14825 // to the top reaches the top — from anywhere, at a width that wraps. The
14826 // invariant above says a motion lands somewhere legal; this one says it
14827 // gets somewhere at all, which is what a caret pinned at a wrap boundary
14828 // was quietly failing to do while every assertion around it held.
14829 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
14830 - item one two three four five\n\nlast\n";
14831 for width in [80, 12] {
14832 let mut d = wysiwyg_doc("no_dead_end", src);
14833 d.build_visual(width);
14834 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14835 let (first, last) = (stops[0], stops[stops.len() - 1]);
14836 for &start in &stops {
14837 for (name, motion, want) in [
14838 (
14839 "down",
14840 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
14841 last,
14842 ),
14843 ("up", |d: &mut Doc| d.move_up(false), first),
14844 ] {
14845 d.caret = start;
14846 d.anchor = None;
14847 d.goal_col = None;
14848 // Every row, plus the presses the edges take, plus slack.
14849 for _ in 0..d.vmap.num_rows() + 4 {
14850 motion(&mut d);
14851 }
14852 assert_eq!(
14853 d.caret, want,
14854 "{name} held from {start} at width {width} never arrived"
14855 );
14856 }
14857 }
14858 }
14859 }
14860 // ── display columns ──────────────────────────────────────────────────────
14861 // A `col` is a terminal cell, not a character. The two are the same number
14862 // for the ASCII the fixtures above are written in, which is how they came
14863 // apart in the first place: `你` is one character drawn in two cells, so a
14864 // column counted in characters names a cell the text isn't in — one earlier
14865 // for every wide character to its left.
14866
14867 #[test]
14868 fn a_wide_character_is_two_columns_wide() {
14869 // The reproduction: `你` is one char and two cells, so the caret just
14870 // past it drew at column 1 — inside the character it had already left.
14871 for (view, tag) in VIEWS {
14872 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
14873 d.caret = "你".len();
14874 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
14875 d.caret = "你好".len();
14876 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
14877 }
14878 }
14879
14880 #[test]
14881 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
14882 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
14883 // two-cell — measuring six cells one at a time, but the character they
14884 // spell is drawn in two. Width belongs to the cluster, not the glyph,
14885 // and the frontends measure it the same way.
14886 let family = "👨👩👧";
14887 for (view, tag) in VIEWS {
14888 let src = format!("a{family}b\n");
14889 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
14890 d.caret = 1 + family.len();
14891 assert_eq!(
14892 d.caret_pos(),
14893 (0, 3),
14894 "{tag}: 'a' is one cell, the family two"
14895 );
14896 }
14897 }
14898
14899 #[test]
14900 fn both_cells_of_a_wide_character_mean_the_character() {
14901 // Clicking the far half of `好` is still clicking `好`: half a character
14902 // is not a place the caret can be, so it comes to rest at the
14903 // character's start — the column it would have been drawn at anyway.
14904 for (view, tag) in VIEWS {
14905 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
14906 for col in [2, 3] {
14907 d.caret = 0;
14908 d.click(0, col, false);
14909 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
14910 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
14911 }
14912 // Past the last cell is the line's end, as it is for ASCII.
14913 d.click(0, 9, false);
14914 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
14915 }
14916 }
14917
14918 #[test]
14919 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
14920 // The mapping is only a mapping if it inverts: the cell the caret is
14921 // drawn in has to be the cell that brings it back to the same offset.
14922 // Over a fixture where a character may be one cell or two, and one
14923 // codepoint or five.
14924 use unicode_segmentation::UnicodeSegmentation;
14925
14926 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
14927
14928 let mut d = doc_in(View::Source, "roundtrip_source", src);
14929 // Every offset the source view's caret can occupy: it steps by grapheme
14930 // cluster, so those are its boundaries.
14931 for (off, _) in src
14932 .grapheme_indices(true)
14933 .chain(std::iter::once((src.len(), "")))
14934 {
14935 d.caret = off;
14936 let (row, col) = d.caret_pos();
14937 d.click(row, col, false);
14938 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
14939 }
14940
14941 // And in WYSIWYG, where the offsets the caret can occupy are the map's
14942 // stops rather than every boundary.
14943 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
14944 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14945 assert!(stops.len() > 20, "fixture should have plenty of stops");
14946 for off in stops {
14947 d.caret = off;
14948 let (row, col) = d.caret_pos();
14949 d.click(row, col, false);
14950 assert_eq!(
14951 d.caret, off,
14952 "wysiwyg: {off} → ({row}, {col}) → {}",
14953 d.caret
14954 );
14955 }
14956 }
14957
14958 #[test]
14959 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
14960 // Down from under `世` lands under the glyph in that cell, not two
14961 // characters further along the line. The goal is a column, so a line of
14962 // wide characters and a line of ASCII line up the way they're drawn.
14963 //
14964 // The gap differs by view: a bare newline inside a paragraph is a soft
14965 // break, which WYSIWYG draws as a space on a single row. The views share
14966 // a grid only where the source's lines are the renderer's rows too.
14967 for (view, tag) in VIEWS {
14968 let gap = if view == View::Source { "\n" } else { "\n\n" };
14969 let src = format!("你好世{gap}abcdef\n");
14970 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
14971 d.caret = "你好".len();
14972 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
14973 d.move_down(false);
14974 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
14975 assert!(
14976 d.source[d.caret..].starts_with('e'),
14977 "{tag}: landed on the wrong glyph"
14978 );
14979 }
14980 }
14981
14982 #[test]
14983 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
14984 // Down from column 3 onto `你好`, whose characters start at columns 0
14985 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
14986 // between the cells of one character, so the caret rests on it — and on
14987 // its start, which is the only offset there that is a caret stop.
14988 for (view, tag) in VIEWS {
14989 let gap = if view == View::Source { "\n" } else { "\n\n" };
14990 let src = format!("abcdef{gap}你好\n");
14991 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
14992 let line = src.find('你').unwrap();
14993 d.caret = 3;
14994 d.move_down(false);
14995 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
14996 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
14997 }
14998 }
14999
15000 #[test]
15001 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
15002 // The column the cell's text is laid out in is measured in cells, so the
15003 // caret walking that text has to be too — the two agreeing is the whole
15004 // point of the grid staying square.
15005 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
15006 let at = d.source.find("你").unwrap();
15007 d.caret = at;
15008 let (row, col) = d.caret_pos();
15009 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
15010 // cells further along.
15011 assert_eq!(col, 2, "the cell's first character");
15012 d.move_right(false);
15013 assert_eq!(
15014 d.caret_pos(),
15015 (row, 4),
15016 "`好` is drawn past `你`'s two cells"
15017 );
15018 assert_eq!(d.caret, at + "你".len());
15019 }
15020
15021 // ── active inline marks ───────────────────────────────────────────────────
15022
15023 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
15024 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
15025 let (src, caret) = parse_caret(marked);
15026 let mut d = doc_in(view, name, &src);
15027 d.caret = caret;
15028 d.active_inline_marks().iter().collect()
15029 }
15030
15031 /// The marks over the selection `[start, end)`.
15032 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
15033 let mut d = doc_in(view, name, src);
15034 d.anchor = Some(start);
15035 d.caret = end;
15036 d.active_inline_marks().iter().collect()
15037 }
15038
15039 #[test]
15040 fn a_caret_in_a_mark_reports_it() {
15041 for (view, tag) in VIEWS {
15042 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
15043 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
15044 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
15045 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
15046 // Plain text under no mark lights nothing — the toolbar's resting state.
15047 assert_eq!(m("a| **bold** b"), [], "{tag}");
15048 assert!(m("plain t|ext").is_empty(), "{tag}");
15049 }
15050 }
15051
15052 #[test]
15053 fn nested_marks_all_report() {
15054 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
15055 // the ancestor chain is a chain, and every mark on it is in force.
15056 for (view, tag) in VIEWS {
15057 assert_eq!(
15058 marks(
15059 view,
15060 &format!("marks_nested_{tag}"),
15061 "**bold and *bo|th*** end"
15062 ),
15063 [InlineKind::Strong, InlineKind::Emph],
15064 "{tag}"
15065 );
15066 }
15067 }
15068
15069 #[test]
15070 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
15071 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
15072 // the first byte of its text and the byte after its last — both inside
15073 // the mark's span, both places typing lands inside the bold. The offset
15074 // past the closing delimiter is the next text, and reports nothing.
15075 let src = "a **bold** b";
15076 let inner_start = src.find("bold").unwrap(); // 4
15077 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
15078 for (view, tag) in VIEWS {
15079 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
15080 for off in [2, 3, inner_start, inner_end, 9] {
15081 d.caret = off;
15082 assert!(
15083 d.active_inline_marks().contains(InlineKind::Strong),
15084 "{tag}: offset {off} is inside the strong span"
15085 );
15086 }
15087 for off in [0, 1, 10, 11, 12] {
15088 d.caret = off;
15089 assert!(
15090 !d.active_inline_marks().contains(InlineKind::Strong),
15091 "{tag}: offset {off} is outside the strong run"
15092 );
15093 }
15094 }
15095 }
15096
15097 #[test]
15098 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
15099 // Regression: twig resolves an offset that is one node's end and the
15100 // next one's start to the node that *starts* there, so `**bold**|\n`
15101 // isn't bold. With nothing following there's no tie to break and the
15102 // chain still ended at the mark, which made a trailing `\n` — not the
15103 // text — decide whether the caret after a bold word reported bold. It's
15104 // the offset past the mark either way, and typing there is plain either
15105 // way. A blank document typed into is exactly this shape.
15106 for (view, tag) in VIEWS {
15107 let m = |name: String, marked| marks(view, &name, marked);
15108 assert_eq!(
15109 m(format!("marks_eob_{tag}"), "**bold**|"),
15110 [],
15111 "{tag}: no trailing newline"
15112 );
15113 assert_eq!(
15114 m(format!("marks_eol_{tag}"), "**bold**|\n"),
15115 [],
15116 "{tag}: with one"
15117 );
15118 // And the last offset that *is* in the mark still is.
15119 assert_eq!(
15120 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
15121 [InlineKind::Strong],
15122 "{tag}"
15123 );
15124 }
15125 }
15126
15127 #[test]
15128 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
15129 let src = "a **bold** b";
15130 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
15131 for (view, tag) in VIEWS {
15132 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
15133 // The whole bold word, and a slice of it.
15134 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
15135 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
15136 // Ending exactly at the closing delimiter's start is still all-bold:
15137 // an exclusive end sits *past* the last selected character, so the
15138 // question is asked of the character, not the boundary.
15139 assert_eq!(
15140 m(b, d_ + 2),
15141 [InlineKind::Strong],
15142 "{tag}: through the close"
15143 );
15144 // Half in, half out: Bold lit here would claim a press turns it off.
15145 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
15146 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
15147 }
15148 }
15149
15150 #[test]
15151 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
15152 // Both ends are bold, but the space between them isn't — two runs are two
15153 // nodes, which is exactly what the node id catches and a kind-only
15154 // comparison would not.
15155 let src = "**one** **two**";
15156 for (view, tag) in VIEWS {
15157 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
15158 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
15159 }
15160 }
15161
15162 #[test]
15163 fn marks_read_the_document_as_it_is_edited() {
15164 // The point of asking twig every frame instead of caching: the answer has
15165 // to follow the toggle that changed it.
15166 let mut d = wysiwyg_doc("marks_live", "one two\n");
15167 d.anchor = Some(0);
15168 d.caret = 3;
15169 assert!(d.active_inline_marks().is_empty(), "plain to start");
15170 d.toggle(InlineKind::Strong);
15171 assert_eq!(d.source, "**one** two\n");
15172 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
15173 assert!(d.active_inline_marks().contains(InlineKind::Strong));
15174 d.toggle(InlineKind::Strong);
15175 assert!(d.active_inline_marks().is_empty(), "and off again");
15176 }
15177
15178 #[test]
15179 fn a_link_is_not_an_inline_mark() {
15180 // `link`/`str` are inline nodes, but nothing on the inline toolbar
15181 // toggles them — a set with a "link mark" in it would have no button.
15182 for (view, tag) in VIEWS {
15183 assert_eq!(
15184 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
15185 [],
15186 "{tag}"
15187 );
15188 }
15189 }
15190
15191 // ── blank documents ───────────────────────────────────────────────────────
15192
15193 #[test]
15194 fn a_blank_document_is_untitled_empty_and_markdown() {
15195 let mut d = Doc::blank().unwrap();
15196 assert!(d.is_untitled());
15197 assert_eq!(d.path, PathBuf::new());
15198 assert_eq!(
15199 d.file_name(),
15200 "untitled",
15201 "the header has to show something"
15202 );
15203 assert_eq!(d.format_name(), "markdown");
15204 assert_eq!(d.source, "");
15205 assert!(!d.dirty, "nothing typed yet is nothing to lose");
15206 assert_eq!(d.disk_state(), DiskState::Untitled);
15207 // And it's a document you can be in: the default view renders it.
15208 d.build_visual(80);
15209 assert_eq!(d.caret, 0);
15210 }
15211
15212 #[test]
15213 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
15214 let mut d = Doc::blank().unwrap();
15215 d.insert("hello");
15216 assert!(d.dirty);
15217 d.save();
15218 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
15219 assert!(d.dirty, "it must not come away believing it saved");
15220 assert!(d.is_untitled(), "and it still has no file");
15221 }
15222
15223 #[test]
15224 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
15225 let p = temp_path("blank_save_as");
15226 let mut d = Doc::blank().unwrap();
15227 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
15228 // be kept literal (`\#`); this test is about save-as, not escaping (which
15229 // has its own test), so it types nothing that escaping would touch.
15230 d.insert("hi");
15231 d.save_as(p.clone());
15232 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
15233 assert!(!d.is_untitled());
15234 assert!(!d.dirty);
15235 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
15236 assert_eq!(
15237 d.disk_state(),
15238 DiskState::Unchanged,
15239 "the watermark is stamped"
15240 );
15241 // And ⌘S is a plain save from here on.
15242 d.insert("!");
15243 d.save();
15244 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
15245 let _ = std::fs::remove_file(&p);
15246 }
15247
15248 // ── a file that isn't there yet ───────────────────────────────────────────
15249
15250 /// A unique path in the temp dir with the given extension, guaranteed not to
15251 /// exist — what `leaf notes.md` is handed when the file has never been made.
15252 fn missing_path(name: &str, ext: &str) -> PathBuf {
15253 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15254 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15255 let mut p = std::env::temp_dir();
15256 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
15257 let _ = std::fs::remove_file(&p);
15258 p
15259 }
15260
15261 #[test]
15262 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
15263 let p = missing_path("named", "md");
15264 let mut d = Doc::open_or_create(p.clone()).unwrap();
15265
15266 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
15267 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
15268 assert!(
15269 !d.is_untitled(),
15270 "it has the name the user asked for — ^S must not detour to Save As"
15271 );
15272 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
15273 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
15274 assert!(!p.exists(), "and opening it wrote nothing");
15275 // And it's a document you can be in.
15276 d.build_visual(80);
15277 assert_eq!(d.caret, 0);
15278 }
15279
15280 #[test]
15281 fn a_new_file_is_created_by_its_first_save() {
15282 let p = missing_path("first_save", "md");
15283 let mut d = Doc::open_or_create(p.clone()).unwrap();
15284 d.insert("hello\n");
15285 assert!(d.dirty);
15286 d.save();
15287
15288 assert_eq!(
15289 std::fs::read_to_string(&p).unwrap(),
15290 "hello\n",
15291 "a plain ^S wrote it — no Save As, no name to invent"
15292 );
15293 assert!(!d.dirty);
15294 assert_eq!(d.disk_state(), DiskState::Unchanged);
15295 let _ = std::fs::remove_file(&p);
15296 }
15297
15298 #[test]
15299 fn a_new_file_takes_its_format_from_the_extension() {
15300 // The one thing `blank` can't do: with no name it has to assume Markdown,
15301 // and typing djot into a Markdown parse is the wrong buffer.
15302 let dj = missing_path("format", "dj");
15303 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
15304 let md = missing_path("format", "md");
15305 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
15306 }
15307
15308 #[test]
15309 fn a_new_file_reports_itself_missing_until_it_is_saved() {
15310 // Not `Untitled` — that's the answer for a document with no path, and it
15311 // would tell a frontend there is nothing a save could collide with. Here
15312 // there is a path, and the file simply isn't at it yet.
15313 let p = missing_path("disk_state", "md");
15314 let mut d = Doc::open_or_create(p.clone()).unwrap();
15315 assert_eq!(d.disk_state(), DiskState::Missing);
15316
15317 // Somebody else creates it while the buffer is open: that's an overwrite
15318 // the frontend has to be able to prompt about, exactly as for an opened
15319 // file. Their bytes, not ours, so `Changed`.
15320 std::fs::write(&p, "theirs\n").unwrap();
15321 assert_eq!(d.disk_state(), DiskState::Changed);
15322
15323 // Saving makes the file ours and re-stamps the watermark.
15324 d.insert("ours\n");
15325 d.save();
15326 assert_eq!(d.disk_state(), DiskState::Unchanged);
15327 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
15328 let _ = std::fs::remove_file(&p);
15329 }
15330
15331 #[test]
15332 fn open_or_create_still_opens_a_file_that_is_there() {
15333 let d = doc_with("open_or_create_existing", "body\n");
15334 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
15335 assert_eq!(reopened.source, "body\n");
15336 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
15337 }
15338
15339 #[test]
15340 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
15341 // A mistyped flag or a stray argument must not become a buffer promising
15342 // to save somewhere — the same refusal `open` gives a real file.
15343 let mut p = std::env::temp_dir();
15344 p.push("leaf_test_new_bad_ext.wat");
15345 assert!(Doc::open_or_create(p).is_err());
15346 let mut none = std::env::temp_dir();
15347 none.push("leaf_test_new_no_ext");
15348 assert!(Doc::open_or_create(none).is_err());
15349 }
15350
15351 #[test]
15352 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
15353 // Opening reads nothing, so there is nothing to fail on yet; the write is
15354 // where it fails, and it says so rather than claiming a save.
15355 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
15356 let mut d = Doc::open_or_create(p).unwrap();
15357 d.insert("x");
15358 d.save();
15359 assert!(
15360 d.status.as_deref().unwrap().starts_with("save failed:"),
15361 "got {:?}",
15362 d.status
15363 );
15364 assert!(d.dirty, "it must not come away believing it saved");
15365 }
15366
15367 // ── save as ───────────────────────────────────────────────────────────────
15368
15369 /// A unique path in the temp dir that no fixture wrote — a Save As target.
15370 fn temp_path(name: &str) -> PathBuf {
15371 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15372 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15373 let mut p = std::env::temp_dir();
15374 p.push(format!("leaf_test_target_{name}_{seq}.md"));
15375 let _ = std::fs::remove_file(&p);
15376 p
15377 }
15378
15379 #[test]
15380 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
15381 let mut d = doc_with("save_as_move", "original\n");
15382 let old = d.path.clone();
15383 let new = temp_path("save_as_move");
15384 d.insert("edited: ");
15385 d.save_as(new.clone());
15386
15387 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
15388 assert_eq!(
15389 std::fs::read_to_string(&old).unwrap(),
15390 "original\n",
15391 "Save As doesn't touch the file it came from"
15392 );
15393 assert_eq!(d.path, new, "the document moved");
15394 assert!(!d.dirty);
15395 assert_eq!(
15396 d.status.as_deref(),
15397 Some(&*format!("saved {}", d.file_name()))
15398 );
15399
15400 // Every later save follows it, which is the whole difference from a copy.
15401 d.caret = 0;
15402 d.insert("re-");
15403 d.save();
15404 assert_eq!(
15405 std::fs::read_to_string(&new).unwrap(),
15406 "re-edited: original\n"
15407 );
15408 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
15409 let _ = std::fs::remove_file(&new);
15410 }
15411
15412 #[test]
15413 fn save_as_overwrites_an_existing_target() {
15414 // The picker already asked; asking again down here is the same question
15415 // twice, and the second one has no way to be answered.
15416 let new = temp_path("save_as_over");
15417 std::fs::write(&new, "theirs\n").unwrap();
15418 let mut d = doc_with("save_as_over", "ours\n");
15419 d.save_as(new.clone());
15420 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
15421 let _ = std::fs::remove_file(&new);
15422 }
15423
15424 #[test]
15425 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
15426 let mut d = doc_with("save_as_fail", "body\n");
15427 let old = d.path.clone();
15428 d.insert("x");
15429 // A directory that doesn't exist: the write can't land.
15430 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
15431 d.save_as(bad);
15432
15433 assert_eq!(
15434 d.path, old,
15435 "the document must not move to a file that isn't there"
15436 );
15437 assert!(d.dirty, "and must not believe it saved");
15438 assert!(
15439 d.status.as_deref().unwrap().starts_with("save failed:"),
15440 "the same failure a plain save reports, got {:?}",
15441 d.status
15442 );
15443 // The original is still the document's file, and still saveable.
15444 d.save();
15445 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
15446 assert!(!d.dirty);
15447 }
15448
15449 #[test]
15450 fn save_as_renames_without_reparsing_the_format() {
15451 // `.dj` on the name doesn't make the buffer djot: it was parsed as
15452 // Markdown and still is, and saying otherwise would be a conversion the
15453 // user never asked for (and an undo history thrown away to do it).
15454 let mut d = doc_with("save_as_format", "**b**\n");
15455 let mut new = temp_path("save_as_format");
15456 new.set_extension("dj");
15457 d.save_as(new.clone());
15458 assert_eq!(d.format_name(), "markdown");
15459 let _ = std::fs::remove_file(&new);
15460 }
15461
15462 // ── external change / reload ──────────────────────────────────────────────
15463
15464 #[test]
15465 fn an_untouched_file_reports_unchanged() {
15466 let mut d = doc_with("disk_clean", "body\n");
15467 assert_eq!(d.disk_state(), DiskState::Unchanged);
15468 // Editing the buffer is not editing the file.
15469 d.insert("x");
15470 assert_eq!(d.disk_state(), DiskState::Unchanged);
15471 assert!(d.dirty);
15472 // Saving re-stamps the watermark rather than reporting our own bytes back.
15473 d.save();
15474 assert_eq!(d.disk_state(), DiskState::Unchanged);
15475 }
15476
15477 #[test]
15478 fn a_file_written_underneath_reports_changed() {
15479 let mut d = doc_with("disk_changed", "body\n");
15480 std::fs::write(&d.path, "someone else\n").unwrap();
15481 assert_eq!(d.disk_state(), DiskState::Changed);
15482 // Dirty *and* changed is the clobber: both halves are readable, and
15483 // leaf-core takes neither side.
15484 d.insert("x");
15485 assert!(d.dirty && d.disk_state() == DiskState::Changed);
15486 // Saving anyway is allowed — the frontend asked, or chose not to.
15487 d.save();
15488 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
15489 assert_eq!(d.disk_state(), DiskState::Unchanged);
15490 }
15491
15492 #[test]
15493 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
15494 // The hash is what makes this honest: the file was written (a fresh
15495 // mtime), and nothing about the document is stale.
15496 let d = doc_with("disk_same_bytes", "body\n");
15497 std::fs::write(&d.path, "body\n").unwrap();
15498 assert_eq!(d.disk_state(), DiskState::Unchanged);
15499 }
15500
15501 #[test]
15502 fn a_deleted_file_reports_missing() {
15503 let mut d = doc_with("disk_missing", "body\n");
15504 std::fs::remove_file(&d.path).unwrap();
15505 assert_eq!(d.disk_state(), DiskState::Missing);
15506 // A save recreates it, and the document is whole again.
15507 d.save();
15508 assert_eq!(d.disk_state(), DiskState::Unchanged);
15509 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
15510 }
15511
15512 #[test]
15513 fn reload_replaces_the_document_with_the_file() {
15514 for (view, tag) in VIEWS {
15515 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
15516 d.insert("edited ");
15517 assert!(d.dirty);
15518 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15519 d.reload();
15520
15521 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
15522 assert!(!d.dirty, "{tag}: the file is what we have");
15523 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
15524 assert_eq!(
15525 d.status.as_deref(),
15526 Some(&*format!("reloaded {}", d.file_name()))
15527 );
15528 // The reloaded tree is live, not the old parse.
15529 d.caret = d.source.find("three").unwrap();
15530 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
15531 }
15532 }
15533
15534 #[test]
15535 fn reload_clamps_the_caret_and_drops_the_selection() {
15536 let mut d = doc_with("reload_caret", "a long first line\n");
15537 d.caret = 12;
15538 d.anchor = Some(4);
15539 std::fs::write(&d.path, "short\n").unwrap();
15540 d.reload();
15541 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
15542 assert_eq!(
15543 d.anchor, None,
15544 "a selection over bytes that changed is a lie"
15545 );
15546 assert!(d.selection().is_none());
15547
15548 // A caret the file still has room for stays put.
15549 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
15550 d.caret = 2;
15551 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15552 d.reload();
15553 assert_eq!(d.caret, 2);
15554 }
15555
15556 /// A silent reload is something that happened *to* a reader — a formatter,
15557 /// a `git checkout` — so it has to be undoable like anything else that
15558 /// changes the document, and undoable as one step rather than as however
15559 /// many the file happens to differ by.
15560 #[test]
15561 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
15562 let mut d = doc_with("reload_undo", "body\n");
15563 d.insert("x");
15564 assert_eq!(d.source, "xbody\n");
15565 std::fs::write(&d.path, "replaced\n").unwrap();
15566 d.reload();
15567 assert_eq!(d.source, "replaced\n");
15568 assert!(!d.dirty, "a reload lands clean");
15569
15570 // One ^Z takes the whole swap off, and hands back the unsaved work it
15571 // replaced — which is unsaved again, because the file no longer says it.
15572 d.undo();
15573 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
15574 assert!(d.dirty, "and what it comes back to is unsaved");
15575 // …and the history under it is still there.
15576 d.undo();
15577 assert_eq!(
15578 d.source, "body\n",
15579 "the typing before the reload undoes too"
15580 );
15581 // Redo walks back up through the reload.
15582 d.redo();
15583 d.redo();
15584 assert_eq!(d.source, "replaced\n");
15585 }
15586
15587 /// A file rewritten with the bytes it already had is not an edit, so it
15588 /// must not leave an undo step behind for something nobody did.
15589 #[test]
15590 fn reloading_identical_bytes_pushes_no_undo_step() {
15591 let mut d = doc_with("reload_same", "body\n");
15592 d.insert("x");
15593 std::fs::write(&d.path, "xbody\n").unwrap();
15594 d.reload();
15595 assert_eq!(d.source, "xbody\n");
15596 assert!(!d.dirty, "the file now says what the buffer does");
15597 d.undo();
15598 assert_eq!(
15599 d.source, "body\n",
15600 "one step back is the typing, not a no-op"
15601 );
15602 }
15603
15604 #[test]
15605 fn a_reload_that_cant_read_leaves_the_document_alone() {
15606 let mut d = doc_with("reload_gone", "body\n");
15607 d.insert("x");
15608 std::fs::remove_file(&d.path).unwrap();
15609 d.reload();
15610 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
15611 assert!(d.dirty);
15612 assert!(
15613 d.status.as_deref().unwrap().starts_with("reload failed:"),
15614 "{:?}",
15615 d.status
15616 );
15617
15618 // And an untitled document has nothing to reload from.
15619 let mut d = Doc::blank().unwrap();
15620 d.insert("typed");
15621 d.reload();
15622 assert_eq!(d.source, "typed");
15623 assert_eq!(d.status.as_deref(), Some("no file to reload"));
15624 }
15625
15626 #[test]
15627 fn a_read_only_document_refuses_every_door() {
15628 let mut d = doc_with("readonly", "one two three\n");
15629 d.insert("x");
15630 assert!(d.dirty, "writable first, so the undo step exists");
15631 d.set_read_only(true);
15632 let before = d.source.clone();
15633 d.insert("y");
15634 d.backspace();
15635 d.undo();
15636 d.redo();
15637 assert_eq!(d.source, before, "no door moved a byte");
15638 d.set_read_only(false);
15639 d.undo();
15640 assert_ne!(d.source, before, "off again, the same doors work");
15641 }
15642
15643 /// The doors that go to twig's own verbs rather than through the splice.
15644 /// Typed text in the rendered view under the default markup mode is the
15645 /// everyday one — it is what a keystroke in leaf-web or the Apple views
15646 /// becomes — and it walked straight past the gate.
15647 #[test]
15648 fn a_read_only_document_refuses_the_doors_around_the_splice() {
15649 let mut d = wysiwyg_doc(
15650 "readonly-doors",
15651 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
15652 );
15653 d.set_markup_mode(MarkupMode::None);
15654 d.set_read_only(true);
15655 let before = d.source.clone();
15656 d.place_caret(3, false);
15657 d.insert("y");
15658 d.insert_link("https://example.com");
15659 d.insert_image("a.png", "alt");
15660 d.insert_thematic_break();
15661 d.insert_footnote();
15662 d.place_caret(0, false);
15663 d.place_caret(3, true);
15664 d.toggle(InlineKind::Strong);
15665 d.toggle_heading(2);
15666 d.set_block(BlockKind::Paragraph);
15667 d.toggle_list(false);
15668 d.toggle_blockquote();
15669 d.toggle_task_item();
15670 d.newline();
15671 d.indent();
15672 d.set_code_language("rust");
15673 let in_cell = d.source.find("| c").unwrap() + 2;
15674 d.place_caret(in_cell, false);
15675 assert!(d.caret_in_table(), "the caret is in the grid");
15676 assert!(!d.cell_line_break(), "the cell break reports the refusal");
15677 assert_eq!(d.source, before, "no door moved a byte");
15678 assert!(!d.dirty, "nothing to save");
15679 d.set_read_only(false);
15680 d.place_caret(3, false);
15681 d.insert("y");
15682 assert_ne!(d.source, before, "off again, the same doors work");
15683 }
15684
15685 #[test]
15686 fn a_selection_quote_carries_its_context_on_char_boundaries() {
15687 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
15688 let start = d.source.find("exact").unwrap();
15689 d.place_caret(start, false);
15690 d.place_caret(start + "exact".len(), true);
15691 let q = d.selection_quote(3).unwrap();
15692 assert_eq!(q.exact, "exact");
15693 assert_eq!(
15694 q.prefix, "你好 ",
15695 "chars, not bytes — the multibyte pair counts as two"
15696 );
15697 assert_eq!(q.suffix, " 世界");
15698 assert_eq!(&d.source[q.start..q.end], "exact");
15699 // At the edges the context clips rather than erring.
15700 d.place_caret(0, false);
15701 d.place_caret(6, true);
15702 let q = d.selection_quote(40).unwrap();
15703 assert_eq!(q.prefix, "");
15704 assert_eq!(q.exact, "before");
15705 // No selection is no quote.
15706 d.place_caret(0, false);
15707 assert!(d.selection_quote(3).is_none());
15708 }
15709
15710 #[test]
15711 fn highlights_are_kept_sorted_and_answer_point_queries() {
15712 let mut d = doc_with("hl", "one two three\n");
15713 d.set_highlights(vec![
15714 Highlight {
15715 start: 8,
15716 end: 13,
15717 id: "b".into(),
15718 color: None,
15719 marker: None,
15720 },
15721 Highlight {
15722 start: 0,
15723 end: 3,
15724 id: "a".into(),
15725 color: Some("#ffe066".into()),
15726 marker: None,
15727 },
15728 Highlight {
15729 start: 5,
15730 end: 5,
15731 id: "empty".into(),
15732 color: None,
15733 marker: None,
15734 },
15735 ]);
15736 assert_eq!(
15737 d.highlights()
15738 .iter()
15739 .map(|h| h.id.as_str())
15740 .collect::<Vec<_>>(),
15741 ["a", "b"],
15742 "sorted by start, the empty range dropped"
15743 );
15744 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
15745 assert_eq!(d.highlight_at(3), None, "end is exclusive");
15746 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
15747 d.set_highlights(Vec::new());
15748 assert!(d.highlights().is_empty(), "a replace is a replace");
15749 }
15750
15751 /// `Highlight::covering` and the cursor over it are what both painters ask
15752 /// per glyph, so they have to answer the same as the scan they replaced —
15753 /// including in the gaps, which is where most glyphs are.
15754 #[test]
15755 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
15756 let hl = |start: usize, end: usize, id: &str| Highlight {
15757 start,
15758 end,
15759 id: id.into(),
15760 color: None,
15761 marker: None,
15762 };
15763 // Disjoint, as search hits are: in a range, in a gap, and past the end.
15764 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
15765 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
15766 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
15767 assert_eq!(
15768 Highlight::covering(&hits, 105),
15769 None,
15770 "a gap covers nothing"
15771 );
15772 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
15773 assert_eq!(Highlight::covering(&hits, 9_999), None);
15774 assert_eq!(Highlight::covering(&[], 0), None);
15775
15776 // Nested: first by start, so a hit inside an annotation still resolves
15777 // to the annotation — and the range that stops short doesn't mask it.
15778 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
15779 assert_eq!(
15780 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
15781 Some("outer")
15782 );
15783 assert_eq!(
15784 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
15785 Some("outer")
15786 );
15787 }
15788
15789 /// The cursor is an optimisation, so the only thing worth asserting is that
15790 /// it is not also a change of answer — at every offset, over a list with a
15791 /// nest in it, walked forwards and then backwards.
15792 #[test]
15793 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
15794 let hl = |start: usize, end: usize, id: &str| Highlight {
15795 start,
15796 end,
15797 id: id.into(),
15798 color: None,
15799 marker: None,
15800 };
15801 let mut list = vec![
15802 hl(0, 20, "outer"),
15803 hl(5, 10, "inner"),
15804 hl(30, 33, "hit"),
15805 hl(40, 43, "hit"),
15806 ];
15807 list.sort_by_key(|h| (h.start, h.end));
15808
15809 let mut cursor = HighlightCursor::new(&list);
15810 for offset in 0..50 {
15811 assert_eq!(
15812 cursor.at(offset).map(|h| h.id.as_str()),
15813 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15814 "cursor disagrees at {offset}"
15815 );
15816 }
15817 // Backwards: the cursor re-seats rather than answering from where it
15818 // had got to, so a painter that revisits a row is still told the truth.
15819 for offset in (0..50).rev() {
15820 assert_eq!(
15821 cursor.at(offset).map(|h| h.id.as_str()),
15822 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15823 "cursor disagrees walking back at {offset}"
15824 );
15825 }
15826 }
15827
15828 // ── the presentation vocabulary ─────────────────────────────────────────
15829
15830 /// A document in `format`, for the gesture tests that want more than the
15831 /// Markdown `doc_with` writes.
15832 fn fmt_doc(body: &str, format: Format) -> Doc {
15833 Doc::from_source(body.to_string(), format).unwrap()
15834 }
15835
15836 /// Alignment is a block property, so the gesture is `set_block_attrs` on
15837 /// the caret's block whatever is selected — and each format spells it its
15838 /// own way: djot's `{…}` line above the block, a `<div>` around it in
15839 /// Markdown (the format has nowhere else to put it), the tag in HTML.
15840 #[test]
15841 fn set_alignment_spells_the_class_the_format_s_own_way() {
15842 let mut dj = fmt_doc("hello\n", Format::Djot);
15843 dj.caret = 1;
15844 dj.set_alignment(Some(Align::Center));
15845 assert_eq!(dj.source, "{.center}\nhello\n");
15846 assert!(dj.dirty);
15847 assert_eq!(dj.status, None);
15848
15849 let mut md = fmt_doc("hello\n", Format::Markdown);
15850 md.caret = 1;
15851 md.set_alignment(Some(Align::Right));
15852 assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
15853
15854 let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
15855 html.caret = html.source.find("hello").unwrap();
15856 html.set_alignment(Some(Align::Justify));
15857 assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
15858 }
15859
15860 /// Each gesture edits **one key and keeps the rest** — twig's contract is
15861 /// replace-not-merge, so leaf reads the node's attributes, edits its own
15862 /// key out of them, and passes the list back whole. A document from
15863 /// elsewhere passes through the editor unharmed.
15864 #[test]
15865 fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
15866 let mut d = fmt_doc(
15867 "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
15868 Format::Djot,
15869 );
15870 d.caret = d.source.find("hello").unwrap();
15871 d.set_alignment(Some(Align::Right));
15872 // `center` goes, `lead` stays, and neither the id nor the spacing is
15873 // touched.
15874 // The serializer picks the order; what matters is which keys survive.
15875 assert!(d.source.contains(".lead"), "{:?}", d.source);
15876 assert!(d.source.contains(".right"), "{:?}", d.source);
15877 assert!(!d.source.contains(".center"), "{:?}", d.source);
15878 assert!(d.source.contains("#intro"), "{:?}", d.source);
15879 assert!(
15880 d.source.contains("data-line-height=\"1.5\""),
15881 "{:?}",
15882 d.source
15883 );
15884 assert_eq!(d.alignment_at_caret(), Some(Align::Right));
15885 assert_eq!(
15886 d.line_spacing_at_caret(),
15887 Some(LineHeight::Step(LineSpacing::OneHalf))
15888 );
15889
15890 // And the other way round: the spacing gesture leaves the classes be.
15891 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
15892 assert!(d.source.contains(".lead"), "{:?}", d.source);
15893 assert!(d.source.contains(".right"), "{:?}", d.source);
15894 assert_eq!(
15895 d.line_spacing_at_caret(),
15896 Some(LineHeight::Step(LineSpacing::Double))
15897 );
15898 }
15899
15900 /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
15901 /// owns go out of `class`, and a block left with nothing at all is spelled
15902 /// bare again — in Markdown by unwrapping the div twig wrapped it in.
15903 #[test]
15904 fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
15905 let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
15906 dj.caret = dj.source.find("hello").unwrap();
15907 dj.set_alignment(None);
15908 assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
15909 assert_eq!(dj.alignment_at_caret(), None);
15910
15911 let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
15912 bare.caret = bare.source.find("hello").unwrap();
15913 bare.set_alignment(None);
15914 assert_eq!(
15915 bare.source, "hello\n",
15916 "the last key takes the line with it"
15917 );
15918
15919 let mut md = fmt_doc("hello\n", Format::Markdown);
15920 md.caret = 1;
15921 md.set_alignment(Some(Align::Center));
15922 assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
15923 md.caret = md.source.find("hello").unwrap();
15924 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
15925 assert_eq!(
15926 md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
15927 "the second key rewrites the div rather than nesting a second"
15928 );
15929 md.caret = md.source.find("hello").unwrap();
15930 md.set_alignment(None);
15931 md.caret = md.source.find("hello").unwrap();
15932 md.set_line_spacing(None);
15933 assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
15934 }
15935
15936 /// Size, face and colour are the run's over a selection and the block's
15937 /// with none — so "make this paragraph larger" is a click with the caret in
15938 /// it rather than a select-all first.
15939 #[test]
15940 fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
15941 // With a selection: a span, in each format's own spelling.
15942 let mut dj = fmt_doc("a big b\n", Format::Djot);
15943 dj.anchor = Some(2);
15944 dj.caret = 5;
15945 dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
15946 assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
15947 assert_eq!(
15948 dj.font_size_at_caret(),
15949 Some(FontSize::Step(SizeStep::Large))
15950 );
15951
15952 let mut md = fmt_doc("a big b\n", Format::Markdown);
15953 md.anchor = Some(2);
15954 md.caret = 5;
15955 md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
15956 assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
15957 assert_eq!(
15958 md.text_color_at_caret(),
15959 Some(TextColor::Named(MarkColor::Blue))
15960 );
15961
15962 // Without one: the caret's block, through the block gesture.
15963 let mut block = fmt_doc("a big b\n", Format::Djot);
15964 block.caret = 3;
15965 block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
15966 assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
15967 assert_eq!(
15968 block.font_family_at_caret(),
15969 Some(FontFace::Generic(FontFamily::Monospace))
15970 );
15971 }
15972
15973 /// The *Other…* row of each of the four menus: a value goes into the
15974 /// document in its canonical spelling and comes back out of the query as
15975 /// the same value. One round trip per property, because the four go out
15976 /// through different doors — two block gestures, and the run three through
15977 /// the span that `wrap_range_attrs` mints.
15978 #[test]
15979 fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
15980 // Size: the run three, over a selection.
15981 let mut d = fmt_doc("a big b\n", Format::Djot);
15982 d.anchor = Some(2);
15983 d.caret = 5;
15984 d.set_font_size(FontSize::points(14.0));
15985 assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
15986 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
15987
15988 // Colour, onto the same span — the gesture keeps the size it finds.
15989 d.set_text_color(Some(TextColor::Rgb {
15990 r: 0xc0,
15991 g: 0x30,
15992 b: 0x30,
15993 }));
15994 assert_eq!(
15995 d.source,
15996 "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
15997 );
15998 assert_eq!(
15999 d.text_color_at_caret(),
16000 Some(TextColor::Rgb {
16001 r: 0xc0,
16002 g: 0x30,
16003 b: 0x30
16004 })
16005 );
16006
16007 // Face: a family name, as given.
16008 d.set_font_family(Some(FontFace::Named("Garamond".into())));
16009 assert!(
16010 d.source.contains("data-font=\"Garamond\""),
16011 "{:?}",
16012 d.source
16013 );
16014 assert_eq!(
16015 d.font_family_at_caret(),
16016 Some(FontFace::Named("Garamond".into()))
16017 );
16018
16019 // Line spacing: a block gesture, and an exact ratio.
16020 let mut block = fmt_doc("hello\n", Format::Djot);
16021 block.caret = 1;
16022 block.set_line_spacing(LineHeight::ratio(1.3));
16023 assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
16024 assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
16025
16026 // And a value spelled long is written back short, so the same press
16027 // twice writes the same bytes: `14.0pt` in, `14pt` out.
16028 let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
16029 long.caret = long.source.find("hello").unwrap();
16030 assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
16031 let in_force = long.font_size_at_caret();
16032 long.set_font_size(in_force);
16033 assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
16034 }
16035
16036 /// A value the grammar does not cover is what it was before the vocabulary
16037 /// opened: carried untouched by the document, answered `None` by the query
16038 /// so the menu ticks *Default*, and rewritten only by a gesture on its own
16039 /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
16040 #[test]
16041 fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
16042 let src =
16043 "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
16044 let mut d = fmt_doc(src, Format::Djot);
16045 d.caret = d.source.find("hello").unwrap();
16046 assert_eq!(d.font_size_at_caret(), None);
16047 assert_eq!(d.text_color_at_caret(), None);
16048 assert_eq!(d.line_spacing_at_caret(), None);
16049
16050 // The keys are still there, untouched, after a gesture on a *different*
16051 // key — "edit one key and keep the rest" holds for a value it cannot
16052 // read as readily as for one it can.
16053 d.set_alignment(Some(Align::Center));
16054 assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
16055 assert!(
16056 d.source.contains("data-color=\"rgb(1,2,3)\""),
16057 "{:?}",
16058 d.source
16059 );
16060 assert!(
16061 d.source.contains("data-line-height=\"1.3em\""),
16062 "{:?}",
16063 d.source
16064 );
16065 // And the gesture on its *own* key replaces it, which is the one way a
16066 // carried value ever changes.
16067 d.caret = d.source.find("hello").unwrap();
16068 d.set_font_size(FontSize::points(12.0));
16069 assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
16070 assert!(!d.source.contains("huge"), "{:?}", d.source);
16071 }
16072
16073 /// The nearest node wins whichever *form* either node wrote: a value inside
16074 /// a name, a name inside a value. The fold has one rule and does not learn
16075 /// a second one for exact values.
16076 #[test]
16077 fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
16078 // A value inside a name: the block says `small`, the span says `14pt`.
16079 let mut d = fmt_doc(
16080 "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
16081 Format::Djot,
16082 );
16083 d.caret = d.source.find('y').unwrap();
16084 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16085 d.caret = d.source.find('x').unwrap();
16086 assert_eq!(
16087 d.font_size_at_caret(),
16088 Some(FontSize::Step(SizeStep::Small))
16089 );
16090
16091 // And a name inside a value, which is the same rule read the other way.
16092 let mut e = fmt_doc(
16093 "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
16094 Format::Djot,
16095 );
16096 e.caret = e.source.find('y').unwrap();
16097 assert_eq!(
16098 e.font_size_at_caret(),
16099 Some(FontSize::Step(SizeStep::Small))
16100 );
16101 assert_eq!(
16102 e.text_color_at_caret(),
16103 Some(TextColor::Rgb {
16104 r: 0xc0,
16105 g: 0x30,
16106 b: 0x30
16107 }),
16108 "the block's colour still reaches the span"
16109 );
16110 e.caret = e.source.find('x').unwrap();
16111 assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
16112 }
16113
16114 /// twig re-styles the span a range already lies in rather than nesting a
16115 /// second, and an empty set unwraps it — so a second press of the menu
16116 /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
16117 /// means "the theme's own" takes the span away.
16118 #[test]
16119 fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
16120 let mut d = fmt_doc("a big b\n", Format::Djot);
16121 d.anchor = Some(2);
16122 d.caret = 5;
16123 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16124 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16125
16126 // The selection `wrap_range_attrs` left behind covers the whole span;
16127 // colouring it now keeps the size, because the gesture reads the span's
16128 // attributes before it edits its own key.
16129 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16130 assert_eq!(
16131 d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
16132 "one span, both keys"
16133 );
16134 assert_eq!(
16135 d.font_size_at_caret(),
16136 Some(FontSize::Step(SizeStep::Large))
16137 );
16138 assert_eq!(
16139 d.text_color_at_caret(),
16140 Some(TextColor::Named(MarkColor::Red))
16141 );
16142
16143 d.set_text_color(None);
16144 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16145 d.set_font_size(None);
16146 assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
16147 assert_eq!(d.font_size_at_caret(), None);
16148 }
16149
16150 /// The queries read the nearest node that names the property: the span the
16151 /// caret is in, then its block, then the `div`s around it.
16152 #[test]
16153 fn a_presentation_query_reads_the_nearest_node_that_names_it() {
16154 let mut d = fmt_doc(
16155 "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
16156 Format::Djot,
16157 );
16158 // In the span: its own size, the block's face and alignment.
16159 d.caret = d.source.find('y').unwrap();
16160 assert_eq!(
16161 d.font_size_at_caret(),
16162 Some(FontSize::Step(SizeStep::XxLarge))
16163 );
16164 assert_eq!(
16165 d.font_family_at_caret(),
16166 Some(FontFace::Generic(FontFamily::Serif))
16167 );
16168 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
16169 assert_eq!(d.line_spacing_at_caret(), None);
16170 assert_eq!(d.text_color_at_caret(), None);
16171
16172 // Outside it: the block's size.
16173 d.caret = d.source.find('x').unwrap();
16174 assert_eq!(
16175 d.font_size_at_caret(),
16176 Some(FontSize::Step(SizeStep::Small))
16177 );
16178
16179 // And through a Markdown div, which is where a Markdown block's
16180 // attributes live.
16181 let mut md = fmt_doc(
16182 "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
16183 Format::Markdown,
16184 );
16185 md.caret = md.source.find("hello").unwrap();
16186 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16187 assert_eq!(
16188 md.font_size_at_caret(),
16189 Some(FontSize::Step(SizeStep::Large))
16190 );
16191
16192 // A document that names none of it answers `None` everywhere, which is
16193 // "the theme's own" and what every toolbar draws unlit.
16194 let mut plain = doc_with("plain_presentation", "hello\n");
16195 plain.caret = 1;
16196 assert_eq!(plain.alignment_at_caret(), None);
16197 assert_eq!(plain.line_spacing_at_caret(), None);
16198 assert_eq!(plain.font_size_at_caret(), None);
16199 assert_eq!(plain.font_family_at_caret(), None);
16200 assert_eq!(plain.text_color_at_caret(), None);
16201 }
16202
16203 /// A djot fenced div is anonymous the way an attributed span is, and is a
16204 /// block all the same — the *form* is the whole of what tells them apart.
16205 /// Read as a span it poisoned both halves: the run gesture copied the div's
16206 /// entire attribute set onto the span it minted, duplicating the `id`, and
16207 /// the run and block queries answered off a node the walker draws nothing
16208 /// for.
16209 #[test]
16210 fn a_djot_fenced_div_is_not_an_attributed_span() {
16211 let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
16212 let mut d = fmt_doc(src, Format::Djot);
16213 let at = d.source.find("world").unwrap();
16214 d.anchor = Some(at);
16215 d.caret = at + "world".len();
16216 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16217 assert_eq!(
16218 d.source,
16219 "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
16220 "the span carries its own key and nothing of the div's"
16221 );
16222
16223 // And the queries stop at the block: a djot div is not a `<div>`, the
16224 // walker lends its keys to nothing inside it, and a query that said
16225 // otherwise would tick a menu entry no glyph on screen obeys.
16226 assert_eq!(
16227 d.text_color_at_caret(),
16228 Some(TextColor::Named(MarkColor::Red))
16229 );
16230 assert_eq!(d.font_size_at_caret(), None);
16231 assert_eq!(d.alignment_at_caret(), None);
16232 }
16233
16234 /// Clearing a property the block does not name and a `div` around it does
16235 /// would write nothing and change nothing — twig's `set_block_attrs`
16236 /// reaches one node, and the div is not it. The gesture says so instead of
16237 /// leaving the author pressing an entry that never ticks.
16238 #[test]
16239 fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
16240 // Markdown, two paragraphs in one div: not the sole-child shape twig
16241 // writes, so `block_attrs_at_caret` reads the paragraph and the
16242 // paragraph names none of it.
16243 let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
16244 let mut md = fmt_doc(src, Format::Markdown);
16245 md.caret = md.source.find("hello").unwrap();
16246 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16247
16248 md.set_alignment(None);
16249 assert_eq!(md.source, src, "nothing written");
16250 assert!(!md.dirty);
16251 assert_eq!(
16252 md.status.as_deref(),
16253 Some("alignment: set on the div around the block")
16254 );
16255 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16256
16257 // The same for a `data-` key, at both levels — the block pair and the
16258 // run three, the run three at a bare caret being the block gesture.
16259 md.set_line_spacing(None);
16260 assert_eq!(md.source, src);
16261 assert_eq!(
16262 md.status.as_deref(),
16263 Some("line spacing: set on the div around the block")
16264 );
16265 md.set_font_size(None);
16266 assert_eq!(md.source, src);
16267 assert_eq!(
16268 md.status.as_deref(),
16269 Some("size: set on the div around the block")
16270 );
16271
16272 // HTML has no sole-child fold at all: a block's attributes go on the
16273 // block, so the div around one is always out of reach.
16274 let html_src = "<div class=\"center\"><p>hi</p></div>\n";
16275 let mut html = fmt_doc(html_src, Format::Html);
16276 html.caret = html.source.find("hi").unwrap();
16277 assert_eq!(html.alignment_at_caret(), Some(Align::Center));
16278 html.set_alignment(None);
16279 assert_eq!(html.source, html_src);
16280 assert!(!html.dirty);
16281 assert_eq!(
16282 html.status.as_deref(),
16283 Some("alignment: set on the div around the block")
16284 );
16285
16286 // And it is a refusal, not a rule against clearing: a block that names
16287 // the property itself still loses it, div or no div.
16288 let mut own = fmt_doc(
16289 "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
16290 Format::Html,
16291 );
16292 own.caret = own.source.find("hi").unwrap();
16293 own.set_alignment(None);
16294 assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
16295 assert_eq!(own.status, None);
16296 }
16297
16298 /// An edited key is rewritten **where it stands**. The proposal's worked
16299 /// example is the test: a paragraph that came in as `id="intro"
16300 /// class="lead center" data-line-height="1.5"` and is right-aligned goes
16301 /// out as the same list with one token changed. Removing the key and
16302 /// pushing it back shuffled a document's attributes on every press.
16303 #[test]
16304 fn an_edited_key_keeps_its_place_among_the_attributes() {
16305 let mut html = fmt_doc(
16306 "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
16307 Format::Html,
16308 );
16309 html.caret = html.source.find("hello").unwrap();
16310 html.set_alignment(Some(Align::Right));
16311 assert_eq!(
16312 html.source,
16313 "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
16314 );
16315
16316 // A `data-` key the same way, and a key the block did not have still
16317 // goes on the end.
16318 html.caret = html.source.find("hello").unwrap();
16319 html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16320 assert_eq!(
16321 html.source,
16322 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
16323 );
16324 html.caret = html.source.find("hello").unwrap();
16325 html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16326 assert_eq!(
16327 html.source,
16328 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
16329 );
16330
16331 // Djot writes the same list in its own spelling, and the order is the
16332 // author's there too.
16333 let mut dj = fmt_doc(
16334 "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
16335 Format::Djot,
16336 );
16337 dj.caret = dj.source.find("hello").unwrap();
16338 dj.set_alignment(Some(Align::Right));
16339 assert_eq!(
16340 dj.source,
16341 "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
16342 );
16343 }
16344
16345 /// A page break is a block, so twig alone lands one after the caret's whole
16346 /// block; the paragraph is parted at the caret first, exactly as
16347 /// `insert_thematic_break` parts it, and each format spells the directive
16348 /// its own way.
16349 #[test]
16350 fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
16351 let mut md = doc_with("page_break_md", "hello world\n");
16352 md.caret = 5;
16353 md.insert_page_break();
16354 assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
16355 assert!(md.dirty);
16356 assert_eq!(md.status, None);
16357
16358 let mut dj = fmt_doc("hello world\n", Format::Djot);
16359 dj.caret = 5;
16360 dj.insert_page_break();
16361 assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
16362
16363 // At a block's end there is no second half to mint, so the break simply
16364 // follows the block — the rule the rule button already has.
16365 let mut end = doc_with("page_break_end", "hello\n");
16366 end.caret = 5;
16367 end.insert_page_break();
16368 assert_eq!(end.source, "hello\n\n::page-break\n");
16369
16370 // And it reaches the map as the placeholder row a frontend paginates on.
16371 end.view = View::Wysiwyg;
16372 end.build_visual(80);
16373 assert_eq!(
16374 end.vmap
16375 .rows
16376 .iter()
16377 .find_map(|r| r.leaf_directive.as_ref())
16378 .map(|m| m.name.as_str()),
16379 Some(PAGE_BREAK)
16380 );
16381 }
16382
16383 /// The vocabulary's capabilities, per format. The two block properties are
16384 /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
16385 /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
16386 /// keeps an id and a role and has no slot for a `data-` key.
16387 #[test]
16388 fn the_presentation_capabilities_are_ragged_per_format() {
16389 for fmt in [Format::Markdown, Format::Djot, Format::Html] {
16390 let c = Capabilities::of(fmt);
16391 assert!(c.alignment, "{fmt:?} alignment");
16392 assert!(c.line_spacing, "{fmt:?} line spacing");
16393 assert!(c.font_size, "{fmt:?} size");
16394 assert!(c.font_family, "{fmt:?} face");
16395 assert!(c.text_color, "{fmt:?} colour");
16396 }
16397 // Markdown spells both only under the extensions leaf parses with — a
16398 // `<div>` and a `<span>` read back as containers under `html_elements`,
16399 // and `::page-break` as a directive under `directives`. Ask twig's own
16400 // defaults and the answer is no, which is why `Capabilities` is built
16401 // with `supports_with`.
16402 assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
16403 assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
16404 assert!(!Format::Markdown.supports(Gesture::InsertDirective));
16405
16406 let adoc = Capabilities::of(Format::Asciidoc);
16407 assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
16408 assert!(
16409 !adoc.font_size && !adoc.font_family && !adoc.text_color,
16410 "AsciiDoc has no inline spelling that keeps a data- key"
16411 );
16412
16413 // XML spells none of it, and neither page break.
16414 let xml = Capabilities::of(Format::Xml);
16415 assert!(!xml.alignment && !xml.font_size && !xml.page_break);
16416 assert!(Capabilities::of(Format::Markdown).page_break);
16417 assert!(Capabilities::of(Format::Djot).page_break);
16418
16419 // And those two *only*, though twig spells the gesture in HTML and
16420 // AsciiDoc as well: it spells it differently there —
16421 // `<page-break></page-break>` and `<<<` — and the walker reads neither,
16422 // so the button would write a break that draws as nothing at all in
16423 // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
16424 // what leaf can show, not what twig can write. See
16425 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
16426 let exts = parse_extensions();
16427 assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
16428 assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
16429 assert!(!Capabilities::of(Format::Html).page_break);
16430 assert!(!Capabilities::of(Format::Asciidoc).page_break);
16431 }
16432
16433 /// A format that cannot spell a property refuses in its own words and
16434 /// writes nothing — the guard every other gesture has.
16435 #[test]
16436 fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
16437 let src = "<doc><p>hello</p></doc>\n";
16438 #[allow(clippy::type_complexity)]
16439 let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
16440 ("alignment", &|d: &mut Doc| {
16441 d.set_alignment(Some(Align::Center))
16442 }),
16443 ("line spacing", &|d: &mut Doc| {
16444 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
16445 }),
16446 ("size", &|d: &mut Doc| {
16447 d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
16448 }),
16449 ("face", &|d: &mut Doc| {
16450 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
16451 }),
16452 ("colour", &|d: &mut Doc| {
16453 d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
16454 }),
16455 ("page break", &|d: &mut Doc| d.insert_page_break()),
16456 ];
16457 for (name, op) in ops {
16458 let mut d = fmt_doc(src, Format::Xml);
16459 let at = d.source.find("hello").unwrap();
16460 d.caret = at;
16461 d.anchor = Some(at + 5);
16462 op(&mut d);
16463 assert_eq!(d.source, src, "{name} edited an XML document");
16464 assert!(!d.dirty, "{name} marked the document dirty");
16465 let status = d.status.as_deref().unwrap_or("");
16466 assert!(
16467 status.contains("xml"),
16468 "{name}: the refusal should name the format, got {status:?}"
16469 );
16470 }
16471
16472 // AsciiDoc is the ragged one: the block gesture works where the run
16473 // gesture does not, and a *selection* is what tells the two apart.
16474 let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
16475 adoc.anchor = Some(0);
16476 adoc.caret = 5;
16477 adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16478 assert_eq!(adoc.source, "hello world\n", "no inline spelling");
16479 assert!(adoc.status.is_some());
16480 }
16481
16482 /// A read-only document takes none of it, and a caret on a blank line has
16483 /// no block to carry an attribute — both say so rather than writing.
16484 #[test]
16485 fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
16486 let mut ro = fmt_doc("hello\n", Format::Djot);
16487 ro.read_only = true;
16488 ro.caret = 1;
16489 ro.set_alignment(Some(Align::Center));
16490 assert_eq!(ro.source, "hello\n");
16491
16492 let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
16493 blank.caret = 2; // the empty line between the two paragraphs
16494 blank.set_alignment(Some(Align::Center));
16495 assert_eq!(blank.source, "a\n\n\nb\n");
16496 assert!(
16497 blank.status.as_deref().unwrap_or("").contains("no block"),
16498 "got {:?}",
16499 blank.status
16500 );
16501 }
16502
16503 /// A block attribute gesture keeps the caret on the **text** it was on, not
16504 /// on the byte offset it had. Markdown has nowhere to put a paragraph's
16505 /// attributes but a `<div>` around it, and twig splices the div and the
16506 /// block it wraps as one region — so a caret that kept its offset landed in
16507 /// the markup, and every press after the first answered "no block at the
16508 /// caret" with the toolbar's queries reading nothing.
16509 #[test]
16510 fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
16511 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16512 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16513 md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
16514
16515 // Wrapping: the div and two blank lines open above the block.
16516 md.set_alignment(Some(Align::Center));
16517 assert_eq!(
16518 md.source,
16519 "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
16520 );
16521 assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
16522 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16523
16524 // Re-styling: the attribute line changes length under the same caret,
16525 // and the second press reaches the same block rather than nothing.
16526 md.set_alignment(Some(Align::Right));
16527 assert_eq!(
16528 md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
16529 "a second press re-styles the div"
16530 );
16531 assert_eq!(md.status, None);
16532 assert_eq!(word(&md), 2);
16533
16534 // A second key on the same div — the line grows, the caret rides it.
16535 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16536 assert_eq!(
16537 md.source,
16538 "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16539 );
16540 assert_eq!(word(&md), 2);
16541 assert_eq!(
16542 md.line_spacing_at_caret(),
16543 Some(LineHeight::Step(LineSpacing::Double))
16544 );
16545
16546 // Unwrapping: the line shrinks, and then the div goes altogether.
16547 md.set_alignment(None);
16548 assert_eq!(
16549 md.source,
16550 "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16551 );
16552 assert_eq!(word(&md), 2);
16553 md.set_line_spacing(None);
16554 assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
16555 assert_eq!(word(&md), 2, "the caret came back down with the block");
16556 assert_eq!(md.alignment_at_caret(), None);
16557 assert_eq!(md.status, None);
16558 }
16559
16560 /// The same rule in djot, where the spelling is a `{…}` line *above* the
16561 /// block rather than a wrapper around it: inserting it pushes the block
16562 /// down, re-styling it changes the line's length, and clearing the last key
16563 /// takes the line away again. The caret rides all three.
16564 #[test]
16565 fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
16566 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16567 let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
16568 dj.caret = dj.source.find("brown").unwrap() + 2;
16569
16570 dj.set_alignment(Some(Align::Center));
16571 assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
16572 assert_eq!(word(&dj), 2);
16573 assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
16574
16575 dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16576 assert_eq!(
16577 dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
16578 "a second press edits the line the first wrote"
16579 );
16580 assert_eq!(word(&dj), 2);
16581
16582 dj.set_alignment(None);
16583 assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
16584 assert_eq!(word(&dj), 2);
16585
16586 dj.set_line_spacing(None);
16587 assert_eq!(dj.source, "the quick brown fox\n");
16588 assert_eq!(word(&dj), 2);
16589 assert_eq!(dj.status, None);
16590 }
16591
16592 /// The run gestures with no selection are the block gesture, so they keep
16593 /// the caret the same way — and a heading keeps it inside the heading's own
16594 /// text, past the `# ` its content span starts after. A selection rides
16595 /// along whole: a block gesture is not a run gesture, and what was selected
16596 /// before the press is still selected after it.
16597 #[test]
16598 fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
16599 // No selection: the run gesture goes through the block door.
16600 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16601 md.caret = md.source.find("brown").unwrap() + 2;
16602 md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16603 assert_eq!(
16604 md.source,
16605 "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
16606 );
16607 assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
16608 assert_eq!(
16609 md.font_size_at_caret(),
16610 Some(FontSize::Step(SizeStep::Large))
16611 );
16612 md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
16613 assert_eq!(
16614 md.font_size_at_caret(),
16615 Some(FontSize::Step(SizeStep::Small)),
16616 "the second press reached the same block"
16617 );
16618
16619 // A selection: alignment is the block's whatever is selected, and the
16620 // words stay selected.
16621 let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
16622 let at = sel.source.find("brown").unwrap();
16623 sel.anchor = Some(at);
16624 sel.caret = at + 5;
16625 sel.set_alignment(Some(Align::Center));
16626 let now = sel.source.find("brown").unwrap();
16627 assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
16628
16629 // A heading: the content span starts past the `# `.
16630 let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
16631 h.caret = h.source.find("there").unwrap() + 1;
16632 h.set_alignment(Some(Align::Right));
16633 assert_eq!(
16634 h.source,
16635 "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
16636 );
16637 assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
16638 assert_eq!(h.alignment_at_caret(), Some(Align::Right));
16639 }
16640
16641 /// A djot document open in the rich view, with its map built as
16642 /// [`wysiwyg_doc`] builds a Markdown one's.
16643 fn wysiwyg_djot(body: &str) -> Doc {
16644 let mut d = fmt_doc(body, Format::Djot);
16645 d.view = View::Wysiwyg;
16646 d.build_visual(80);
16647 d
16648 }
16649
16650 /// Backspace at the start of a block whose presentation is spelled as
16651 /// hidden markup before it strips that presentation, the way Backspace at
16652 /// a heading's start strips its `#`. The ordinary delete fused djot's
16653 /// `{.center}` line onto the text and took the blank line a Markdown div
16654 /// needs between its tag and its paragraph.
16655 #[test]
16656 fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
16657 let mut md = wysiwyg_doc(
16658 "wys_attr_bksp",
16659 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16660 );
16661 md.caret = md.source.find("hello").unwrap();
16662 md.backspace();
16663 assert_eq!(
16664 md.source, "above\n\nhello\n\nbelow\n",
16665 "the div is unwrapped"
16666 );
16667 assert_eq!(md.caret, 7, "the caret stays at the start of its text");
16668 md.backspace();
16669 assert_eq!(
16670 md.source, "above\nhello\n\nbelow\n",
16671 "the next press joins the paragraphs, as it always did"
16672 );
16673
16674 let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
16675 dj.caret = dj.source.find("hello").unwrap();
16676 dj.backspace();
16677 assert_eq!(
16678 dj.source, "above\n\nhello\n\nbelow\n",
16679 "the attribute line goes"
16680 );
16681 assert_eq!(dj.caret, 7);
16682
16683 // A heading's own marker is the nearer hidden markup, and goes first;
16684 // the attributes are the next press's.
16685 let mut dj = wysiwyg_djot("{.center}\n# Title\n");
16686 dj.caret = dj.source.find("Title").unwrap();
16687 dj.backspace();
16688 assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
16689 dj.build_visual(80);
16690 dj.backspace();
16691 assert_eq!(dj.source, "Title\n", "then the attributes");
16692 assert_eq!(dj.caret, 0);
16693 }
16694
16695 /// A div around several blocks has no sole child for twig to unwrap, so
16696 /// at its first block the caret steps back to the stop before rather than
16697 /// taking the div apart; a later block has an ordinary paragraph above it
16698 /// and joins as any paragraph does.
16699 #[test]
16700 fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
16701 let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
16702 let mut d = wysiwyg_doc("wys_div_first", src);
16703 d.caret = d.source.find("hello").unwrap();
16704 d.backspace();
16705 assert_eq!(d.source, src, "nothing is deleted");
16706 assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
16707
16708 let mut d = wysiwyg_doc("wys_div_later", src);
16709 d.caret = d.source.find("world").unwrap();
16710 d.backspace();
16711 assert_eq!(
16712 d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
16713 "a later block joins the one above it"
16714 );
16715 }
16716
16717 /// Backspace at the start of the paragraph after a Markdown div joins it
16718 /// into the div's last paragraph — the join any two paragraphs make, with
16719 /// the hidden `</div>` carried past the joined text. The ordinary delete
16720 /// took the newline under the tag, which drew nothing different, and the
16721 /// next press took the `>` and left the div unclosed.
16722 #[test]
16723 fn backspace_after_a_div_joins_the_paragraph_into_it() {
16724 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
16725 let mut d = wysiwyg_doc("wys_div_join", src);
16726 d.caret = d.source.find("below").unwrap();
16727 d.backspace();
16728 assert_eq!(
16729 d.source,
16730 "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
16731 );
16732 assert_eq!(
16733 d.caret,
16734 d.source.find("below").unwrap(),
16735 "the caret stays at the start of the joined text"
16736 );
16737 d.build_visual(80);
16738 assert_eq!(
16739 d.alignment_at_caret(),
16740 Some(Align::Center),
16741 "and is centred now"
16742 );
16743 d.undo();
16744 assert_eq!(d.source, src, "one undo step");
16745
16746 // A list closes the div: the paragraph joins the last item's text,
16747 // under the item's continuation indent, inside the div.
16748 let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
16749 let mut d = wysiwyg_doc("wys_div_list", src);
16750 d.caret = d.source.find("below").unwrap();
16751 d.backspace();
16752 assert_eq!(
16753 d.source, "<div class=\"center\">\n\n- item\n below\n\n</div>\n",
16754 "the paragraph joins the item"
16755 );
16756 assert_eq!(d.caret, d.source.find("below").unwrap());
16757
16758 // And where twig has nothing to join into — a code block above — the
16759 // caret steps back to the stop before, and nothing is deleted.
16760 let src = "```\ncode\n```\n\nbelow\n";
16761 let mut d = wysiwyg_doc("wys_code_then_para", src);
16762 d.caret = d.source.find("below").unwrap();
16763 d.backspace();
16764 assert_eq!(d.source, src, "nothing is deleted");
16765 assert!(
16766 d.caret < d.source.find("below").unwrap(),
16767 "the caret stepped back"
16768 );
16769 }
16770
16771 /// Backspace on a blank line collapses to the stop before it — but not
16772 /// across hidden markup, which that collapse deleted whole: a `</div>`,
16773 /// or a comment between two blocks. There the blank line goes alone, and
16774 /// the caret lands where the collapse would have put it.
16775 #[test]
16776 fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
16777 let mut d = wysiwyg_doc(
16778 "wys_div_blank",
16779 "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
16780 );
16781 d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
16782 assert!(
16783 d.vmap.is_stop(d.caret),
16784 "the empty paragraph is a caret home"
16785 );
16786 d.backspace();
16787 assert_eq!(
16788 d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16789 "the blank line goes and the div stays closed"
16790 );
16791 assert_eq!(
16792 d.caret,
16793 d.source.find("hello").unwrap() + 5,
16794 "onto the end of `hello`"
16795 );
16796
16797 let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
16798 d.caret = d.source.find("below").unwrap() - 2;
16799 assert!(d.vmap.is_stop(d.caret));
16800 d.backspace();
16801 assert_eq!(
16802 d.source, "above\n\n<!-- note -->\n\nbelow\n",
16803 "the comment stays"
16804 );
16805 assert_eq!(d.caret, 5);
16806 }
16807
16808 /// Backspace at the end of an attributed span steps inside its hidden
16809 /// closing tag the way it steps inside a `**`, and takes the span with
16810 /// its last letter. Before, the byte-step took the `>` of `</span>`,
16811 /// which left the paragraph unparseable: it vanished from the rich view,
16812 /// and the Backspace after that joined the next block into the wreck.
16813 #[test]
16814 fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
16815 let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
16816 let mut d = wysiwyg_doc("wys_span_bs", src);
16817 d.caret = d.source.find("a test").unwrap() + 6;
16818 for _ in 0..7 {
16819 d.backspace();
16820 }
16821 assert_eq!(
16822 d.source,
16823 "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
16824 );
16825 d.backspace();
16826 assert_eq!(
16827 d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
16828 "the first Backspace after the tag takes the letter, not the `>`"
16829 );
16830 d.backspace();
16831 assert_eq!(
16832 d.source, "above\n\nThis \n\nTest 2\n",
16833 "the last letter takes the span with it"
16834 );
16835 assert_eq!(d.caret, 12, "the caret is where the letter was");
16836 d.backspace();
16837 assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
16838 assert_eq!(d.caret, 11);
16839 d.build_visual(80);
16840 assert!(
16841 d.vmap
16842 .rows
16843 .iter()
16844 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
16845 "the paragraph is still drawn"
16846 );
16847 }
16848
16849 #[test]
16850 fn backspace_walks_into_a_djot_sized_span_too() {
16851 let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
16852 d.caret = d.source.find("\n\nTest 2").unwrap();
16853 // The caret home at the paragraph's end is inside the span, before
16854 // its `]`: the map offers no stop after `]{…}`.
16855 d.build_visual(80);
16856 assert_eq!(d.vmap.stop_before(31), Some(8));
16857 d.caret = 8;
16858 d.backspace();
16859 assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
16860 d.backspace();
16861 assert_eq!(
16862 d.source, "This \n\nTest 2\n",
16863 "the attribute block outside the span goes with it"
16864 );
16865 d.backspace();
16866 assert_eq!(d.source, "This\n\nTest 2\n");
16867 assert_eq!(d.caret, 4);
16868 }
16869
16870 /// A span that is empty as the file was written has no stop of its own;
16871 /// Backspace reaching it from behind takes it with the character before
16872 /// it, the character the key looked aimed at.
16873 #[test]
16874 fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
16875 let src = "This <span data-size=\"x-large\"></span> a test\n";
16876 let mut d = wysiwyg_doc("wys_span_empty", src);
16877 d.caret = d.source.find(" a test").unwrap();
16878 d.backspace();
16879 assert_eq!(d.source, "This a test\n");
16880 assert_eq!(d.caret, 4);
16881 }
16882
16883 /// The mirror: Delete in front of a span's opening tag takes its first
16884 /// letter, and the span with its last.
16885 #[test]
16886 fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
16887 let src = "This <span data-size=\"x-large\">is</span> a test\n";
16888 let mut d = wysiwyg_doc("wys_span_del", src);
16889 d.caret = 5;
16890 d.delete_forward();
16891 assert_eq!(
16892 d.source,
16893 "This <span data-size=\"x-large\">s</span> a test\n"
16894 );
16895 d.delete_forward();
16896 assert_eq!(d.source, "This a test\n");
16897 assert_eq!(d.caret, 5);
16898 d.delete_forward();
16899 assert_eq!(d.source, "This a test\n");
16900 assert_eq!(d.caret, 5);
16901 }
16902
16903 /// The block version of the span's emptying rule. Centre a one-letter
16904 /// paragraph — Markdown spells that as a `<div>` around it — and
16905 /// Backspace the letter: the div goes with it, leaving a plain blank line
16906 /// the caret is at home on, in the incremental map and the from-scratch
16907 /// one alike. Before, the letter went alone; the emptied div drew a
16908 /// caret home only the stale map had, and the next Backspace collapsed
16909 /// the line and left `<div class="center">\n\n</div>` standing invisibly
16910 /// in the file.
16911 #[test]
16912 fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
16913 let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
16914 d.caret = d.source.len() - 1;
16915 d.set_alignment(Some(Align::Center));
16916 assert_eq!(
16917 d.source,
16918 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
16919 );
16920 d.backspace();
16921 assert_eq!(d.source, "Try the toolbar.\n\n\n");
16922 assert_eq!(d.caret, 18, "on the blank line where the letter was");
16923 // The host rebuilds the map after every key; the spliced map and a
16924 // fresh one both give the line a caret home.
16925 d.build_visual_unwrapped();
16926 assert!(d.vmap.is_stop(18));
16927 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
16928 d.backspace();
16929 assert_eq!(
16930 d.source, "Try the toolbar.\n",
16931 "then the blank line collapses"
16932 );
16933 assert_eq!(d.caret, 16);
16934
16935 // With a block after the div the blank line keeps a gap each side.
16936 let mut d = wysiwyg_doc(
16937 "wys_div_empty_mid",
16938 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
16939 );
16940 d.caret = d.source.find("T\n").unwrap() + 1;
16941 d.backspace();
16942 assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
16943 assert_eq!(d.caret, 18);
16944 d.build_visual_unwrapped();
16945 assert!(d.vmap.is_stop(18));
16946 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
16947 }
16948
16949 /// djot spells the same paragraph as a `{.center}` line above it, and
16950 /// twig has no node at all for that line once the paragraph is gone —
16951 /// so the line goes with the letter too.
16952 #[test]
16953 fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
16954 let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
16955 d.build_visual(80);
16956 d.caret = d.source.len() - 1;
16957 d.set_alignment(Some(Align::Center));
16958 assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
16959 d.backspace();
16960 assert_eq!(d.source, "Try the toolbar.\n\n\n");
16961 assert_eq!(d.caret, 18);
16962 d.build_visual(80);
16963 assert!(d.vmap.is_stop(18));
16964 }
16965
16966 /// The rule is for a block that would be no block: a div holding more
16967 /// keeps its tags, and an emptied heading is still a heading.
16968 #[test]
16969 fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
16970 let mut d = wysiwyg_doc(
16971 "wys_div_more",
16972 "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
16973 );
16974 d.caret = d.source.find("T\n").unwrap() + 1;
16975 d.backspace();
16976 assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
16977 assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
16978
16979 let mut d = wysiwyg_doc(
16980 "wys_div_heading",
16981 "<div class=\"center\">\n\n# T\n\n</div>\n",
16982 );
16983 d.caret = d.source.find("T\n").unwrap() + 1;
16984 d.backspace();
16985 assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
16986 assert_eq!(d.caret, 24);
16987 d.build_visual(80);
16988 assert!(
16989 d.vmap.is_stop(24),
16990 "the empty heading is still a caret home"
16991 );
16992 }
16993
16994 /// The mirror: Delete in front of the letter takes the div with it.
16995 #[test]
16996 fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
16997 let mut d = wysiwyg_doc(
16998 "wys_div_empty_del",
16999 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
17000 );
17001 d.caret = d.source.find("T\n").unwrap();
17002 d.delete_forward();
17003 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17004 assert_eq!(d.caret, 18);
17005 d.build_visual(80);
17006 assert!(d.vmap.is_stop(18));
17007
17008 let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
17009 d.build_visual(80);
17010 d.caret = d.source.find("T\n").unwrap();
17011 d.delete_forward();
17012 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17013 assert_eq!(d.caret, 18);
17014 }
17015
17016 /// Backspace at a block's start is twig's join, spelled per format — so
17017 /// the cases the one-newline delete got wrong come out right: a
17018 /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
17019 /// and a quote's prefix is written on the joined line.
17020 #[test]
17021 fn backspace_at_a_block_start_joins_it_the_format_s_way() {
17022 let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
17023 d.caret = d.source.find("below").unwrap();
17024 d.backspace();
17025 assert_eq!(d.source, "# Title below\n", "onto the heading's line");
17026 assert_eq!(d.caret, d.source.find("below").unwrap());
17027 d.undo();
17028 assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
17029
17030 let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
17031 d.caret = d.source.find('b').unwrap();
17032 d.backspace();
17033 assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
17034 assert_eq!(d.caret, d.source.find('b').unwrap());
17035
17036 let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
17037 h.view = View::Wysiwyg;
17038 h.build_visual(80);
17039 h.caret = h.source.find("below").unwrap();
17040 h.backspace();
17041 assert_eq!(
17042 h.source, "<p>above\nbelow</p>\n",
17043 "one paragraph, the tag gone whole"
17044 );
17045 assert_eq!(h.caret, h.source.find("below").unwrap());
17046 }
17047
17048 /// Delete at the end of a block's content is the same join aimed at the
17049 /// block after it, and the caret stays where the joined text now begins.
17050 #[test]
17051 fn delete_at_a_block_end_joins_the_next_block_into_it() {
17052 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17053 let mut d = wysiwyg_doc("wys_del_join", src);
17054 d.caret = d.source.find("hello").unwrap() + 5;
17055 d.delete_forward();
17056 assert_eq!(
17057 d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
17058 "below joins hello inside the div"
17059 );
17060 assert_eq!(
17061 d.caret,
17062 d.source.find("hello").unwrap() + 5,
17063 "the caret stays"
17064 );
17065
17066 let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
17067 d.caret = 5;
17068 d.delete_forward();
17069 assert_eq!(
17070 d.source, "above\nTitle\n",
17071 "the heading's marker goes with the join"
17072 );
17073 assert_eq!(d.caret, 5);
17074
17075 // A code block after the paragraph: nothing to join, the caret steps
17076 // forward onto the next stop and nothing is deleted.
17077 let src = "above\n\n```\ncode\n```\n";
17078 let mut d = wysiwyg_doc("wys_del_code", src);
17079 d.caret = 5;
17080 d.delete_forward();
17081 assert_eq!(d.source, src);
17082 assert!(d.caret > 5, "the caret stepped forward");
17083 }
17084}