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::counts::{self, TextCounts};
42use crate::html;
43use crate::source::{self, SourceMap};
44use crate::style::{Align, FontFace, FontSize, LineHeight, MarkColor, TextColor};
45use crate::wysiwyg::{self, MediaKind, MediaStop, Reveal, VisualMap};
46
47/// Which view the body shows.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum View {
50 /// The raw document with a caret in source bytes.
51 Source,
52 /// Markup resolved to real styles, caret riding the rendered glyphs.
53 Wysiwyg,
54}
55
56/// How much of the source markup the WYSIWYG view exposes — a per-editor
57/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
58/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
59/// terms, and every rung below is about *delimiters*, whatever grammar spells
60/// them. The examples are Markdown only because that is what most documents are.
61///
62/// A single ladder over two underlying axes, because only three of their four
63/// combinations are coherent:
64///
65/// | | authoring off | authoring on |
66/// |---|---|---|
67/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
68/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
69///
70/// The empty quadrant would show delimiters on the caret's line and then escape
71/// the ones you type — a surface that displays a syntax it refuses to accept.
72/// Someone who wants to read raw markup without authoring it has
73/// [`View::Source`], which is the better tool for it.
74///
75/// The two axes are read separately by the code that cares — see
76/// [`reveals_caret_line`](Self::reveals_caret_line) and
77/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
78/// as a ladder.
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
80pub enum MarkupMode {
81 /// Delimiters stay hidden even on the caret's line, and typed syntax stays
82 /// literal — twig escapes anything that would open markup, so formatting
83 /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
84 /// reading surface for people who don't write markup by hand; the default,
85 /// and what Diaryx ships.
86 #[default]
87 None,
88 /// Delimiters stay hidden, but typing them authors real markup: `*x*`
89 /// becomes italic and the asterisks disappear into the styling
90 /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
91 /// clean surface back once it has been applied.
92 Shortcuts,
93 /// The caret's line shows its raw markup while every other line renders
94 /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
95 /// — for people fluent in the document's grammar who want to see and edit
96 /// the delimiters they type.
97 Full,
98}
99
100impl MarkupMode {
101 /// Whether the rich view shows raw delimiters on the line holding the caret.
102 /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
103 /// WYSIWYG builder.
104 pub fn reveals_caret_line(self) -> bool {
105 matches!(self, MarkupMode::Full)
106 }
107
108 /// Whether typed markup characters author real formatting. The editing axis
109 /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
110 pub fn authors(self) -> bool {
111 !matches!(self, MarkupMode::None)
112 }
113}
114
115/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
116/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
117/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
118/// either flow. The renderer consults it when it lays a block's inline content
119/// into visual rows.
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
121pub enum LineFlow {
122 /// A soft break folds into a space and the paragraph reflows to the
123 /// viewport width — flowing prose, where the source's line wrapping is
124 /// insignificant. The default, and what Diaryx ships.
125 #[default]
126 Fold,
127 /// A soft break renders as a line break exactly where it was written, so
128 /// the author's source line structure shows on screen unchanged — the mode
129 /// for people who lay out their prose deliberately (one sentence or clause
130 /// per line, semantic line breaks). The break is still a soft break in the
131 /// source; only its rendering changes.
132 Preserve,
133}
134
135/// What the file behind a document looks like right now, against the bytes leaf
136/// last read from it or wrote to it — the question a frontend asks before it
137/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
138/// happen) or when its window regains focus. See [`Doc::disk_state`].
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum DiskState {
141 /// The file holds exactly the bytes leaf last read or wrote.
142 Unchanged,
143 /// Someone else wrote the file since. Saving overwrites their work; see
144 /// [`Doc::reload`] for the other direction.
145 Changed,
146 /// The file is gone — deleted or renamed away. A save recreates it.
147 Missing,
148 /// There is a path, but the file couldn't be read (permissions, a directory
149 /// in the way): leaf can't tell, and won't guess.
150 Unreadable,
151 /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
152 /// changed under a document that was never on disk.
153 Untitled,
154}
155
156/// The inline marks in force at a point in the document — what a toolbar
157/// lights up. A `Copy` bitset rather than a `HashSet`, because
158/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
159/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct InlineMarks(u8);
162
163impl InlineMarks {
164 /// Every kind, in the order [`InlineMarks::iter`] yields them.
165 const ALL: [InlineKind; 8] = [
166 InlineKind::Strong,
167 InlineKind::Emph,
168 InlineKind::Verbatim,
169 InlineKind::Mark,
170 InlineKind::Superscript,
171 InlineKind::Subscript,
172 InlineKind::Insert,
173 InlineKind::Delete,
174 ];
175
176 pub const fn empty() -> Self {
177 InlineMarks(0)
178 }
179
180 /// Private: the set is an *answer*, and adding a mark to it doesn't mark
181 /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
182 fn insert(&mut self, kind: InlineKind) {
183 self.0 |= Self::bit(kind);
184 }
185
186 /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
187 fn flip(&mut self, kind: InlineKind) {
188 self.0 ^= Self::bit(kind);
189 }
190
191 /// The symmetric difference: which marks differ between the two sets. Used
192 /// to resolve the marks already in force at the caret against the pending
193 /// delta — a bit set in the delta flips the base mark for the next keystroke.
194 fn xor(self, other: InlineMarks) -> InlineMarks {
195 InlineMarks(self.0 ^ other.0)
196 }
197
198 /// Whether `kind` is in force — the toolbar's "is Bold active?".
199 pub fn contains(self, kind: InlineKind) -> bool {
200 self.0 & Self::bit(kind) != 0
201 }
202
203 pub fn is_empty(self) -> bool {
204 self.0 == 0
205 }
206
207 /// The marks in force, for a frontend that renders whatever is on rather
208 /// than asking after a fixed list.
209 pub fn iter(self) -> impl Iterator<Item = InlineKind> {
210 Self::ALL.into_iter().filter(move |&k| self.contains(k))
211 }
212
213 fn bit(kind: InlineKind) -> u8 {
214 1 << match kind {
215 InlineKind::Strong => 0,
216 InlineKind::Emph => 1,
217 InlineKind::Verbatim => 2,
218 InlineKind::Mark => 3,
219 InlineKind::Superscript => 4,
220 InlineKind::Subscript => 5,
221 InlineKind::Insert => 6,
222 InlineKind::Delete => 7,
223 }
224 }
225}
226
227impl FromIterator<InlineKind> for InlineMarks {
228 fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
229 let mut m = InlineMarks::empty();
230 for k in iter {
231 m.insert(k);
232 }
233 m
234 }
235}
236
237/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
238/// into one undo step (a run of typed characters undoes together); `Other` never
239/// coalesces, so a paste, format toggle, or block change is always its own step.
240#[derive(Clone, Copy, PartialEq, Eq)]
241enum EditKind {
242 Insert,
243 Delete,
244 /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
245 /// rather than `Insert`'s because a composition is not typing: each step
246 /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
247 /// though no two steps insert the same bytes, and it must not fold into the
248 /// typed characters on either side of it.
249 Compose,
250 Other,
251}
252
253/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
254/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
255/// the caret), `Forward` is Delete (one starting at it).
256#[derive(Clone, Copy)]
257enum BreakEdge {
258 Backward,
259 Forward,
260}
261
262/// A re-spelling of one inline mark run, held ready in case the edit about to
263/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
264/// Every offset in it is in the coordinates the document will have *after* the
265/// plain edit, since that is when it may be applied.
266struct MarkEdgeFix {
267 /// The run's kind, and an offset inside what was its content: together they
268 /// answer "did the plain edit actually break this mark?" — the question that
269 /// decides whether any of this is applied at all.
270 kind: InlineKind,
271 probe: usize,
272 /// The byte range to re-spell (the run's delimiters included) and its new
273 /// spelling, with the edge whitespace moved outside the delimiters.
274 start: usize,
275 end: usize,
276 text: String,
277 /// Where the caret belongs afterwards — the same place on screen it would
278 /// have had, which is now on the other side of a delimiter.
279 caret: usize,
280 /// The marks in force for text typed at that caret. The caret can land
281 /// outside a run it was inside, and the marks have to survive the move or
282 /// the toolbar goes dark mid-word.
283 want: InlineMarks,
284}
285
286/// The caret and selection at one moment — the part of a history step twig's
287/// `Change` cannot carry, because the caret is leaf's state and twig only knows
288/// about bytes. leaf serializes it into the opaque per-state blob twig now
289/// stores in its own undo history (see `record_caret`), so undo and redo hand
290/// back the caret that matches the source they restore.
291#[derive(Clone, Copy)]
292struct CaretState {
293 caret: usize,
294 anchor: Option<usize>,
295}
296
297impl CaretState {
298 /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
299 /// then an anchor-present flag and the anchor. twig copies these bytes and
300 /// never reads them.
301 fn to_blob(self) -> [u8; 17] {
302 let mut b = [0u8; 17];
303 b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
304 if let Some(a) = self.anchor {
305 b[8] = 1;
306 b[9..].copy_from_slice(&(a as u64).to_le_bytes());
307 }
308 b
309 }
310
311 /// Recover a state from twig's blob, or `None` when it is empty or the wrong
312 /// length — a state twig restored that never had a caret set on it, which
313 /// leaves the caller to fall back to the edit site.
314 fn from_blob(b: &[u8]) -> Option<Self> {
315 let b: &[u8; 17] = b.try_into().ok()?;
316 let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
317 let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
318 Some(CaretState { caret, anchor })
319 }
320}
321
322/// A footnote reference and the note it names — the answer to
323/// [`Doc::footnote_at`].
324///
325/// The two `Option`s move together: a reference whose definition is missing has
326/// neither a body to show nor a place to jump to, and one that resolved has
327/// both.
328#[derive(Clone, PartialEq, Eq, Debug)]
329pub struct FootnoteRef {
330 /// The reference's label — the `1` of `[^1]`, with neither the `^` that
331 /// spells it a footnote nor the brackets around it.
332 pub label: String,
333 /// The note's body as source bytes (see
334 /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
335 /// document defines no `[^label]:` to read one from.
336 pub text: Option<String>,
337 /// Where the note's *body* starts, for a "go to note" that moves the caret
338 /// there. `None` alongside a `None` `text`.
339 ///
340 /// The body rather than the definition, because this is an offset to put a
341 /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
342 /// aiming at the definition's first byte snaps to the nearest real stop,
343 /// which is up in the paragraph above the note. It is also simply where a
344 /// reader following a reference wants to land: at the note's first word,
345 /// ready to read or amend it.
346 pub offset: Option<usize>,
347 /// Where the note's body ends, exclusive — so a frontend can ask which
348 /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
349 ///
350 /// The rows are the note with its markup resolved: `see *later*` reaches a
351 /// frontend as an italic run, not as asterisks. `text` is the source bytes
352 /// and stays the honest answer for anything that wants the note as written
353 /// (a search index, a copy); this pair of offsets is for anything that wants
354 /// it as *read*. `None` alongside a `None` `offset`.
355 pub end: Option<usize>,
356}
357
358/// A footnote definition and the reference that sends a reader to it — the
359/// answer to [`Doc::footnote_definition_at`], and the other half of the round
360/// trip [`FootnoteRef`] starts.
361///
362/// A note is a place a reader *arrives*, so the useful thing to know while
363/// standing in one is the way back. Without this the jump to a note is a
364/// one-way door: the definitions sit at the foot of the document, so returning
365/// by hand means scrolling back up and finding the sentence again.
366#[derive(Clone, PartialEq, Eq, Debug)]
367pub struct FootnoteDef {
368 /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
369 /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
370 pub label: String,
371 /// Where the reference's *label* is, for a "back to reference" that moves
372 /// the caret there. `None` for a note nothing refers to — an orphan, which
373 /// is worth being able to say rather than silently doing nothing.
374 ///
375 /// The label rather than the reference's first byte, for
376 /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
377 /// and its label is the only part of it the caret can rest on.
378 ///
379 /// The *first* reference, when a label is cited more than once: a repeated
380 /// citation has no one true home, and the first is both the one a reader
381 /// most likely came from and the only choice that doesn't depend on how
382 /// they got here.
383 pub offset: Option<usize>,
384}
385
386/// Where a locator lands — the answer to [`Doc::locate`].
387///
388/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
389/// document, and a place is a span rather than a point: a reader following one
390/// wants the caret at its first byte, and a reader merely *peeking* at one wants
391/// the block it covers drawn. Both are served by carrying the whole span, and
392/// only one of the two can be recovered from an offset alone.
393#[derive(Clone, PartialEq, Eq, Debug)]
394pub struct Landing {
395 /// The first byte of the block the locator names — where a caret goes.
396 pub start: usize,
397 /// One past its last byte, so a frontend can map the pair through
398 /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
399 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
400 pub end: usize,
401}
402
403/// A selection cited out of the source: the text itself, up to a requested
404/// number of characters either side, and the byte range it came from. See
405/// [`Doc::selection_quote`].
406///
407/// The prefix and suffix are what make the quote *re-findable*: the same text
408/// can occur twice, and a little of what surrounded it is how a later reader —
409/// or the same document after an edit — tells the occurrences apart. The Web
410/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
411/// the name.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct Quote {
414 /// The selected source, verbatim.
415 pub exact: String,
416 /// What immediately preceded it — possibly empty, at the document's start.
417 pub prefix: String,
418 /// What immediately followed it — possibly empty, at the document's end.
419 pub suffix: String,
420 /// Byte offset in the source where the selection begins.
421 pub start: usize,
422 /// Byte offset where it ends (exclusive).
423 pub end: usize,
424}
425
426/// A host-painted range of the source — an annotation's footprint, a search
427/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
428/// glyphs whose source falls inside it) and hands back the `id` when the
429/// reader activates it; what the range *means* is entirely the host's.
430///
431/// Ranges are source bytes, like the caret and the selection, so a host that
432/// anchors quotes against the source ([`Doc::selection_quote`] is the other
433/// half of that loop) can paint what it found without any coordinate
434/// conversion. A range that drifts off the text it meant is the host's to
435/// re-anchor; leaf draws what it is told.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct Highlight {
438 /// Byte offset in the source where the wash begins.
439 pub start: usize,
440 /// Byte offset where it ends (exclusive).
441 pub end: usize,
442 /// The host's name for it, handed back on activation. Opaque to leaf.
443 pub id: String,
444 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
445 /// nothing for the theme's default wash.
446 pub color: Option<String>,
447 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
448 /// a marker gets a small glyph in the margin beside its first line, and
449 /// the glyph — not the wash — is what activates it: the wash is ink, the
450 /// marker is the control, which is what lets a reader put a caret in (or
451 /// copy from) annotated text without a card leaping at them. The name is
452 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
453 /// as a class.
454 pub marker: Option<String>,
455}
456
457impl Highlight {
458 /// The range covering source `offset` in a list [`Doc::set_highlights`]
459 /// sorted, first by start where several overlap — the one place that
460 /// question is answered, for the frontends that paint by asking it as well
461 /// as for [`Doc::highlight_at`].
462 ///
463 /// The list is sorted by `(start, end)`, so the scan can stop at the first
464 /// range starting past `offset` rather than running to the end. A painter
465 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
466 /// one-shot form, for the host asking what the reader just activated.
467 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
468 highlights
469 .iter()
470 .take_while(|h| h.start <= offset)
471 .find(|h| offset < h.end)
472 }
473}
474
475/// [`Highlight::covering`] for a caller walking the document in order — which
476/// is every painter, since a frontend draws rows top to bottom and glyphs left
477/// to right.
478///
479/// The one-shot form is a scan from the front of the list per glyph, and a
480/// document with two hundred search hits pays that two hundred times a row. A
481/// range that ends at or before an offset can never cover that offset *or any
482/// later one*, so the cursor retires those permanently and each glyph costs the
483/// ranges that actually reach it. The answer is identical to
484/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
485/// the part that was being redone dropped, not a cheaper approximation.
486///
487/// Offsets are expected to arrive non-decreasing. One that goes backwards is
488/// still answered correctly: the cursor re-seats to the front, since a painter
489/// that revisits a row is asking a question the retired ranges may own again.
490pub struct HighlightCursor<'a> {
491 highlights: &'a [Highlight],
492 /// The first range not yet retired.
493 at: usize,
494 /// The last offset asked about, to notice a caller going backwards.
495 last: usize,
496}
497
498impl<'a> HighlightCursor<'a> {
499 pub fn new(highlights: &'a [Highlight]) -> Self {
500 HighlightCursor {
501 highlights,
502 at: 0,
503 last: 0,
504 }
505 }
506
507 /// The range covering `offset`, advancing the cursor past every range that
508 /// can no longer cover anything.
509 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
510 if offset < self.last {
511 self.at = 0;
512 }
513 self.last = offset;
514 while self
515 .highlights
516 .get(self.at)
517 .is_some_and(|h| h.end <= offset)
518 {
519 self.at += 1;
520 }
521 Highlight::covering(&self.highlights[self.at..], offset)
522 }
523}
524
525/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
526/// purpose: the only useful question is whether two of them are the same map,
527/// and what is behind it — which `Doc` built it, and the (revision, wrap,
528/// reveal line) it was built from — is core's business.
529///
530/// The document is part of it because the rest is not unique to one: two
531/// documents opened at the same width are both at revision zero with no reveal
532/// line, and a frontend holding one copy of a map across the two would take
533/// the second's key for the first's and paint the wrong document.
534#[derive(Clone, PartialEq, Eq, Debug)]
535pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
536
537pub struct Doc {
538 editor: Editor,
539 pub format: Format,
540 pub path: PathBuf,
541 /// Current source, refreshed from the editor after every successful edit.
542 pub source: String,
543 /// The caret, as a byte offset into `source` (always on a char boundary).
544 pub caret: usize,
545 /// The selection's fixed end, if a selection is active; the moving end is
546 /// the caret. `None` means no selection.
547 pub anchor: Option<usize>,
548 pub dirty: bool,
549 pub status: Option<String>,
550 pub view: View,
551 /// Whether the document refuses to change — a *reading* surface over the
552 /// same rendering, selection, and navigation the editor has.
553 ///
554 /// Enforced here rather than by each frontend hiding its input paths,
555 /// because every mutation funnels through a few doors —
556 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
557 /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
558 /// verbs directly rather than through the splice (a typed literal, a link,
559 /// an image, a rule, a footnote, a cell's line break) — and guarded doors
560 /// are a guarantee where a frontend's suppressed keyboard is a hope. A
561 /// gated door reports exactly like a rolled-back splice, a path every
562 /// caller already handles. `a_read_only_document_refuses_every_door` is
563 /// the list; a new `self.editor.insert_*` call belongs on it.
564 read_only: bool,
565 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
566 /// State like the selection rather than like the text: no edit history,
567 /// no dirty bit, redrawn from whatever the host last set.
568 highlights: Vec<Highlight>,
569 /// How much of the source markup the rich view exposes — a frontend preference (see
570 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
571 /// [`reveal_line`](Self::reveal_line), the editing one by
572 /// [`insert`](Self::insert).
573 markup_mode: MarkupMode,
574 /// Whether soft breaks fold into the reflowed paragraph or render where
575 /// they were written (see [`LineFlow`]) — an independent frontend
576 /// preference the WYSIWYG builder consults when it lays out a block.
577 line_flow: LineFlow,
578 /// The kind of the last edit, for coalescing: twig owns the undo *history*
579 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
580 /// call, so leaf decides when a run continues and tells twig to coalesce.
581 last_edit_kind: Option<EditKind>,
582 /// The inline marks the user has toggled *at a collapsed caret* with no
583 /// selection — "start typing bold here". Held as the XOR delta from the marks
584 /// already in force at [`pending_at`](Self::pending_at): a set bit means
585 /// "flip this kind for the next typed text", so it both turns a mark on where
586 /// none is (type into bold) and off where one already covers the caret (type
587 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
588 /// freshly typed text and then clears it — a mark once realised is carried by
589 /// the caret sitting inside the run, not by this delta.
590 pending_marks: InlineMarks,
591 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
592 /// delta is live only while the caret still stands here with no selection;
593 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
594 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
595 pending_at: Option<usize>,
596 /// The source as of the last open/save — `dirty` is `source != clean_source`,
597 /// so undoing back to the saved state correctly clears the modified flag.
598 clean_source: String,
599 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
600 /// while the document has no file behind it. [`Doc::disk_state`] compares
601 /// the file against this to catch an edit made *outside* leaf before a save
602 /// silently overwrites it — `clean_source` only knows what leaf itself did.
603 ///
604 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
605 /// writes inside one filesystem timestamp tick are indistinguishable, a
606 /// clock that steps backwards (or a writer that restores an mtime) hides a
607 /// real change, and a `touch` invents one. The whole point of the watermark
608 /// is to not clobber someone's work, so it reads the bytes and compares what
609 /// is actually there. That costs a file read per question, which is why the
610 /// question is asked on a user event (focus, save) and not every frame.
611 disk_hash: Option<u64>,
612 /// The "sticky" display column vertical motion aims for, in the active
613 /// view's grid. Set on the first `move_up`/`move_down` of a run and
614 /// reused by every subsequent one in that run, so passing through a
615 /// shorter line doesn't permanently forget the original column. Any
616 /// horizontal motion or edit clears it.
617 ///
618 /// A column, not a character index: dropping down a line of `你好` onto one
619 /// of ASCII has to land under the glyph the caret was drawn beneath, which
620 /// is the only thing the user can see to aim by. Where the goal falls inside
621 /// a wide character on the target line, the mapping resolves it to that
622 /// character — the caret lands on it rather than between its cells.
623 goal_col: Option<usize>,
624 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
625 /// and clicks read it to stay in visible space.
626 pub vmap: VisualMap,
627 /// The syntax map for the source view; empty in the WYSIWYG view, which
628 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
629 /// frontend that never calls it paints raw source unstyled, which is what
630 /// every frontend did before this map existed.
631 pub smap: SourceMap,
632 /// The revision `smap` was built from, or `None` before the first build.
633 /// The map is a pure function of the text alone — no width, no caret, no
634 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
635 /// whole key.
636 smap_key: Option<u64>,
637 /// Everything the map is built from, as one number: bumped whenever the
638 /// document's text changes, and never by a motion, a selection, or a save.
639 /// A frontend can hold work against it — see [`Doc::revision`].
640 revision: u64,
641 /// How many history steps stand behind the caret, and how many ahead of
642 /// it — the answer to a native Edit menu's "may Undo be enabled?", which
643 /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
644 /// the funnel every edit comes through, and moved back and forth by
645 /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
646 /// an exact depth: a coalesced run of typing is one of twig's steps but
647 /// several of these, and twig's own cap on history is not mirrored here.
648 /// Neither error can make `can_undo` false while a step remains, which is
649 /// the only property a menu needs; the one place the bound can be wrong the
650 /// other way — the cap has retired every step — is reconciled the moment
651 /// twig reports nothing to undo.
652 undo_steps: usize,
653 redo_steps: usize,
654 /// What `vmap` was built from, or `None` before the first build. The map is
655 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
656 /// moved, rebuilding it produces the identical map — see
657 /// [`Doc::build_visual`].
658 ///
659 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
660 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
661 /// text and width alone, and a caret motion still rebuilds nothing.
662 vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
663 /// Which `Doc` this is, distinct from every other one built in this
664 /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
665 /// can never be mistaken for another document's — see
666 /// [`Doc::visual_key`]. Nothing else reads it.
667 identity: u64,
668 /// Per-block row cache backing the incremental rebuild: when the text
669 /// changes, only the top-level blocks whose bytes moved are re-rendered and
670 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
671 /// builds; a pure accelerator, so it's never read for correctness.
672 block_cache: wysiwyg::BlockCache,
673 /// What the frontend has said about itself — how tall its pictures came
674 /// out, keyed by destination or by TeX, and whether it paints a picture in
675 /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`] and
676 /// [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
677 /// so this is the only way it learns a height or a capability. Threaded
678 /// into every build; a change drops both caches, since none of it is in a
679 /// block's bytes.
680 surface: wysiwyg::Surface,
681
682 // View geometry the renderer stamps each frame, so mouse events can map a
683 // screen cell back to a byte offset.
684 pub scroll: usize,
685 pub body_origin: (u16, u16),
686 /// Width of the body rectangle last painted by the frontend. Zero means
687 /// unknown (used by tests or a frontend that has not drawn yet).
688 pub body_width: u16,
689 pub body_height: u16,
690 /// The caret as of the last frame drawn, or `None` before the first.
691 ///
692 /// Scrolling is the viewport's business, not the caret's: the view follows
693 /// the caret when the caret *moves*, but a wheel that doesn't touch the
694 /// caret has to be free to scroll away from it — otherwise the view is
695 /// pinned to the caret and stops dead at the edge of the document you can
696 /// see. Comparing against this is what tells the two apart, and it catches a
697 /// caret set by any route, including a frontend assigning the field itself.
698 pub drawn_caret: Option<usize>,
699}
700
701/// The Markdown extensions every leaf document is parsed with — five of them,
702/// each departing from twig's defaults for a reason leaf can state.
703///
704/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
705/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
706/// node the frontends can frame and rasterize instead of opaque `raw_block`
707/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
708/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
709/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
710/// plain tinted container, agnostic of `name`.
711///
712/// `highlight` and `highlight_colors` are the pair that makes Markdown read
713/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
714/// `data-color`. leaf already had somewhere to put both: the
715/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
716/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
717/// from a Djot one it was converted from — the button wrote `==…==` and the
718/// reparse read it straight back as text.
719/// They are on together because a colour is inert without the highlight itself,
720/// and a document that writes `==🔴 x==` means the colour by it.
721///
722/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
723/// a `display_math` one — what djot reads natively and what leaf has
724/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
725/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
726/// twig has already read as an escaped comma, and an author who types a
727/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
728/// twig's own rule that a dollar followed by whitespace never opens math, so
729/// `$5 and $6` stays prose.
730///
731/// Every flag is inert for non-Markdown formats, so it's safe to pass them
732/// unconditionally. Threading this through every constructor (not just `open`)
733/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
734/// way — twig reparses with these same flags after each edit.
735pub(crate) fn parse_extensions() -> MarkdownExtensions {
736 MarkdownExtensions {
737 html_elements: true,
738 directives: true,
739 highlight: true,
740 highlight_colors: true,
741 math: true,
742 }
743}
744
745/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
746/// mapping twig's error into the `anyhow` context every constructor shares.
747fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
748 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
749}
750
751/// Does `format` spell a table as a **pipe table** — the one grid twig's table
752/// editor knows how to emit?
753///
754/// This is the single capability leaf still has to answer for itself, and the
755/// only hand-maintained format list left in this file. Every other gesture is
756/// [`Format::supports`], which is twig's own answer read across the C ABI — but
757/// twig deliberately leaves the table ops out of that query, because they read
758/// no `Syntax` table at all. They rewrite a grid that is already in the source
759/// and refuse on *position*, never on format. Handed a caret inside an HTML
760/// `<table>`, `table_insert_row` therefore re-emits the whole element as
761/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
762/// `dirty` flag, and nothing downstream able to tell it from a good edit.
763///
764/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
765/// wildcard answers "no" for a format leaf has never heard of: a new twig
766/// language that *does* spell pipe tables loses its grid controls until this
767/// line is updated, which shows up as a missing button. The other default hands
768/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
769fn spells_pipe_tables(format: Format) -> bool {
770 matches!(format, Format::Markdown | Format::Djot)
771}
772
773/// Which of leaf's authoring controls this document's format can actually
774/// spell — one flag per toolbar button, resolved once so a frontend can build
775/// its chrome instead of discovering each refusal on a click.
776///
777/// Every field but [`table`](Self::table) is `Format::supports_with` on the
778/// gesture the matching [`Doc`] method calls, so this record cannot drift from
779/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
780/// doesn't export.
781///
782/// `supports_with` rather than `supports` because two of these are facts about
783/// the *parse options* as much as about the format. `Format::supports` answers
784/// for twig's defaults, and leaf never parses with those — it parses with
785/// [`parse_extensions`], and a document's toolbar has to describe the document
786/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
787/// without it would mint bytes its own reparse hands back as plain text, which
788/// is why twig asks before it writes.
789///
790/// **The formats are ragged, and that is the point.** A single per-document
791/// boolean was enough while the two authorable formats were Markdown and djot
792/// and everything else spelled nothing. HTML is neither: it writes seven of the
793/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
794/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
795/// pair, and since 3.5 a quote, a list, a code block, a link and an image
796/// printed as fresh nodes; it spells no task box (a form control there) and
797/// no footnote, and its `<table>` is one twig reads but will not write. So
798/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
799/// table buttons do not, and no one flag can say that. Markdown and djot
800/// differ from each other too:
801/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
802#[derive(Clone, Copy, Debug, Eq, PartialEq)]
803pub struct Capabilities {
804 /// ⌘B — `InlineKind::Strong`.
805 pub bold: bool,
806 /// ⌘I — `InlineKind::Emph`.
807 pub italic: bool,
808 /// Inline code — `InlineKind::Verbatim`.
809 pub code: bool,
810 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
811 /// under the `highlight` extension [`parse_extensions`] turns on: the
812 /// button writes `==text==`, which is what the reparse reads back.
813 pub mark: bool,
814 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
815 pub underline: bool,
816 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
817 /// out of the box, since twig parses it out of the box.
818 pub strike: bool,
819 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
820 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
821 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
822 /// so a toolbar offering the swatches wherever the button lights would offer
823 /// them in a document that cannot write one. Pair with
824 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
825 /// a highlight to colour as much as a format that spells one.
826 pub mark_color: bool,
827 pub superscript: bool,
828 pub subscript: bool,
829 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
830 pub heading: bool,
831 pub blockquote: bool,
832 pub bullet_list: bool,
833 pub ordered_list: bool,
834 /// The checkbox controls: giving an item a box, and ticking one.
835 pub task: bool,
836 pub link: bool,
837 /// Covers [`Doc::insert_media`] too — see the note there on why the three
838 /// media kinds stand or fall together.
839 pub image: bool,
840 /// The horizontal-rule button. HTML spells this one (`<hr>`).
841 pub thematic_break: bool,
842 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
843 /// the pair; HTML has no footnote of its own, so the button goes away rather
844 /// than writing brackets that would render as brackets.
845 pub footnote: bool,
846 /// Setting a fenced block's language — a control only ever offered with the
847 /// caret already in a fence.
848 pub code_language: bool,
849 /// The grid controls: insert/delete/move a row or column, set a column's
850 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
851 /// question — an HTML `<table>` holds the caret and still can't be edited.
852 pub table: bool,
853 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
854 /// idiomatic in-cell break.
855 pub cell_line_break: bool,
856 /// The alignment control — [`Doc::set_alignment`], twig's
857 /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
858 /// block's attributes, Markdown under the `html_elements`
859 /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
860 /// through its `[…]` line.
861 pub alignment: bool,
862 /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
863 /// [`alignment`](Self::alignment) and so the same answer, and its own flag
864 /// because a toolbar dims controls one at a time and the pair may yet
865 /// diverge.
866 pub line_spacing: bool,
867 /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
868 /// over a selection. **Narrower than the block pair**: AsciiDoc's
869 /// `[#id.role]#text#` keeps an id and a role and has no slot for a
870 /// `data-` key, so twig refuses the span there and this is `false` while
871 /// [`alignment`](Self::alignment) is `true`. The block-level form of the
872 /// same property — the caret in a paragraph, no selection — goes through
873 /// `SetBlockAttrs` and still works, which is why the flag describes the
874 /// control rather than the caret.
875 pub font_size: bool,
876 /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
877 /// [`font_size`](Self::font_size) is.
878 pub font_family: bool,
879 /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
880 /// and not to be confused with [`mark_color`](Self::mark_color): that is a
881 /// highlight's background and rides the `mark` node twig already owns,
882 /// this is a run's foreground and rides an attributed span.
883 pub text_color: bool,
884 /// The page-break button — [`Doc::insert_page_break`], twig's
885 /// `Gesture::InsertDirective`. Markdown under the `directives` extension
886 /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
887 /// it as an empty `::: page-break` fence.
888 ///
889 /// **Those two and no others**, though twig spells the gesture in HTML and
890 /// AsciiDoc as well — see [`Capabilities::of`].
891 pub page_break: bool,
892}
893
894impl Capabilities {
895 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
896 /// twig computes each from a static table — but a frontend that wants to
897 /// hold them can.
898 ///
899 /// The extensions are not a parameter because they are not a choice a
900 /// caller makes: every leaf document is parsed with [`parse_extensions`],
901 /// so the format is the whole of what varies.
902 pub fn of(format: Format) -> Self {
903 let exts = parse_extensions();
904 let supports = |g| format.supports_with(exts, g);
905 let inline = |k| supports(Gesture::ToggleInline(k));
906 let container = |k| supports(Gesture::ToggleBlockContainer(k));
907 Self {
908 bold: inline(InlineKind::Strong),
909 italic: inline(InlineKind::Emph),
910 code: inline(InlineKind::Verbatim),
911 mark: inline(InlineKind::Mark),
912 underline: inline(InlineKind::Insert),
913 strike: inline(InlineKind::Delete),
914 mark_color: supports(Gesture::SetMarkColor),
915 superscript: inline(InlineKind::Superscript),
916 subscript: inline(InlineKind::Subscript),
917 heading: supports(Gesture::SetBlock),
918 blockquote: container(BlockContainerKind::BlockQuote),
919 bullet_list: container(BlockContainerKind::BulletList),
920 ordered_list: container(BlockContainerKind::OrderedList),
921 // Both halves of the checkbox story, and leaf offers no control that
922 // needs only one: the item gesture mints the box, the checked one
923 // ticks it, and a format spelling a `task_marker` spells both.
924 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
925 link: supports(Gesture::InsertLink),
926 image: supports(Gesture::InsertImage),
927 thematic_break: supports(Gesture::InsertThematicBreak),
928 footnote: supports(Gesture::InsertFootnote),
929 code_language: supports(Gesture::SetCodeLanguage),
930 table: spells_pipe_tables(format),
931 cell_line_break: supports(Gesture::InsertLineBreak),
932 // The presentation vocabulary, one gesture per level: the two
933 // line-level properties are a block's attributes and the three
934 // run-level ones a span's. They are asked separately because the
935 // formats answer differently — AsciiDoc spells the block and not
936 // the span — and a toolbar that dimmed all five together would dim
937 // three controls that work.
938 alignment: supports(Gesture::SetBlockAttrs),
939 line_spacing: supports(Gesture::SetBlockAttrs),
940 font_size: supports(Gesture::WrapRangeAttrs),
941 font_family: supports(Gesture::WrapRangeAttrs),
942 text_color: supports(Gesture::WrapRangeAttrs),
943 // Narrower than the gesture, on purpose. Twig spells
944 // `InsertDirective` in HTML and AsciiDoc too, and spells it
945 // *differently* there — `<page-break></page-break>` and `<<<` —
946 // and the walker reads only the two spellings above. An HTML page
947 // break draws as nothing at all (no row, no caret home) and an
948 // AsciiDoc one as an empty unlabelled row, so the button would
949 // write a break the author cannot see and cannot get back to.
950 // The proposal claims Markdown and djot, and this is that claim.
951 // Widening it is the walker's work, not this line's — see
952 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
953 page_break: supports(Gesture::InsertDirective)
954 && matches!(format, Format::Markdown | Format::Djot),
955 }
956 }
957}
958
959/// The source of [`Doc::identity`], one per document ever built.
960static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
961
962impl Doc {
963 #[cfg(feature = "fs")]
964 pub fn open(path: PathBuf) -> Result<Self> {
965 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
966 Self::from_disk_bytes(path, bytes)
967 }
968
969 /// An empty document *named* `path`, for a file that isn't there yet — what
970 /// every other terminal editor gives you when you name a file that doesn't
971 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
972 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
973 /// the header shows the name the user asked for.
974 ///
975 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
976 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
977 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
978 /// parse is still an error: a mistyped flag or a stray argument should say
979 /// so, not open a buffer promising to save somewhere.
980 ///
981 /// The watermark is the hash of *no bytes*, not `None`, and that is the
982 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
983 /// answering [`DiskState::Untitled`] for a document that has a path and
984 /// intends to write to it. Hashing `""` instead makes the answers the true
985 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
986 /// recreates it, which is exactly what this is for), and
987 /// [`DiskState::Changed`] if somebody creates it underneath us between
988 /// launch and save, so the frontend's overwrite prompt guards a new file as
989 /// it guards an opened one.
990 ///
991 /// Nothing is written here. A buffer that is never typed into never touches
992 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
993 /// open — the write is where that fails, and it says so then.
994 #[cfg(feature = "fs")]
995 pub fn create(path: PathBuf) -> Result<Self> {
996 Self::from_disk_bytes(path, Vec::new())
997 }
998
999 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1000 /// the call a CLI frontend wants for its path argument.
1001 ///
1002 /// The decision is made from the failed read itself rather than a `exists()`
1003 /// check first, so there is no window between the two for the file to appear
1004 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1005 /// a directory in the way is still an error, because pretending those are
1006 /// "no file yet" would offer to save over something leaf couldn't read.
1007 #[cfg(feature = "fs")]
1008 pub fn open_or_create(path: PathBuf) -> Result<Self> {
1009 match std::fs::read(&path) {
1010 Ok(bytes) => Self::from_disk_bytes(path, bytes),
1011 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1012 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1013 }
1014 }
1015
1016 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1017 /// stand in for) the file at `path`, parsed as the format its extension
1018 /// names. Keeping the two on one path is what makes a new file's document
1019 /// identical in every respect to an opened one but its contents.
1020 #[cfg(feature = "fs")]
1021 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1022 let format = detect_format(&path)?;
1023 let editor = new_editor(&bytes, format)?;
1024 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1025 let disk_hash = Some(hash_bytes(source.as_bytes()));
1026 // Store the document's *absolute* path. A relative one (`leaf README.md`)
1027 // has an empty parent, so a frontend can't resolve a relative image
1028 // destination (``) against the document's directory and the
1029 // picture silently falls back to its text placeholder. `absolute` is
1030 // purely lexical — it prefixes the current directory and normalizes, but
1031 // reads nothing and resolves no symlinks — so `file_name` and save are
1032 // unchanged; it only gives `path.parent()` something to join against.
1033 let path = std::path::absolute(&path).unwrap_or(path);
1034 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1035 }
1036
1037 /// Build a document from an in-memory string, the format named explicitly —
1038 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1039 /// path and sniffs the format from its extension). A wasm or FFI host, which
1040 /// has no path to read, uses this: it hands over bytes it fetched however it
1041 /// could, and later persists [`Doc::source`] however it can (a browser
1042 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1043 ///
1044 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1045 /// true) exactly like a [`Doc::blank`] that has been given content.
1046 pub fn from_source(source: String, format: Format) -> Result<Self> {
1047 let editor = new_editor(source.as_bytes(), format)?;
1048 Ok(Doc::from_parts(
1049 editor,
1050 format,
1051 PathBuf::new(),
1052 source,
1053 None,
1054 ))
1055 }
1056
1057 /// An untitled, empty document — the `+` button and a `leaf` launched with
1058 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1059 ///
1060 /// It is Markdown, because a format has to be chosen before a name exists to
1061 /// read one from: `detect_format` reads the extension and an untitled
1062 /// document has neither. Markdown is what leaf's own files are, what its
1063 /// block markers are already written for (`insert_block_prefix`), and the
1064 /// extension a Save As will overwhelmingly pick — a wrong guess here would
1065 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1066 /// *doesn't* revisit this: see [`Doc::save_as`].
1067 pub fn blank() -> Result<Self> {
1068 let format = Format::Markdown;
1069 let editor = new_editor(b"", format)?;
1070 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1071 // field two frontends already read; making it an `Option` to say this
1072 // would break both). `is_untitled` is the question to ask, not the
1073 // representation to copy.
1074 Ok(Doc::from_parts(
1075 editor,
1076 format,
1077 PathBuf::new(),
1078 String::new(),
1079 None,
1080 ))
1081 }
1082
1083 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1084 /// apart in the ones neither of them has an opinion about.
1085 // `identity` is taken from a counter rather than from the `Doc`'s address,
1086 // which moves — a session that holds one is moved into and out of
1087 // containers freely, and an identity that changed with it would defeat the
1088 // one comparison it exists for.
1089 fn from_parts(
1090 editor: Editor,
1091 format: Format,
1092 path: PathBuf,
1093 source: String,
1094 disk_hash: Option<u64>,
1095 ) -> Self {
1096 Doc {
1097 editor,
1098 format,
1099 path,
1100 disk_hash,
1101 clean_source: source.clone(),
1102 source,
1103 caret: 0,
1104 anchor: None,
1105 dirty: false,
1106 status: None,
1107 read_only: false,
1108 highlights: Vec::new(),
1109 // leaf opens in the rich-text (WYSIWYG) view by default — the
1110 // markup-resolved surface is leaf's differentiator. Frontends can
1111 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1112 // toggles at runtime.
1113 view: View::Wysiwyg,
1114 // `None` by default — the clean surface Diaryx ships, with typed
1115 // syntax kept literal; a markup-fluent frontend can climb the
1116 // ladder to `Shortcuts` or `Full`.
1117 markup_mode: MarkupMode::default(),
1118 // Fold by default — flowing prose that reflows to the viewport, the
1119 // behaviour every frontend had before this preference existed.
1120 line_flow: LineFlow::default(),
1121 last_edit_kind: None,
1122 pending_marks: InlineMarks::empty(),
1123 pending_at: None,
1124 goal_col: None,
1125 vmap: VisualMap::default(),
1126 smap: SourceMap::default(),
1127 // No map yet — the first `build_source` always builds.
1128 smap_key: None,
1129 revision: 0,
1130 undo_steps: 0,
1131 redo_steps: 0,
1132 // No map yet — the first `build_visual` always builds.
1133 vmap_key: None,
1134 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1135 block_cache: wysiwyg::BlockCache::default(),
1136 surface: wysiwyg::Surface::default(),
1137 scroll: 0,
1138 body_origin: (0, 0),
1139 body_width: 0,
1140 body_height: 0,
1141 drawn_caret: None,
1142 }
1143 }
1144
1145 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1146 /// has never been saved. The question a ⌘S handler asks to know it should
1147 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1148 /// header asks to know the name it shows is a placeholder.
1149 pub fn is_untitled(&self) -> bool {
1150 self.path.as_os_str().is_empty()
1151 }
1152
1153 pub fn toggle_view(&mut self) {
1154 self.view = match self.view {
1155 View::Source => View::Wysiwyg,
1156 View::Wysiwyg => View::Source,
1157 };
1158 self.scroll = 0;
1159 self.status = None;
1160 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1161 // lift it to the first rendered offset.
1162 self.clamp_caret();
1163 }
1164
1165 /// The current markup-exposure preference (see [`MarkupMode`]).
1166 pub fn markup_mode(&self) -> MarkupMode {
1167 self.markup_mode
1168 }
1169
1170 /// Set the markup-exposure preference. Both of its axes take effect at
1171 /// once: the editing one on the next [`insert`](Self::insert), and the
1172 /// rendering one on the next build — which is why this drops the cached
1173 /// visual map and the per-block render cache, exactly as
1174 /// [`set_line_flow`](Self::set_line_flow) does.
1175 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1176 if self.markup_mode == mode {
1177 return;
1178 }
1179 self.markup_mode = mode;
1180 // Neither cache is keyed on the mode, and moving between `Full` and the
1181 // hidden modes changes every row the caret's line renders to — so
1182 // invalidate both explicitly.
1183 self.vmap_key = None;
1184 self.block_cache = wysiwyg::BlockCache::default();
1185 }
1186
1187 /// The line the caret sits on, when that line should render something
1188 /// raw — `None` when nothing on it would, which is what the builder reads
1189 /// as "reveal nothing" and what keeps caret motion from costing a build.
1190 ///
1191 /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1192 /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1193 /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1194 /// formula's content is not its picture and hiding the `$` alone would
1195 /// leave nothing to edit; there the line is threaded through only when it
1196 /// meets a block that holds one, which the last build's layout knows
1197 /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1198 /// keeps the `None` it always had, and one with math pays a rebuild only
1199 /// while the caret is in the formula's block.
1200 ///
1201 /// A *source* line (newline to newline), not a visual row: a wrapped
1202 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1203 /// line across several rows, and revealing half a delimiter pair because the
1204 /// other half wrapped would be worse than revealing neither. The range
1205 /// excludes the terminating newline and is empty-but-present on a blank
1206 /// line, which reveals nothing but still keys the caches correctly.
1207 ///
1208 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1209 /// there is nothing there to reveal.
1210 pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1211 if self.view != View::Wysiwyg {
1212 return None;
1213 }
1214 let line = source_line_range(&self.source, self.caret);
1215 if self.markup_mode.reveals_caret_line() {
1216 return Some(Reveal::full(line));
1217 }
1218 self.block_cache
1219 .math_meets(&line)
1220 .then_some(Reveal::math(line))
1221 }
1222
1223 /// The current soft-break flow preference (see [`LineFlow`]).
1224 pub fn line_flow(&self) -> LineFlow {
1225 self.line_flow
1226 }
1227
1228 /// Set the soft-break flow preference. The mode changes how every block lays
1229 /// out, so a change drops the cached visual map and the per-block render
1230 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1231 ///
1232 /// [`build_visual`]: Self::build_visual
1233 pub fn set_line_flow(&mut self, mode: LineFlow) {
1234 if self.line_flow == mode {
1235 return;
1236 }
1237 self.line_flow = mode;
1238 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1239 // so invalidate them explicitly, or the next build would reuse rows laid
1240 // out under the old flow.
1241 self.vmap_key = None;
1242 self.block_cache = wysiwyg::BlockCache::default();
1243 }
1244
1245 pub fn view_name(&self) -> &'static str {
1246 match self.view {
1247 View::Source => "source",
1248 View::Wysiwyg => "wysiwyg",
1249 }
1250 }
1251
1252 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1253 /// (called by the renderer each frame it's in the WYSIWYG view).
1254 /// Build the WYSIWYG map, wrapped at `width` display columns.
1255 ///
1256 /// Cheap to call every frame, which is what both frontends do: the map is a
1257 /// pure function of the document and the wrap width, so a call that would
1258 /// rebuild the same map returns the one already built. Only an edit (or a
1259 /// resize) pays.
1260 ///
1261 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1262 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1263 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1264 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1265 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1266 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1267 pub fn build_visual(&mut self, width: usize) {
1268 self.build_map(Some(width));
1269 }
1270
1271 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1272 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1273 /// than a fixed character column.
1274 pub fn build_visual_unwrapped(&mut self) {
1275 self.build_map(None);
1276 }
1277
1278 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1279 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1280 /// styling for [`View::Wysiwyg`].
1281 ///
1282 /// A frontend calls this before painting raw source. One that doesn't gets
1283 /// an empty map and paints unstyled text, so this is additive: nothing
1284 /// breaks by not calling it.
1285 ///
1286 /// Built at most once per revision, and the revision is the whole key — the
1287 /// map has no width and no caret in it, so it survives every resize, every
1288 /// motion, and every selection change.
1289 ///
1290 /// The builds it does do cost a whole-arena marshal, which is precisely what
1291 /// the WYSIWYG path works to avoid, so this has no incremental path where
1292 /// that one has two. From `cargo run --release -p leaf-core --example
1293 /// bench`, per keystroke, against the WYSIWYG build the source view is
1294 /// *not* doing:
1295 ///
1296 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1297 /// |------:|-------:|--------:|----------------:|-------------------:|
1298 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1299 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1300 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1301 ///
1302 /// Linear, two thirds of it the marshal, and the build itself five to seven
1303 /// times cheaper than the one it stands in for at every size. Comfortable
1304 /// well past any document a person edits in a terminal — a megabyte is where
1305 /// it would want [`Editor::dirty_range`] and the same splice treatment
1306 /// `build_spliced` gives the other map. The door is open; nothing has needed
1307 /// it yet.
1308 pub fn build_source(&mut self) {
1309 if self.smap_key == Some(self.revision) {
1310 return;
1311 }
1312 let nodes = self.nodes();
1313 self.smap = source::build(&nodes, &self.source);
1314 self.smap_key = Some(self.revision);
1315 }
1316
1317 /// Tell the model how many visual rows each block image should reserve, keyed
1318 /// by the image's destination. A terminal frontend calls this once it has
1319 /// decoded and measured its pictures — core does no image I/O, so this is the
1320 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1321 /// placeholder out that tall (the label row plus blank filler rows the
1322 /// frontend paints the raster over). A destination left out of the map falls
1323 /// back to the bare one-row placeholder, which is also what a frontend that
1324 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1325 /// by never calling this.
1326 ///
1327 /// Cheap to call every frame with the same map: only a *change* invalidates
1328 /// the built map (and the block-row cache, since a height isn't part of a
1329 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1330 /// no-op, so a frontend can just hand over its current measurements each frame.
1331 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1332 if self.surface.media_rows == rows {
1333 return;
1334 }
1335 self.surface.media_rows = rows;
1336 self.surface_changed();
1337 }
1338
1339 /// Tell the model how many visual rows each display formula should
1340 /// reserve, keyed by the formula's TeX exactly as the map's
1341 /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1342 /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1343 /// typesets the picture, measures it in cells, and reports back; a
1344 /// frontend that lays formulas out in pixels never calls this and gets
1345 /// the one-row placeholder to paint over.
1346 pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1347 if self.surface.math_rows == rows {
1348 return;
1349 }
1350 self.surface.math_rows = rows;
1351 self.surface_changed();
1352 }
1353
1354 /// Tell the model whether the frontend can paint a picture *inside* a line
1355 /// of text. When it can, an inline formula renders to one atom glyph the
1356 /// frontend draws its typeset picture over — see
1357 /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1358 /// the code-styled TeX it always showed. Off until a frontend says
1359 /// otherwise, so a host that has not caught up sees what it saw.
1360 pub fn set_inline_pictures(&mut self, on: bool) {
1361 if self.surface.inline_pictures == on {
1362 return;
1363 }
1364 self.surface.inline_pictures = on;
1365 self.surface_changed();
1366 }
1367
1368 /// A height or a capability lives outside a block's source bytes, so the
1369 /// content-keyed block cache would hand back the old rows on a hit. Drop
1370 /// it (and the splice layout it carries) so the next build re-renders
1371 /// every block against the new surface, and force that build by clearing
1372 /// the map key.
1373 fn surface_changed(&mut self) {
1374 self.block_cache = wysiwyg::BlockCache::default();
1375 self.vmap_key = None;
1376 }
1377
1378 /// The revision the document's text is at — bumped by every edit, undo,
1379 /// redo, and reload, and by nothing else. A frontend caches against this to
1380 /// tell a repaint that needs new work from one that doesn't.
1381 ///
1382 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1383 /// lands on the same text two revisions later. Work is only ever rebuilt
1384 /// needlessly, never wrongly reused.
1385 pub fn revision(&self) -> u64 {
1386 self.revision
1387 }
1388
1389 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1390 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1391 /// unbuilt map before the first one.
1392 ///
1393 /// This is *not* [`revision`](Self::revision). The revision says where the
1394 /// text is; this says where the map is, and the two part company the moment
1395 /// an edit lands, until something rebuilds. A frontend that keeps its own
1396 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1397 /// under an oversized heading — compares this against the value it held when
1398 /// it took the copy, and learns whether `vmap` is still the map it stashed
1399 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1400 /// map would paint a stale document; restoring nothing hands core's
1401 /// incremental rebuild a map it never built.
1402 ///
1403 /// "Somebody else" includes another document. The key names the `Doc`
1404 /// as well as the build, so a frontend that draws two documents through
1405 /// one stash — a host with several buffers, or one that opens the next
1406 /// document where the last one stood — never has the copy it took of one
1407 /// accepted by the other, however alike their builds are.
1408 pub fn visual_key(&self) -> VisualKey {
1409 VisualKey(self.identity, self.vmap_key.clone())
1410 }
1411
1412 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1413 /// runs on every call: the caret moves without the document changing, and
1414 /// keeping it on a legal stop is this function's job either way.
1415 fn build_map(&mut self, wrap: Option<usize>) {
1416 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1417 // as well as the text, so the line joins the key: moving within a line
1418 // still reuses the map, and crossing into another one rebuilds it. In
1419 // every other mode `reveal_line` is `None` and the key is what it was,
1420 // so caret motion goes on costing nothing.
1421 let reveal = self.reveal_line();
1422 let key = (self.revision, wrap, reveal.clone());
1423 if self.vmap_key.as_ref() != Some(&key) {
1424 self.build_map_with(wrap, reveal);
1425 self.vmap_key = Some(key);
1426 // In a hidden mode the reveal line was decided from the *previous*
1427 // build's layout, whose spans are stale across an edit: the
1428 // keystroke that closes a new `$…$` on the caret's line asked "is
1429 // there math here?" of a layout that had none, and the formula
1430 // would snap to its picture under the caret until the next
1431 // motion. Ask again of the layout just built, and go once more if
1432 // the answer moved. Between edits the first answer is exact and
1433 // this is one comparison.
1434 let again = self.reveal_line();
1435 if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1436 self.build_map_with(wrap, again.clone());
1437 self.vmap_key = Some((self.revision, wrap, again));
1438 }
1439 }
1440 self.clamp_caret();
1441 }
1442
1443 /// One build of the map at `wrap` under `reveal`, incremental where it can
1444 /// be — the body of [`build_map`](Self::build_map), which decides whether
1445 /// to call it.
1446 fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1447 {
1448 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1449 // A subtree is pulled only for the block(s) that actually changed, so
1450 // the FFI marshal shrinks from O(document) to O(edited block).
1451 let top = self.top_blocks();
1452
1453 // Fast path: when twig reports a dirty byte range, try to patch the
1454 // previous map in place — a single-block edit moves the prefix,
1455 // shifts the suffix, and re-renders only one block. `build_spliced`
1456 // returns `None` (and we fall back to the always-correct full rebuild)
1457 // whenever the edit reshaped the block structure, hit a table, or
1458 // there's no previous map to patch.
1459 // Preserve soft breaks as written when the flow preference asks for
1460 // it — the builder renders each as its own visual row instead of
1461 // folding it into the reflowed paragraph.
1462 let preserve_soft = self.line_flow == LineFlow::Preserve;
1463 let spliced = match self.editor.dirty_range() {
1464 Some(dirty) => {
1465 let prev = std::mem::take(&mut self.vmap);
1466 let source = &self.source;
1467 let cache = &mut self.block_cache;
1468 let surface = &self.surface;
1469 let editor = &mut self.editor;
1470 wysiwyg::build_spliced(
1471 prev,
1472 source,
1473 wrap,
1474 preserve_soft,
1475 &top,
1476 dirty,
1477 surface,
1478 reveal.clone(),
1479 cache,
1480 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1481 )
1482 }
1483 None => None,
1484 };
1485 self.vmap = spliced.unwrap_or_else(|| {
1486 let source = &self.source;
1487 let cache = &mut self.block_cache;
1488 let surface = &self.surface;
1489 let editor = &mut self.editor;
1490 wysiwyg::build_cached(
1491 &top,
1492 source,
1493 wrap,
1494 preserve_soft,
1495 surface,
1496 reveal,
1497 cache,
1498 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1499 )
1500 });
1501 // Acknowledge the dirty range so the next edit's range starts fresh.
1502 self.editor.clear_dirty();
1503 }
1504 }
1505
1506 fn nodes(&mut self) -> Vec<FlatNode> {
1507 self.editor.nodes().unwrap_or_default()
1508 }
1509
1510 /// The document's top-level blocks for the incremental render. See
1511 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1512 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1513 wysiwyg::top_blocks(&mut self.editor)
1514 }
1515
1516 pub fn format_name(&self) -> &'static str {
1517 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1518 // required. It also covers `Asciidoc`, which twig parses but cannot
1519 // serialize — leaf never opens a document in it (see `Doc::open`).
1520 match self.format {
1521 Format::Djot => "djot",
1522 Format::Markdown => "markdown",
1523 Format::Xml => "xml",
1524 Format::Html => "html",
1525 _ => "unknown",
1526 }
1527 }
1528
1529 /// Whether this document's format offers *any* door in — `false` only for a
1530 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1531 /// a frontend may as well open the file read-only.
1532 ///
1533 /// This is a much weaker claim than the name suggests, and driving per-button
1534 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1535 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1536 /// while a heading, a quote, a list, a task box, a link and a code fence all
1537 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1538 /// [`supports`](Self::supports) — per control.
1539 pub fn authorable(&self) -> bool {
1540 self.format.is_authorable()
1541 }
1542
1543 /// Whether this document can spell `gesture`, which is twig's own answer
1544 /// rather than a copy of it: `Format::supports_with` reads the same
1545 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1546 /// the very [`parse_extensions`] this document's editor reparses with — so
1547 /// what the toolbar offers and what the splice will accept are one table.
1548 ///
1549 /// It is a fact about the *document*, not about the caret. `true` does not
1550 /// promise the gesture succeeds where it is standing — a link over a table
1551 /// border still fails — only that it will not fail with
1552 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1553 /// will work here".
1554 pub fn supports(&self, gesture: Gesture) -> bool {
1555 self.format.supports_with(parse_extensions(), gesture)
1556 }
1557
1558 /// Every control's enabled state in one read — what a toolbar builds itself
1559 /// from when a document opens or its format changes. See [`Capabilities`].
1560 pub fn capabilities(&self) -> Capabilities {
1561 Capabilities::of(self.format)
1562 }
1563
1564 /// Refuse a gesture this document's format cannot spell, saying so in the
1565 /// status line. `true` means the caller must return without calling twig.
1566 ///
1567 /// Most of these refusals duplicate one twig would make anyway, and they are
1568 /// made here regardless because a message naming the *document's* format
1569 /// reads better than one naming twig's internals. Two of them are not
1570 /// duplicates and are the reason this is a guard rather than an error
1571 /// translation:
1572 ///
1573 /// - The table family (see [`table_op`](Self::table_op)) consults no
1574 /// `Syntax` table, so twig does not refuse it at all.
1575 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1576 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1577 /// could not keep.
1578 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1579 self.refuse_unless(what, self.supports(gesture))
1580 }
1581
1582 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1583 /// answers itself — today only [`spells_pipe_tables`].
1584 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1585 if supported {
1586 return false;
1587 }
1588 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1589 true
1590 }
1591
1592 /// The name to show for this document. An untitled one has no file to name
1593 /// it, and both frontends put this straight on screen — an empty path
1594 /// renders as an empty header, so it says so instead.
1595 pub fn file_name(&self) -> String {
1596 if self.is_untitled() {
1597 return "untitled".into();
1598 }
1599 self.path
1600 .file_name()
1601 .map(|s| s.to_string_lossy().into_owned())
1602 .unwrap_or_else(|| self.path.display().to_string())
1603 }
1604
1605 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1606 /// caret and anchor coincide (an empty selection is no selection).
1607 pub fn selection(&self) -> Option<(usize, usize)> {
1608 self.anchor
1609 .map(|a| (a.min(self.caret), a.max(self.caret)))
1610 .filter(|(s, e)| s != e)
1611 }
1612
1613 /// The selected text, or `None` when there's no selection — the source
1614 /// slice a copy/cut hands to the system clipboard.
1615 pub fn selected_text(&self) -> Option<&str> {
1616 self.selection().map(|(s, e)| &self.source[s..e])
1617 }
1618
1619 /// The selection as a quote with a little of what surrounds it — the shape
1620 /// a host that cites, annotates, or searches for a passage wants, cut from
1621 /// the **source** rather than from anything rendered, so the quote is
1622 /// findable in the document again by plain string search.
1623 ///
1624 /// `context` is a count of characters (not bytes) on each side, clipped at
1625 /// the document's edges; the slices land on char boundaries by
1626 /// construction. `None` when nothing is selected.
1627 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1628 let (start, end) = self.selection()?;
1629 let mut before = start;
1630 for _ in 0..context {
1631 match self.source[..before].chars().next_back() {
1632 Some(c) => before -= c.len_utf8(),
1633 None => break,
1634 }
1635 }
1636 let mut after = end;
1637 for _ in 0..context {
1638 match self.source[after..].chars().next() {
1639 Some(c) => after += c.len_utf8(),
1640 None => break,
1641 }
1642 }
1643 Some(Quote {
1644 exact: self.source[start..end].to_string(),
1645 prefix: self.source[before..start].to_string(),
1646 suffix: self.source[end..after].to_string(),
1647 start,
1648 end,
1649 })
1650 }
1651
1652 /// Words, characters, and paragraphs over the whole document — the numbers
1653 /// a status bar or an inspector puts next to a piece of writing.
1654 ///
1655 /// Counted over the text a **reader** sees, not the markup that spells it:
1656 /// `**bold**` is one word and four characters, a link is its label and not
1657 /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1658 /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1659 /// not render at all — is not writing. [`crate::counts`] states the rules
1660 /// in full; [`TextCounts`] states them per field.
1661 ///
1662 /// The same numbers in both views. They have to be: a word count that fell
1663 /// when you pressed ⌘E would be telling you the view had changed, which
1664 /// you knew already. So this reads neither [`Doc::view`] nor the map the
1665 /// frontend last built — it renders the source afresh, unwrapped, with
1666 /// soft breaks folded and no line revealed, and counts that. A narrower
1667 /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1668 /// source view all give the identical answer, because none of them is an
1669 /// input.
1670 ///
1671 /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1672 /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1673 /// settle and wrong in a paint loop, so a frontend should ask when the
1674 /// typing stops rather than once a keystroke. Caching it against
1675 /// [`revision`](Self::revision) is the obvious next move if that is ever
1676 /// not enough; nothing has needed it yet.
1677 pub fn counts(&self) -> TextCounts {
1678 self.count_over(None)
1679 }
1680
1681 /// The same statistics over the selection alone — `None` when nothing is
1682 /// selected, since an empty selection is no selection.
1683 ///
1684 /// Same rules, over the same rendering, narrowed to the glyphs whose
1685 /// source byte falls inside [`selection`](Self::selection)'s range. A
1686 /// block the selection only clips still counts as one paragraph, and one
1687 /// it enters without catching a visible character counts as none — a
1688 /// selection that starts on a hidden `**` gains no paragraph from it.
1689 pub fn selection_counts(&self) -> Option<TextCounts> {
1690 let (start, end) = self.selection()?;
1691 Some(self.count_over(Some(start..end)))
1692 }
1693
1694 /// The rendering both counters tally, and the tally itself.
1695 ///
1696 /// A fresh parse rather than `self.editor`, because these take `&self` and
1697 /// twig's arena is reached through `&mut`. A document that will not
1698 /// reparse is a "cannot happen" — the source came out of an editor that
1699 /// had already accepted it — and answers zero rather than panicking in
1700 /// what is very likely a paint path.
1701 fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1702 let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1703 return TextCounts::default();
1704 };
1705 let Ok(nodes) = editor.nodes() else {
1706 return TextCounts::default();
1707 };
1708 // A surface that paints pictures in a line, so an inline formula is
1709 // an atom here and never its TeX: a formula is a picture to a reader
1710 // whichever way it is written, and the count says so consistently.
1711 let surface = wysiwyg::Surface {
1712 inline_pictures: true,
1713 ..Default::default()
1714 };
1715 let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1716 counts::tally(&map, range)
1717 }
1718
1719 /// Whether the document refuses to change — see the field.
1720 pub fn read_only(&self) -> bool {
1721 self.read_only
1722 }
1723
1724 /// Turn the read-only gate on or off. A frontend preference like
1725 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1726 /// itself changes, only what may be done to it from here on.
1727 pub fn set_read_only(&mut self, on: bool) {
1728 self.read_only = on;
1729 }
1730
1731 /// The host-painted ranges, sorted by start — see [`Highlight`].
1732 pub fn highlights(&self) -> &[Highlight] {
1733 &self.highlights
1734 }
1735
1736 /// Replace the host-painted ranges wholesale. The whole set each time,
1737 /// rather than add/remove verbs: the host owns the list (it derives it
1738 /// from its own state — annotations, search hits), and a replace can
1739 /// never leave the two disagreeing about what should be on screen.
1740 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1741 highlights.retain(|h| h.start < h.end);
1742 highlights.sort_by_key(|h| (h.start, h.end));
1743 self.highlights = highlights;
1744 }
1745
1746 /// The highlight covering source `offset`, if one does — first by start
1747 /// when several overlap, which makes overlapping washes resolvable rather
1748 /// than undefined. What a frontend asks when the reader activates a spot.
1749 ///
1750 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1751 /// asking the same question per glyph, against a slice they were handed
1752 /// rather than against a `Doc`, and one answer for both is what keeps a
1753 /// wash and an activation agreeing about which range a spot is in.
1754 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1755 Highlight::covering(&self.highlights, offset)
1756 }
1757
1758 /// The AST breadcrumb at the caret (root → deepest), e.g.
1759 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1760 pub fn breadcrumb(&mut self) -> String {
1761 match self.editor.ancestors_at(self.caret) {
1762 Ok(chain) => chain
1763 .iter()
1764 .map(|m| m.kind.as_str())
1765 .collect::<Vec<_>>()
1766 .join(" › "),
1767 Err(_) => String::new(),
1768 }
1769 }
1770
1771 // ── editing ──────────────────────────────────────────────────────────────
1772
1773 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1774 /// after it. The public form of the internal splice — a pixel frontend that
1775 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1776 /// edits through this, the same twig `edit_range` the caret ops use.
1777 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1778 self.splice(start, end, text, EditKind::Other);
1779 }
1780
1781 /// Insert typed `text` at the caret, replacing the selection if there is one.
1782 /// A single typed character coalesces with the run of typing before it; a
1783 /// newline or a multi-character insert is its own undo step.
1784 ///
1785 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1786 pub fn insert(&mut self, text: &str) {
1787 // The read-only gate, up front: the paths below reach twig by several
1788 // verbs, not all of them through the splice — see the field.
1789 if self.read_only {
1790 return;
1791 }
1792 // Typing against a block picture would dissolve it, and typing past a
1793 // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1794 // the text a paragraph first, so what the caret was standing beside
1795 // stays what it was.
1796 self.open_paragraph_at_block_edge(text);
1797 // Armed sticky marks (⌘b with no selection) turn the next typed text
1798 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1799 // the exception: it takes no mark of its own and keeps the delta armed
1800 // for the character behind it — see `insert_space_with_marks`.
1801 let pending = self.pending_here();
1802 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1803 if text.trim().is_empty() {
1804 self.insert_space_with_marks(self.caret, text, pending);
1805 } else {
1806 self.insert_with_marks(self.caret, text, pending);
1807 }
1808 return;
1809 }
1810 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1811 // anything that would open markup, so a Diaryx user never mints
1812 // formatting by keyboard (it comes from commands instead). The other two
1813 // rungs of the ladder author markup from what you type, which is the
1814 // whole difference between them and this one. Only in the rendered view
1815 // (source view is for typing raw markup) and only where the format has a
1816 // literal spelling at all: escaping is a backslash before a byte from the
1817 // format's own alphabet, and a format with no such alphabet (HTML escapes
1818 // with entities, XML spells nothing) would have `\&` written into it,
1819 // which is two literal characters and not an escape. Marks (⌘b) still
1820 // format — that path returned above; and leaf's own structural inserts go
1821 // through `insert_raw`, never here, so a list marker or quote gutter is
1822 // written as the markup it is.
1823 if !self.markup_mode.authors()
1824 && self.view == View::Wysiwyg
1825 && !text.is_empty()
1826 && self.supports(Gesture::InsertLiteral)
1827 {
1828 self.insert_literal_typed(text);
1829 return;
1830 }
1831 self.insert_raw(text);
1832 }
1833
1834 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1835 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1836 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1837 /// markup by design and must not be escaped.
1838 fn insert_raw(&mut self, text: &str) {
1839 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1840 self.splice(s, e, text, typed_edit_kind(text));
1841 }
1842
1843 /// Open a paragraph for text about to be inserted at one of a block media's
1844 /// two caret stops, or at a table's trailing stop, and leave the caret
1845 /// standing in it.
1846 ///
1847 /// A block image is a paragraph whose entire content is the picture, and the
1848 /// caret's only homes on it are in front of it and just past it (see
1849 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1850 /// *that* paragraph — and a paragraph holding anything besides the image is
1851 /// no longer a block image but a line of text with an inline one in it. The
1852 /// frontend that was painting a photo there paints a text run instead; the
1853 /// picture is still in the file, and nothing said a word. Those two offsets
1854 /// are also exactly where a click on the picture lands, so the whole accident
1855 /// is one tap and one keystroke.
1856 ///
1857 /// So the break goes in first and the text lands in the new empty paragraph —
1858 /// what pressing Return before typing would have done, which is a habit no
1859 /// one should have to learn from losing a photo. A no-op everywhere else, and
1860 /// over a selection (which is replaced, not joined into).
1861 ///
1862 /// A picture inside a quote or a list leaves its container, because `\n\n`
1863 /// ends the block. The alternative is worse: the `\n> ` / next-item
1864 /// continuation [`newline`](Self::newline) writes stays in the same
1865 /// *paragraph*, which is the thing being prevented.
1866 ///
1867 /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1868 /// accident from the other side of a different block: the stop sits at the
1869 /// end of the table's last source line, and a line glued under a table is
1870 /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1871 /// break goes in there too, and the text lands under the table.
1872 ///
1873 /// Only in the rendered view. Source view is for typing raw markup, where
1874 /// putting a character against an image is exactly what it looks like.
1875 fn open_paragraph_at_block_edge(&mut self, text: &str) {
1876 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1877 return;
1878 }
1879 if self.selection().is_some() {
1880 return;
1881 }
1882 // The map may be a revision behind (nothing has drawn since the last
1883 // edit), and this asks it about offsets — a stale answer would splice a
1884 // break into the wrong place. Free when it is already current, which it
1885 // is whenever a frontend drew a frame between keystrokes.
1886 self.rebuild_map();
1887 let at = self.caret;
1888 let side = match self.vmap.block_media_stop(at) {
1889 Some((side, _)) => side,
1890 None if self.vmap.table_end_stop(at) => MediaStop::After,
1891 None => return,
1892 };
1893 if !self.splice(at, at, "\n\n", EditKind::Other) {
1894 return;
1895 }
1896 // The break is part of the keystroke, not an edit of its own: leave the
1897 // run marked as typing so the character about to arrive folds into it and
1898 // one undo puts the document back the way it was found. (A paste, or a
1899 // multi-character insert, is `EditKind::Other` and stays its own step —
1900 // as it would have been anywhere else in the document.)
1901 self.last_edit_kind = Some(EditKind::Insert);
1902 if side == MediaStop::Before {
1903 // The break went in above the picture and the caret rode to the end
1904 // of it — which is still hard against the picture. Step back onto the
1905 // blank line it opened, so the text lands above rather than in front.
1906 self.caret = at;
1907 }
1908 }
1909
1910 /// A delete key pressed at one of a block picture's two caret stops, handled
1911 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1912 /// the key was consumed.
1913 ///
1914 /// The caret rests in front of a block image and just past it, never inside
1915 /// its markup — which the rendered view doesn't show. So the byte a delete
1916 /// key nominally takes there is one the writer cannot see, and taking it
1917 /// leaves the picture as broken markup rather than as anything anyone asked
1918 /// for: Backspace at the stop past `` removes the closing paren, and
1919 /// a photo becomes the literal text `
1922 /// prevents from the typing side, and it cost this repository's own test vault
1923 /// a photo before it was found.
1924 ///
1925 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1926 /// when it is behind the caret, Delete when it is in front — which is what
1927 /// every editor does with an embed, and one undo away. The key aimed *away*
1928 /// from it would otherwise delete the paragraph break and merge a neighbour
1929 /// into the picture's own paragraph, which dissolves it just as surely; it
1930 /// steps the caret over the boundary instead and leaves the
1931 /// next press to delete in the block it has reached — the same "first press
1932 /// steps out of the atom, second press deletes" every delete key here gets,
1933 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1934 /// above, and reaches it on the second press rather than taking the break and
1935 /// the picture with it on the first).
1936 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1937 // The map answers about offsets, so it has to be this revision's — see
1938 // the same call in `open_paragraph_at_block_edge`.
1939 self.rebuild_map();
1940 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1941 return false;
1942 };
1943 let aimed_at_it = side
1944 == if forward {
1945 MediaStop::Before
1946 } else {
1947 MediaStop::After
1948 };
1949 if !aimed_at_it {
1950 let over = if forward {
1951 self.vmap.stop_after(self.caret)
1952 } else {
1953 self.vmap.stop_before(self.caret)
1954 };
1955 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1956 self.caret = off;
1957 self.anchor = None;
1958 self.goal_col = None;
1959 }
1960 return true;
1961 }
1962 // Take the break that held the picture apart from its neighbour with it,
1963 // so the delete doesn't leave a blank paragraph standing where the
1964 // picture was. The last arm is a picture that is the whole document.
1965 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1966 (span.start - 2, span.end)
1967 } else if self.source[span.end..].starts_with("\n\n") {
1968 (span.start, span.end + 2)
1969 } else {
1970 (span.start, span.end)
1971 };
1972 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1973 true
1974 }
1975
1976 /// The Hidden-mode typing path: replace any selection, then insert `text`
1977 /// escaped so it stays literal. When it replaces a selection the two edits
1978 /// fold into one undo step, so an overwrite undoes atomically (and restores
1979 /// the selection) exactly as a plain one does.
1980 fn insert_literal_typed(&mut self, text: &str) {
1981 let kind = typed_edit_kind(text);
1982 match self.selection() {
1983 Some((s, e)) => {
1984 if !self.splice(s, e, "", EditKind::Other) {
1985 return;
1986 }
1987 // Typing over a whole marked run takes its delimiters with it
1988 // (the empty content couldn't hold them — see
1989 // `repair_mark_edges`) and leaves its marks armed at the caret.
1990 // The text taking the run's place inherits them, exactly as it
1991 // would have by landing inside a run that survived.
1992 let pending = self.pending_here();
1993 if !pending.is_empty() && !text.trim().is_empty() {
1994 self.insert_with_marks(self.caret, text, pending);
1995 return;
1996 }
1997 self.insert_literal_at(self.caret, text, kind, true);
1998 }
1999 None => {
2000 self.insert_literal_at(self.caret, text, kind, false);
2001 }
2002 }
2003 }
2004
2005 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2006 /// at a collapsed caret, but only while the caret still stands where they
2007 /// were armed and nothing is selected. Empty otherwise, so a stale delta
2008 /// never styles text it wasn't meant for.
2009 fn pending_here(&self) -> InlineMarks {
2010 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2011 self.pending_marks
2012 } else {
2013 InlineMarks::empty()
2014 }
2015 }
2016
2017 /// Drop the armed sticky marks — any caret motion, selection, or edit does
2018 /// this, so "start bold here" only ever applies at the exact spot it was
2019 /// asked for.
2020 fn clear_pending(&mut self) {
2021 self.pending_marks = InlineMarks::empty();
2022 self.pending_at = None;
2023 }
2024
2025 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2026 /// force is wrapped around the freshly typed text; a mark the caret already
2027 /// stands inside is *shed* — the text is inserted past the run's end so it
2028 /// lands unmarked ("type normally again"). The caret comes to rest inside any
2029 /// added runs, so continued typing inherits the marks with no re-wrapping,
2030 /// and the delta is cleared: the marks now live in the document, not here.
2031 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2032 let base = self.mark_spans_at(at);
2033 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2034 // Nothing to shed, and a run of exactly these marks standing just behind
2035 // the caret: carry on writing *that* run rather than opening a second
2036 // one beside it.
2037 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2038 return;
2039 }
2040 // Shed the marks we're turning off: step the insertion point past the
2041 // end of each run the caret sits in, so the new text falls outside it.
2042 let mut ins_at = at;
2043 for (kind, span) in &base {
2044 if marks.contains(*kind) {
2045 ins_at = ins_at.max(span.end);
2046 }
2047 }
2048 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2049 return;
2050 }
2051 // The plain splice inserted exactly `text` at `ins_at`; that byte range
2052 // is the content every added mark wraps.
2053 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2054 for kind in marks.iter() {
2055 if !base_set.contains(kind) {
2056 let (ncs, nce) = self.wrap_span(cs, ce, kind);
2057 cs = ncs;
2058 ce = nce;
2059 }
2060 }
2061 self.caret = ce.min(self.source.len());
2062 self.anchor = None;
2063 self.last_edit_kind = None;
2064 // Realised: the marks are in the document now, and the caret sits inside
2065 // them, so there is no delta left to carry. Arm nothing, but remember the
2066 // spot so a *further* toggle before typing starts a clean delta here.
2067 self.pending_marks = InlineMarks::empty();
2068 self.pending_at = Some(self.caret);
2069 self.clamp_caret();
2070 self.record_caret();
2071 }
2072
2073 /// Carry on the marked run just behind `at` — moving its closing delimiters
2074 /// out past the new text — instead of opening a second run of the same marks
2075 /// beside it. Returns whether it did.
2076 ///
2077 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2078 /// A space typed after a bold word steps the caret out of the run, because
2079 /// `**bold **` is not bold; the next character has to step back *in*, or the
2080 /// writer who typed one bold phrase is left with `**bold** **and**` — two
2081 /// runs that read the same to a reader but spell the file in a way nobody
2082 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2083 /// words it isn't marking), and the marks behind it must be exactly the ones
2084 /// armed — a run of *some* other kind is a neighbour, not this phrase.
2085 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2086 if text.is_empty() || text.trim() != text {
2087 return false;
2088 }
2089 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2090 // Walk in through the delimiters stacked at that point, innermost last:
2091 // `***both*** ` closes two runs with one `***`, and rejoining means
2092 // getting behind all of them.
2093 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2094 while let Some((kind, content_end)) = self
2095 .editor
2096 .ancestors_at(prev_boundary(&self.source, cut))
2097 .unwrap_or_default()
2098 .into_iter()
2099 .filter(|m| m.span.end == cut)
2100 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2101 {
2102 if content_end >= cut {
2103 break; // a mark with no closing delimiter to step behind
2104 }
2105 kinds.insert(kind);
2106 cut = content_end;
2107 }
2108 if cut == gap_at || kinds != marks {
2109 return false;
2110 }
2111 // Re-spell the tail: the gap, then the new text, then the delimiters that
2112 // used to close in front of them — read out of the document rather than
2113 // written from a table, so whatever twig spells them with is what moves.
2114 let tail = format!(
2115 "{}{text}{}",
2116 &self.source[gap_at..at],
2117 &self.source[cut..gap_at]
2118 );
2119 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2120 return false;
2121 }
2122 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2123 self.anchor = None;
2124 self.last_edit_kind = None;
2125 self.pending_marks = InlineMarks::empty();
2126 self.pending_at = Some(self.caret);
2127 self.clamp_caret();
2128 self.record_caret();
2129 true
2130 }
2131
2132 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2133 /// never itself wrapped: a mark around a space draws nothing a reader can
2134 /// see, and in Markdown and Djot it draws its own delimiters instead
2135 /// (`** **`). So the space goes in unmarked — outside any run the armed
2136 /// marks are shedding — and the marks stay armed for the character after it,
2137 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2138 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2139 let base = self.mark_spans_at(at);
2140 // What the *next* character carries: the armed delta resolved against the
2141 // marks in force here, which the space must not quietly drop.
2142 let want = base
2143 .iter()
2144 .map(|(k, _)| *k)
2145 .collect::<InlineMarks>()
2146 .xor(marks);
2147 let mut ins_at = at;
2148 for (kind, span) in &base {
2149 if marks.contains(*kind) {
2150 ins_at = ins_at.max(span.end);
2151 }
2152 }
2153 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2154 return;
2155 }
2156 self.rearm(want);
2157 self.record_caret();
2158 }
2159
2160 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2161 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2162 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2163 /// added split evenly around the content — half the growth on each side.
2164 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2165 // The read-only gate — this door reaches twig without the splice.
2166 if self.read_only {
2167 return (s, e);
2168 }
2169 match self.editor.toggle_inline(s, e, kind) {
2170 Ok(change) => {
2171 self.last_edit_kind = None;
2172 self.refresh();
2173 self.dirty = self.source != self.clean_source;
2174 let added = (change.new.end - change.new.start).saturating_sub(e - s);
2175 let half = added / 2;
2176 (change.new.start + half, change.new.end - half)
2177 }
2178 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2179 // rather than lose the keystroke.
2180 Err(e2) => {
2181 self.status = Some(format!("{kind:?}: {e2}"));
2182 (s, e)
2183 }
2184 }
2185 }
2186
2187 /// The safe offset to splice a block-level break at, given a caret that may
2188 /// sit exactly between an inline mark's content and its own closing
2189 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2190 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2191 /// with nothing following it on the line: the closing `**` renders no
2192 /// glyph of its own, so the caret's "end of line" offset lands right
2193 /// before it). Splicing a paragraph/list/quote break at `off` itself would
2194 /// sever the delimiter from its content, stranding it alone on the new
2195 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2196 /// nested marks closing at the same point (`**_x_**`) all clear together.
2197 /// A no-op everywhere else — mid-run, or past real trailing content, no
2198 /// mark's `content_span` ends exactly at `off`.
2199 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2200 let off = off.min(self.source.len());
2201 let runs = self.run_span_ids();
2202 self.editor
2203 .ancestors_at(off)
2204 .unwrap_or_default()
2205 .into_iter()
2206 .filter(|m| hides_delims(m, &runs))
2207 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2208 .map(|m| m.span.end)
2209 .max()
2210 .unwrap_or(off)
2211 }
2212
2213 /// The offset a *delete* aimed at the character before `off` should stop at,
2214 /// when `off` is the start of a run's text and the bytes behind it are that
2215 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2216 /// byte behind the caret at the start of a bold word is not a character the
2217 /// writer can see, let alone one they aimed Backspace at: taking it leaves
2218 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2219 /// delete steps over the whole delimiter to the visible character in front of
2220 /// it instead. Walks out to the *outermost* mark opening there, so
2221 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2222 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2223 let off = off.min(self.source.len());
2224 let runs = self.run_span_ids();
2225 self.editor
2226 .ancestors_at(off)
2227 .unwrap_or_default()
2228 .into_iter()
2229 .filter(|m| hides_delims(m, &runs))
2230 .filter(|m| {
2231 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2232 })
2233 .map(|m| m.span.start)
2234 .min()
2235 .unwrap_or(off)
2236 }
2237
2238 /// `off` moved *inside* the run whose closing delimiters end there — the
2239 /// other offset the rich view draws in the same place, since a `**` renders
2240 /// no glyph of its own. `**bold**` has a caret home on each side of its
2241 /// closing delimiter, one column apart on screen and eight bytes and a whole
2242 /// run apart in the file, and a plain ← lands on the outer one whenever a
2243 /// space follows the phrase. The inner one is what the writer is pointing at
2244 /// there: the end of their bold word. Walks in through every mark closing at
2245 /// that point, innermost last, so `***both***` lands inside both. A no-op
2246 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2247 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2248 let mut off = off.min(self.source.len());
2249 let runs = self.run_span_ids();
2250 loop {
2251 let inner = self
2252 .editor
2253 .ancestors_at(prev_boundary(&self.source, off))
2254 .unwrap_or_default()
2255 .into_iter()
2256 .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2257 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2258 .filter(|&end| end < off)
2259 .max();
2260 match inner {
2261 Some(end) => off = end,
2262 None => return off,
2263 }
2264 }
2265 }
2266
2267 /// The mirror at the opening edge: `off` moved inside the run whose
2268 /// delimiters *start* there, onto the first character of its text. See
2269 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2270 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2271 let mut off = off.min(self.source.len());
2272 let runs = self.run_span_ids();
2273 loop {
2274 let inner = self
2275 .editor
2276 .ancestors_at(off)
2277 .unwrap_or_default()
2278 .into_iter()
2279 .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2280 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2281 .filter(|&start| start > off)
2282 .min();
2283 match inner {
2284 Some(start) => off = start,
2285 None => return off,
2286 }
2287 }
2288 }
2289
2290 /// The ids of the document's attributed run spans — the inline
2291 /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2292 /// which sees an ancestor chain and so only a kind. Read once per gesture,
2293 /// not once per step of a walk.
2294 fn run_span_ids(&mut self) -> Vec<NodeId> {
2295 self.nodes()
2296 .iter()
2297 .filter(|n| wysiwyg::is_run_span(n))
2298 .map(|n| n.id)
2299 .collect()
2300 }
2301
2302 /// The attributed span whose text is exactly `content` — the whole of
2303 /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2304 /// and sits between the tags of `<span …></span>` — as the whole range
2305 /// spelling the span: the node's span, widened to its attribute block
2306 /// where the format writes that outside the node, as djot's
2307 /// `[i]{data-size="large"}` does. `None` for any other range, including
2308 /// part of a span's text.
2309 ///
2310 /// An empty span has an interior of no bytes, or no known interior at
2311 /// all: twig gives Markdown's `<span …></span>` the first and djot's
2312 /// `[]{…}` the second, and the chain already says the offset is inside.
2313 fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2314 let runs = self.run_span_ids();
2315 let m = self
2316 .editor
2317 .ancestors_at(content.start)
2318 .unwrap_or_default()
2319 .into_iter()
2320 .filter(|m| runs.contains(&NodeId(m.node_id)))
2321 .find(|m| match &m.content_span {
2322 Some(c) => *c == content,
2323 None => content.is_empty(),
2324 })?;
2325 let mut range = m.span;
2326 if let Some(attrs) = self
2327 .editor
2328 .document()
2329 .ok()
2330 .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2331 {
2332 range.start = range.start.min(attrs.start);
2333 range.end = range.end.max(attrs.end);
2334 }
2335 Some(range)
2336 }
2337
2338 /// The attributed block whose whole text is exactly `content` — the `T`
2339 /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2340 /// `{.center}\nT` — as the range a delete that takes that text takes with
2341 /// it: the whole `<div>` when the block is all the div holds, or the
2342 /// `{…}` line down to the end of the text. The block version of
2343 /// [`run_span_of_content`](Self::run_span_of_content), for the same
2344 /// reason: a paragraph with no text is no block, so the div would stand
2345 /// around nothing and the `{…}` line above nothing, and a from-scratch
2346 /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2347 /// `None` for a block with more text, a div holding more, a heading (an
2348 /// empty `# ` is still a heading), and a format whose attributes are the
2349 /// block's own tag (HTML's `<p class="center"></p>` is still a
2350 /// paragraph).
2351 fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2352 if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2353 return None;
2354 }
2355 let nodes = self.nodes();
2356 let block = nodes
2357 .iter()
2358 .filter(|n| n.kind == Kind::Para)
2359 .find(|n| n.content_span.as_ref() == Some(&content))?;
2360 match self.format {
2361 Format::Djot => {
2362 let attrs = self
2363 .editor
2364 .document()
2365 .ok()
2366 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2367 (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2368 }
2369 _ => {
2370 let div = block
2371 .parent
2372 .and_then(|p| nodes.iter().find(|n| n.id == p))
2373 .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2374 let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2375 alone.then(|| div.span.clone())
2376 }
2377 }
2378 }
2379
2380 /// The inline mark kinds whose span covers `off`, each with that span — the
2381 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2382 /// ids instead. Used to shed a mark by stepping past the end of its run.
2383 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2384 let off = off.min(self.source.len());
2385 self.editor
2386 .ancestors_at(off)
2387 .unwrap_or_default()
2388 .into_iter()
2389 .filter(|m| off < m.span.end)
2390 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2391 .collect()
2392 }
2393
2394 /// Insert clipboard `text` at the caret, replacing the selection if there is
2395 /// one — always its own undo step, whatever its length.
2396 ///
2397 /// Provenance is the whole point, and only the caller has it. `insert` reads
2398 /// a lone character as a keystroke and folds it into the run around it,
2399 /// which is right for typing and wrong for a one-character paste: that paste
2400 /// would vanish mid-run on an undo it was never part of, and the characters
2401 /// the user actually typed would go with it. Length can't tell the two
2402 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2403 /// caller comes through is what says which happened.
2404 pub fn paste(&mut self, text: &str) {
2405 // Pasting against a block picture or a table's end joins the block
2406 // exactly as typing does, and for the same reason — see
2407 // `open_paragraph_at_block_edge`.
2408 self.open_paragraph_at_block_edge(text);
2409 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2410 self.splice(s, e, text, EditKind::Other);
2411 }
2412
2413 /// Replace `[start, end)` with `text` as one step of an IME composition —
2414 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2415 /// folds into a single undo.
2416 ///
2417 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2418 /// dozen calls here, each replacing the last one's provisional bytes, and an
2419 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2420 /// unspools backwards through kana — the intermediate states were never text
2421 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2422 /// look like any other edit), so the door the caller comes through is what
2423 /// says so, exactly as it is for [`paste`](Self::paste) versus
2424 /// [`insert`](Self::insert).
2425 ///
2426 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2427 /// composition folds into this one.
2428 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2429 self.splice(start, end, text, EditKind::Compose);
2430 }
2431
2432 /// Close the open composition run, so the next one is its own undo step.
2433 /// Call when the IME commits or withdraws a composition.
2434 ///
2435 /// Only clears a *composition* run: a frontend that reports an end it never
2436 /// began (some IMEs unmark unprompted) would otherwise split the run of
2437 /// typing around it into two undo steps for no reason the user can see.
2438 pub fn end_composition(&mut self) {
2439 if self.last_edit_kind == Some(EditKind::Compose) {
2440 self.last_edit_kind = None;
2441 }
2442 }
2443
2444 // ── the clipboard's rich flavor ──────────────────────────────────────────
2445
2446 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2447 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2448 /// nothing is selected, or when the selection doesn't render (the caller
2449 /// still has [`selected_text`](Self::selected_text), which is what to publish
2450 /// as `text/plain` either way).
2451 ///
2452 /// **The fragment is a source substring, and that is the honest limit here.**
2453 /// It's parsed standalone, so a selection whose meaning depends on its
2454 /// surroundings converts as what it literally says rather than what it looks
2455 /// like on screen: half a list item is a paragraph, a row torn out of a table
2456 /// is the text of a row, the `**` of a bold run selected without its closing
2457 /// `**` is two asterisks. Every one of those still *renders* — there's no
2458 /// error to report — it just renders as the fragment and not as the document.
2459 /// Widening the range to whole blocks would publish text the user didn't
2460 /// select, which is a worse lie than a fragment being a fragment; the plain
2461 /// flavor has the same substring, so the two flavors at least agree.
2462 pub fn selection_html(&mut self) -> Option<String> {
2463 let (start, end) = self.selection()?;
2464 let inline = self.selection_is_inline(start, end);
2465 let html = html::render_fragment(&self.source[start..end], self.format)?;
2466 Some(match inline {
2467 true => html::strip_sole_paragraph(html),
2468 false => html,
2469 })
2470 }
2471
2472 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2473 /// format first. Its own undo step, like any [`paste`](Self::paste).
2474 ///
2475 /// Returns whether it landed. `false` means the HTML didn't convert to
2476 /// anything worth pasting — the caller should fall back to the plain flavor
2477 /// rather than treat it as an error. The `html` module has the full list of
2478 /// what that covers: a table twig won't build, markup it doesn't recognise,
2479 /// an empty result.
2480 pub fn paste_html(&mut self, html: &str) -> bool {
2481 match html::parse_fragment(html, self.format) {
2482 Some(source) => {
2483 self.paste(&source);
2484 true
2485 }
2486 None => false,
2487 }
2488 }
2489
2490 /// Does the selection live *inside* a single top-level block?
2491 ///
2492 /// The question [`selection_html`](Self::selection_html) needs and the
2493 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2494 /// whether the user selected one word of a sentence or a whole paragraph, and
2495 /// only the document knows which. Selecting a word and pasting into Docs
2496 /// should extend the line you paste into; selecting the paragraph should make
2497 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2498 /// is an artifact of standalone parsing), and one that covers a whole block —
2499 /// or spans two — keeps its structure.
2500 ///
2501 /// Reads the block from twig rather than guessing from the bytes:
2502 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2503 /// block containing an offset, and two ends inside the same one cannot have
2504 /// crossed a block boundary.
2505 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2506 // The last *character*, not `end - 1`: the selection's end is exclusive
2507 // and may sit mid-codepoint's-worth of bytes past the last char.
2508 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2509 return false;
2510 };
2511 let (Some(head), Some(tail)) =
2512 (self.top_block_span(start), self.top_block_span(start + off))
2513 else {
2514 return false;
2515 };
2516 head == tail && !(start <= head.start && end >= head.end)
2517 }
2518
2519 /// The byte span of the top-level block containing `offset`, or `None` at an
2520 /// offset that belongs to no block (the blank line between two of them).
2521 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2522 self.editor
2523 .ancestors_at(offset)
2524 .ok()?
2525 .get(1)
2526 .map(|m| m.span.clone())
2527 }
2528
2529 // ── indentation ──────────────────────────────────────────────────────────
2530
2531 /// One indent level.
2532 ///
2533 /// Two spaces, not the four both frontends type for Tab today, because in a
2534 /// markdown document four columns isn't a width — it's a *meaning*. Four
2535 /// spaces at the head of a line is markdown's indented-code-block marker, so
2536 /// one Tab on a paragraph would reparse it into code and style it as such;
2537 /// two cannot, and the line stays the prose it was. Two is also exactly
2538 /// where a `- ` bullet's content starts, so an indented line lands under its
2539 /// parent item's text instead of beside it — the column a list-aware indent
2540 /// has to hit anyway, which keeps this width from being relitigated later.
2541 const INDENT: &'static str = " ";
2542
2543 /// Indent the selected lines — or the caret's line, with no selection — by
2544 /// one level (Tab).
2545 pub fn indent(&mut self) {
2546 self.reindent(true);
2547 // Nesting changes an ordered list's numbering (the nested item restarts,
2548 // its old siblings resume) — keep the source markers in step.
2549 self.renumber_here();
2550 // Nesting an empty `-` item under a text line reparses that text as a
2551 // setext heading; swap the dash for a `*` before it can (a no-op unless
2552 // the collapse actually happened).
2553 self.avoid_setext_collapse();
2554 }
2555
2556 /// Take one indent level back off the selected lines, or the caret's line
2557 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2558 ///
2559 /// A line with *less* than a full level gives back what it has rather than
2560 /// refusing: outdent's job is to walk a line left, and real documents — hand
2561 /// written, or reflowed by some other editor — are full of indentation that
2562 /// was never a clean multiple of anything. Refusing there would strand the
2563 /// line at a depth Shift+Tab couldn't undo.
2564 pub fn outdent(&mut self) {
2565 self.reindent(false);
2566 self.renumber_here();
2567 }
2568
2569 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2570 ///
2571 /// One splice across the whole line range, never one per line: a Tab is one
2572 /// thing the user did, so it has to be one undo step and one reparse. Per
2573 /// line, twig would reparse the document once per line and leave a stack of
2574 /// steps that Shift+⌘Z walks back one line at a time.
2575 fn reindent(&mut self, add: bool) {
2576 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2577 let start = source_line_range(&self.source, sel_start).start;
2578 let end = source_line_range(&self.source, sel_end).end;
2579 let region = self.source[start..end].to_string();
2580 let lines: Vec<&str> = region.split('\n').collect();
2581 // A blank line has no text to move, and padding it would leave nothing
2582 // but trailing whitespace — but Tab on a blank line *is* a request for
2583 // indentation to type into, so the skip only applies where the op has
2584 // other lines to do real work on.
2585 let skip_blank = add && lines.len() > 1;
2586
2587 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2588 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2589 let mut line_off = start;
2590 for (i, full) in lines.iter().enumerate() {
2591 if i > 0 {
2592 out.push('\n');
2593 }
2594 // A list item moves by having its whole leading prefix *replaced*,
2595 // never by having spaces pushed in front of the line. twig spells
2596 // both prefixes, so the quote markers, the parent's indent and an
2597 // ordered marker's extra column all come out right without leaf
2598 // measuring any of them — and a line that only looks like an item
2599 // (a Djot continuation) reports no marker and is left to the plain
2600 // path, where a Tab is just a Tab.
2601 let marker = self.list_marker_on_line(line_off);
2602 let own = marker
2603 .as_ref()
2604 .map(|m| m.marker_start - m.line_start)
2605 .unwrap_or(0);
2606 let delta = if add {
2607 if skip_blank && full.trim().is_empty() {
2608 out.push_str(full);
2609 0
2610 } else if marker.is_some() && self.first_item_of_list(line_off) {
2611 // The first item of a list has no preceding sibling to nest
2612 // under, so a Tab here can't spell a sub-list — twig would
2613 // reparse the shoved-over marker as the same list, only
2614 // indented, which Shift+Tab then can't cleanly undo. Leave the
2615 // item where it is, the way every list editor refuses to
2616 // over-indent a list's first line.
2617 out.push_str(full);
2618 0
2619 } else if marker.is_some() {
2620 // Nesting means standing where a *continuation* of this line
2621 // would stand: past the parent's marker, inside its content
2622 // column. That is `continuation_prefix`, less a checkbox.
2623 let new = self.nesting_prefix_at(line_off);
2624 let delta = new.len() as isize - own as isize;
2625 out.push_str(&new);
2626 out.push_str(&full[own..]);
2627 delta
2628 } else {
2629 out.push_str(Self::INDENT);
2630 out.push_str(full);
2631 Self::INDENT.len() as isize
2632 }
2633 } else if marker.is_some() {
2634 // Unnesting is the mirror: stand where the parent item's own
2635 // line starts, which drops exactly the level it contributed.
2636 let new = self.outdent_prefix_at(line_off);
2637 let delta = new.len() as isize - own as isize;
2638 out.push_str(&new);
2639 out.push_str(&full[own..]);
2640 delta
2641 } else {
2642 // A plain line gives back the ordinary step.
2643 let strip = outdent_width(full, Self::INDENT.len());
2644 out.push_str(&full[strip..]);
2645 -(strip as isize)
2646 };
2647 deltas.push(delta);
2648 line_off += full.len() + 1;
2649 }
2650 // Nothing to give back. Returning before the splice keeps an outdent at
2651 // column zero from spending an undo step on a document it never changed.
2652 if deltas.iter().all(|d| *d == 0) {
2653 return;
2654 }
2655
2656 // Every line's text keeps its offset *within the line*, so the caret is
2657 // remapped by its column, not by its byte offset — which the prefixes on
2658 // the lines above it have already invalidated.
2659 let remap = |off: usize| -> usize {
2660 let (mut old_ls, mut new_ls) = (start, start);
2661 for (line, delta) in lines.iter().zip(&deltas) {
2662 let old_le = old_ls + line.len();
2663 let new_len = (line.len() as isize + delta) as usize;
2664 if off <= old_le {
2665 let col = (off - old_ls) as isize;
2666 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2667 }
2668 old_ls = old_le + 1;
2669 new_ls += new_len + 1;
2670 }
2671 start + out.len()
2672 };
2673 let placed = match self.selection() {
2674 // Keep the rewritten region selected, the way a container toggle
2675 // keeps its own: it leaves a second Tab aimed at the same lines
2676 // rather than at whatever the shifted offsets now happen to cover.
2677 Some(_) => (start + out.len(), Some(start)),
2678 None => (remap(self.caret), None),
2679 };
2680
2681 // A rolled-back splice leaves the old source in place, where every offset
2682 // computed above addresses text that was never written.
2683 if !self.splice(start, end, &out, EditKind::Other) {
2684 return;
2685 }
2686 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2687 // rewrite is the last line's end — nowhere the caret was. Place it, then
2688 // re-record the caret so this is the state redo restores, not the one
2689 // `splice` left behind from the `Change`.
2690 self.caret = placed.0.min(self.source.len());
2691 self.anchor = placed.1;
2692 self.clamp_caret();
2693 self.record_caret();
2694 }
2695
2696 /// The Enter key.
2697 ///
2698 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2699 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2700 /// caret is in decides what actually gets written.
2701 ///
2702 /// - paragraph → twig's [`Editor::split_block`], which parts the
2703 /// block at the caret and reopens its container
2704 /// - list item → likewise: the next item, its indent, quote
2705 /// prefix and `[ ]` box all reproduced by twig —
2706 /// except an *empty* item, which exits the list
2707 /// - block quote → likewise: a new paragraph inside the quote
2708 /// - heading → a new *paragraph*, not another heading
2709 /// - code block → a literal newline (stay in the block)
2710 /// - blank line → a literal newline (one Backspace undoes it)
2711 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2712 /// visible line
2713 ///
2714 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2715 /// and it is better at it: it drops the whitespace the caret was sitting in
2716 /// front of instead of stranding it at the head of the second half, and it
2717 /// knows continuations leaf's marker scan never covered — a checklist item
2718 /// continues as an *unchecked* checklist item rather than a plain bullet.
2719 ///
2720 /// The exceptions above are exceptions because `split_block` is either wrong
2721 /// there or refuses: parting a fence yields two fences with the code split
2722 /// between them, parting a heading yields a second heading where every editor
2723 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2724 /// table all report an error rather than a split.
2725 pub fn newline(&mut self) {
2726 if self.view == View::Source {
2727 self.insert_raw("\n");
2728 return;
2729 }
2730 // Enter over a selection replaces it with a paragraph break.
2731 if let Some((s, e)) = self.selection() {
2732 self.splice(s, e, "\n\n", EditKind::Other);
2733 return;
2734 }
2735 // A caret resting exactly between an inline mark's content and its own
2736 // closing delimiter (`**bold**` with nothing after it on the line —
2737 // the WYSIWYG caret's natural end-of-line position) must not splice a
2738 // block break there: every path below eventually does via
2739 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2740 // delimiter would strand it alone on the new line.
2741 self.caret = self.skip_trailing_close_delims(self.caret);
2742 // The block the caret is in. `block_offset_for_caret` nudges off a line
2743 // end (where the caret sits at the doc level); on a bare line (e.g. an
2744 // empty list item) fall back to the caret so the enclosing list/quote is
2745 // still visible in the ancestors.
2746 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2747 let kinds: Vec<Kind> = self
2748 .editor
2749 .ancestors_at(off)
2750 .map(|c| c.into_iter().map(|m| m.kind).collect())
2751 .unwrap_or_default();
2752 let has = |k: Kind| kinds.contains(&k);
2753
2754 if has(Kind::CodeBlock) {
2755 self.insert_raw("\n");
2756 return;
2757 }
2758 // An *empty* list item exits the list — the standard double-Enter — which
2759 // `split_block` reports as an error rather than a split (there is no
2760 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2761 // the AST gate — it answers from the tree, so a `- ` that reads as a
2762 // marker byte-for-byte but opens no item (a setext underline, a Djot
2763 // continuation line) never reaches here.
2764 if let Some(marker) = self.list_marker_on_line(self.caret)
2765 && self.item_is_empty(&marker)
2766 {
2767 self.exit_list(&marker);
2768 return;
2769 }
2770 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2771 // not another full paragraph break — so it moves down one line and one
2772 // Backspace undoes it, not two. (`split_block` errors here too.)
2773 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2774 let line_end = self.source[self.caret..]
2775 .find('\n')
2776 .map_or(self.source.len(), |i| self.caret + i);
2777 if self.source[line_start..line_end].trim().is_empty() {
2778 self.insert_raw("\n");
2779 return;
2780 }
2781 // In `Preserve` flow a soft break is a *visible* line the author means to
2782 // make, so Enter writes a single `\n` and typing continues the same
2783 // paragraph on the next line — the behaviour of an ordinary text editor.
2784 // A second Enter then lands on the blank line above and takes the
2785 // empty-line branch, so double-Enter still promotes to a full paragraph
2786 // break; and Backspace, which deletes a lone `\n` over a soft break,
2787 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2788 // render as an invisible space, so Enter keeps making the paragraph break
2789 // that actually shows.
2790 //
2791 // Only in running prose. A list or a quote has a continuation of its own
2792 // to write, and a `\n` there is not a soft line but a lost container.
2793 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2794 if self.line_flow == LineFlow::Preserve && !in_container {
2795 self.insert_raw("\n");
2796 return;
2797 }
2798 // A heading gets a *paragraph*, never a second heading: Enter at the end
2799 // of a title is how every editor is asked for the body under it, and
2800 // `split_block` would repeat the `#` instead. Whitespace at the split
2801 // point goes with the break rather than opening the new paragraph, which
2802 // is what `split_block` does everywhere else.
2803 if has(Kind::Heading) {
2804 let mut end = self.caret;
2805 while self.source.as_bytes().get(end) == Some(&b' ') {
2806 end += 1;
2807 }
2808 self.splice(self.caret, end, "\n\n", EditKind::Other);
2809 return;
2810 }
2811 self.split_block_here();
2812 }
2813
2814 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2815 /// the caret in the second half.
2816 ///
2817 /// twig reopens whatever the first half was inside of — the bullet with its
2818 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2819 /// reason this replaced the markup leaf used to spell from the line's bytes.
2820 /// It renumbers nothing, though: a new item mid-list is written with its
2821 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2822 /// behind it, folded into the same undo step.
2823 ///
2824 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2825 /// shape still moves the caret down rather than swallowing the keystroke.
2826 fn split_block_here(&mut self) {
2827 // The read-only gate — this door reaches twig without the splice.
2828 if self.read_only {
2829 return;
2830 }
2831 match self.editor.split_block(self.caret) {
2832 Ok(change) => {
2833 self.last_edit_kind = None;
2834 self.refresh();
2835 self.anchor = None;
2836 self.caret = change.new.end;
2837 self.dirty = self.source != self.clean_source;
2838 self.status = None;
2839 self.clamp_caret();
2840 self.record_caret();
2841 // Aimed at the new block's *start*: the caret twig leaves is one
2842 // past the marker it wrote, where there is no list in reach.
2843 self.renumber_at(change.new.start);
2844 }
2845 Err(_) => self.insert_raw("\n\n"),
2846 }
2847 }
2848
2849 /// Whether the item on the marker's line carries no content — the shape
2850 /// double-Enter reads as "I'm done with this list."
2851 fn item_is_empty(&self, line: &ListMarker) -> bool {
2852 let content_start = line.content_start().min(self.source.len());
2853 let line_end = self.source[self.caret..]
2854 .find('\n')
2855 .map(|i| self.caret + i)
2856 .unwrap_or(self.source.len());
2857 self.source[content_start..line_end.max(content_start)]
2858 .trim()
2859 .is_empty()
2860 }
2861
2862 /// Leave the list: replace the empty item's marker with a blank line, so the
2863 /// caret lands in a fresh paragraph below it.
2864 ///
2865 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2866 /// quote), and the caret's new line keeps the `> ` it was already behind —
2867 /// leaving the list without also leaving the quote.
2868 fn exit_list(&mut self, line: &ListMarker) {
2869 let prefix = self.quote_prefix_at(line.marker_start);
2870 let blank = prefix.trim_end();
2871 self.splice(
2872 line.line_start,
2873 self.caret,
2874 &format!("{blank}\n{prefix}"),
2875 EditKind::Other,
2876 );
2877 }
2878
2879 /// What a line continuing the containers at `off` has to open with — the
2880 /// quote markers reproduced, each enclosing item's marker as its width in
2881 /// spaces. Also the column a nested item's marker stands in, which is what
2882 /// makes it Tab's answer.
2883 fn continuation_prefix_at(&mut self, off: usize) -> String {
2884 self.editor
2885 .document()
2886 .and_then(|mut d| d.continuation_prefix(off))
2887 .map(|p| p.text)
2888 .unwrap_or_default()
2889 }
2890
2891 /// The column a *nested list* may open at inside the item at `off` — which
2892 /// is not always where the item's own text continues.
2893 ///
2894 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2895 /// it is markup a rich view hides, and the item's own wrapped text does
2896 /// stand past it. But a nested list may only open at the *list* marker's
2897 /// column, and four columns further in is an indented continuation of the
2898 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2899 /// So the box's own width goes back.
2900 ///
2901 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2902 /// reports the list marker's column apart from the box; `checked` is what
2903 /// says a box is there at all, so only its width is being measured here.
2904 fn nesting_prefix_at(&mut self, off: usize) -> String {
2905 let cont = self.continuation_prefix_at(off);
2906 let Some(item) = self.innermost_list_item(off) else {
2907 return cont;
2908 };
2909 if item.checked.is_none() {
2910 return cont;
2911 }
2912 let box_width = item
2913 .marker_span
2914 .and_then(|m| self.source.get(m))
2915 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2916 .unwrap_or(0);
2917 // The trailing columns are the ones the item's own marker contributed,
2918 // so trimming from the end leaves any quote prefix standing.
2919 cont[..cont.len().saturating_sub(box_width)].to_string()
2920 }
2921
2922 /// Where the line of the item *containing* the item at `off` begins — the
2923 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2924 /// parent contributed. The quote prefix alone for a top-level item, which
2925 /// has no level left to give.
2926 fn outdent_prefix_at(&mut self, off: usize) -> String {
2927 let items: Vec<usize> = self
2928 .editor
2929 .document()
2930 .and_then(|mut d| d.ancestors_at_caret(off))
2931 .map(|c| {
2932 c.into_iter()
2933 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2934 .map(|m| m.span.start)
2935 .collect()
2936 })
2937 .unwrap_or_default();
2938 // The second-innermost item is the parent; its own line's indent is the
2939 // target. `list_marker_on_line` gives that line's prefix directly.
2940 let parent = items.len().checked_sub(2).map(|i| items[i]);
2941 match parent.and_then(|p| self.list_marker_on_line(p)) {
2942 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2943 None => self.quote_prefix_at(off),
2944 }
2945 }
2946
2947 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2948 /// inside one, `"> > "` inside two.
2949 ///
2950 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2951 /// the `>` and the space after it are twig's spelling rather than leaf's.
2952 /// The whole line prefix can't answer this: it also carries the indent of
2953 /// whatever the quote holds, which a blank separator line must *not* repeat.
2954 fn quote_prefix_at(&mut self, off: usize) -> String {
2955 let Ok(chain) = self
2956 .editor
2957 .document()
2958 .and_then(|mut d| d.ancestors_at_caret(off))
2959 else {
2960 return String::new();
2961 };
2962 let quotes: Vec<usize> = chain
2963 .iter()
2964 .filter(|m| m.kind == Kind::BlockQuote)
2965 .map(|m| m.node_id as usize)
2966 .collect();
2967 let Ok(nodes) = self.editor.nodes() else {
2968 return String::new();
2969 };
2970 quotes
2971 .iter()
2972 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2973 .filter_map(|s| self.source.get(s))
2974 .collect()
2975 }
2976
2977 /// Whether the item at `off` sits inside another one — the test Backspace
2978 /// uses to choose between outdenting and dropping the marker.
2979 ///
2980 /// Counted from the AST rather than from the line's leading whitespace,
2981 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2982 fn item_is_nested(&mut self, off: usize) -> bool {
2983 self.editor
2984 .document()
2985 .and_then(|mut d| d.ancestors_at_caret(off))
2986 .map(|c| {
2987 c.into_iter()
2988 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2989 .count()
2990 > 1
2991 })
2992 .unwrap_or(false)
2993 }
2994
2995 /// The innermost list item containing `probe`, under twig's **caret**
2996 /// containment rule — a block's end is inside it.
2997 ///
2998 /// Half-open containment can't answer this. An empty item's span is exactly
2999 /// its marker, so the caret sitting after `- ` is one past the end and the
3000 /// item it is plainly in tests as out of reach; that is the shape
3001 /// double-Enter has to recognise to leave the list.
3002 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3003 let chain = self
3004 .editor
3005 .document()
3006 .and_then(|mut d| d.ancestors_at_caret(probe))
3007 .ok()?;
3008 let id = chain
3009 .iter()
3010 .rev()
3011 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3012 .node_id as usize;
3013 self.editor.nodes().ok()?.get(id).cloned()
3014 }
3015
3016 /// The list marker opening `off`'s line, per twig — `None` when that line
3017 /// opens no list item.
3018 ///
3019 /// [`Document::line_prefix`] is the whole hidden run from the line start:
3020 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
3021 /// and it is `None` on a *continuation* line, which opens nothing. That last
3022 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
3023 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3024 /// a paragraph and ` - b` is literal text — identical bytes, and only the
3025 /// parser knows which document it is looking at.
3026 ///
3027 /// The item's own marker is separated out via its
3028 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3029 /// the containers around it contribute and what the item does.
3030 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3031 let off = off.min(self.source.len());
3032 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3033 // The prefix belongs to a list only when an item's marker closes it —
3034 // a heading's `# ` or a bare quote's `> ` is a prefix too.
3035 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3036 let marker = item.marker_span.clone()?;
3037 if marker.end != prefix.end {
3038 return None;
3039 }
3040 Some(ListMarker {
3041 line_start: prefix.start,
3042 marker_start: marker.start,
3043 text: self.source.get(prefix)?.to_string(),
3044 })
3045 }
3046
3047 /// Whether the list item on `line_start`'s line is the **first item** of its
3048 /// list — the one Tab must not nest, because nesting needs a preceding
3049 /// sibling to become the new parent and a first item has none. `false` for a
3050 /// line that isn't a list item, and for an item with a sibling above it (the
3051 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3052 /// the same in a setext underline that opens no list at all.
3053 fn first_item_of_list(&mut self, line_start: usize) -> bool {
3054 let Some(marker) = self.list_marker_on_line(line_start) else {
3055 return false;
3056 };
3057 // Probe just inside the marker, where the item's own node is in reach —
3058 // the marker offset itself can resolve to the enclosing list, not the
3059 // `list_item`, whose span starts at the marker.
3060 let probe = marker.content_start().min(self.source.len());
3061 let Some(item) = self.innermost_list_item(probe) else {
3062 return false;
3063 };
3064 let Ok(nodes) = self.editor.nodes() else {
3065 return false;
3066 };
3067 match item.parent {
3068 // First when the parent list opens with this very item.
3069 Some(pid) => nodes
3070 .get(pid.0 as usize)
3071 .is_some_and(|p| p.first_child == Some(item.id)),
3072 // A parentless item is trivially the first (and only) one.
3073 None => true,
3074 }
3075 }
3076
3077 pub fn backspace(&mut self) {
3078 if let Some((s, e)) = self.selection() {
3079 self.splice(s, e, "", EditKind::Other);
3080 return;
3081 }
3082 // WYSIWYG: Backspace at the very start of a list item's content is a
3083 // structural key, not a character delete — it walks the "un-indent, then
3084 // un-list" ladder every list editor gives that keystroke (outdent a
3085 // nested item, strip a top-level one's marker to a paragraph). In source
3086 // view the `- ` is visible text the user is deleting a byte of, so it
3087 // keeps its literal meaning there, like Enter does.
3088 if self.view != View::Source && self.backspace_list_start() {
3089 return;
3090 }
3091 // WYSIWYG: and the same at the start of a heading's content — the `# `
3092 // there is markup the rich view hides, not text the user typed.
3093 if self.view != View::Source && self.backspace_heading_start() {
3094 return;
3095 }
3096 // WYSIWYG: and at the start of a block whose presentation is spelled
3097 // as hidden markup before it — djot's `{.center}` line, Markdown's
3098 // `<div class="center">` — Backspace takes that markup, the way it
3099 // takes a heading's `#`, rather than a byte out of it.
3100 if self.view != View::Source && self.backspace_attributed_block_start() {
3101 return;
3102 }
3103 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3104 // the markup apart under a caret that cannot see it — see
3105 // `delete_around_block_media`.
3106 if self.view != View::Source && self.delete_around_block_media(false) {
3107 return;
3108 }
3109 // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3110 // cell rather than taking the byte behind the caret — the row's closing
3111 // `|`, which the rich view never drew, so the key would have looked like
3112 // it did nothing. The stop before is the last cell's end.
3113 if self.view != View::Source && self.backspace_at_table_end() {
3114 return;
3115 }
3116 // WYSIWYG: at the start of a block's content, the byte behind the caret
3117 // is a block boundary, and Backspace over one is a join — twig's, so
3118 // that what a join is in each format is not this file's to know. After
3119 // the picture and table cases, which are block starts with their own
3120 // answers.
3121 if self.view != View::Source && self.backspace_joins_block() {
3122 return;
3123 }
3124 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3125 // stop, not a single newline. On a line with no text of its own, the byte
3126 // before the caret is a `\n` that spells part of a block boundary — the gap
3127 // between two blocks, drawn but never a caret home. Removing just it strands
3128 // the caret in that gap and leaves an odd blank line the eye reads as one
3129 // separator but the caret can't land on: the "extra newline" left behind
3130 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3131 // Deleting to the previous stop instead collapses the whole break at once,
3132 // landing the caret at the end of the block above. Two blank lines in a row
3133 // are one stop apart, so this still removes exactly one — the lone-Enter /
3134 // lone-Backspace symmetry the empty-line case is built on is untouched.
3135 if self.view != View::Source
3136 && self.caret > self.caret_floor()
3137 && self.caret_on_blank_line()
3138 && let Some(stop) = self.vmap.stop_before(self.caret)
3139 {
3140 let stop = stop.max(self.caret_floor());
3141 if stop < self.caret {
3142 if self.source[stop..self.caret].trim().is_empty() {
3143 self.splice(stop, self.caret, "", EditKind::Delete);
3144 } else {
3145 // Hidden markup stands between the stop and the caret — a
3146 // `</div>`, a comment, a link reference definition — and
3147 // collapsing to the stop would delete it. Take the blank
3148 // line alone, with the newline that opened it, and land
3149 // the caret where the collapse would have.
3150 self.delete_blank_line_to(stop);
3151 }
3152 return;
3153 }
3154 }
3155 if self.caret > self.caret_floor() {
3156 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3157 // takes the whole tag — a single-byte step would leave a broken `<br`
3158 // showing in the cell. Rich view only (source view edits the literal).
3159 if self.view != View::Source
3160 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3161 {
3162 let start = start.max(self.caret_floor());
3163 if start < end {
3164 self.splice(start, end, "", EditKind::Delete);
3165 return;
3166 }
3167 }
3168 // Aim the delete at the character the writer can *see* behind the
3169 // caret, never at a delimiter the rich view drew nothing for. Two
3170 // steps, and either can apply: from the far side of a run's closing
3171 // `**` step back into the run (the caret is drawn at the end of its
3172 // word), and at the start of a run's text step out past its opening
3173 // `**` to the character in front of it, leaving the run standing.
3174 // Without them a plain Backspace unspells the phrase it is editing
3175 // and leaves a literal asterisk on screen.
3176 let end = if self.view == View::Source {
3177 self.caret
3178 } else {
3179 let inside = self.step_inside_close_delims(self.caret);
3180 // An attributed span with no text — `<span …></span>` as the
3181 // file was written — is hidden markup around nothing, and a
3182 // byte-step here would take its `>`. Backspace takes the span
3183 // whole, with the character before it: the character the key
3184 // looks aimed at, since the span draws nothing.
3185 if let Some(span) = self.run_span_of_content(inside..inside) {
3186 let from = if self.source[..span.start].ends_with('\n') {
3187 span.start
3188 } else {
3189 prev_boundary(&self.source, span.start)
3190 };
3191 let from = from.max(self.caret_floor());
3192 self.splice(from, span.end, "", EditKind::Delete);
3193 return;
3194 }
3195 self.skip_leading_open_delims(inside)
3196 .max(self.caret_floor())
3197 };
3198 // Never delete back across the floor — that would eat hidden
3199 // frontmatter the WYSIWYG caret can't even see.
3200 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3201 // Take a hidden escape backslash with the char it escapes: the rich
3202 // view draws `\*` as a single `*`, so Backspace over it must delete
3203 // both bytes, never strand the `\` as a lone visible backslash (the
3204 // mirror of the Hidden-mode typing that wrote the escape). Source view
3205 // shows the `\`, so there it is an ordinary character.
3206 if self.view != View::Source
3207 && prev > self.caret_floor()
3208 && self.is_hidden_escape(prev - 1)
3209 {
3210 prev -= 1;
3211 }
3212 // The delete that takes the last of a span's text takes the span
3213 // with it, in the same edit: `<span …>i</span>` losing its `i`
3214 // would leave an empty span the map has no stop inside, so the
3215 // caret would draw at the next stop — a line away — until a
3216 // further key removed the span. Landing on the span's start is
3217 // where the letter was.
3218 if self.view != View::Source
3219 && let Some(span) = self.run_span_of_content(prev..end)
3220 {
3221 self.splice(span.start, span.end, "", EditKind::Delete);
3222 return;
3223 }
3224 // And the same for a block: the letter that was all of a centred
3225 // paragraph's text goes with the `<div>` around it, or the `{…}`
3226 // line above it, leaving a plain blank line where the letter was.
3227 // A paragraph with no text is no block, so the markup would stand
3228 // around nothing, the map would give it no caret home, and the
3229 // next key would take the tag apart.
3230 if self.view != View::Source
3231 && let Some(block) = self.attributed_block_of_content(prev..end)
3232 {
3233 self.splice(block.start, block.end, "", EditKind::Delete);
3234 return;
3235 }
3236 if prev < end {
3237 self.splice(prev, end, "", EditKind::Delete);
3238 }
3239 }
3240 }
3241
3242 /// Remove the blank line the caret is on — its own newline and the one
3243 /// that ended the line before it — and put the caret on `stop`, the caret
3244 /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3245 /// blank line that hidden markup separates from the block above: the
3246 /// navigable blank row after a `</div>` is always one of at least three
3247 /// newlines under the tag (the drawn separators either side of it), so
3248 /// taking two leaves the blank line the tag needs under it.
3249 fn delete_blank_line_to(&mut self, stop: usize) {
3250 let caret = self.caret;
3251 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3252 let line_end = self.source[caret..]
3253 .find('\n')
3254 .map_or(self.source.len(), |i| caret + i);
3255 let from = line_start.saturating_sub(1).max(stop);
3256 let to = (line_end + 1).min(self.source.len());
3257 self.splice(from, to, "", EditKind::Delete);
3258 self.caret = stop;
3259 self.anchor = None;
3260 self.goal_col = None;
3261 self.record_caret();
3262 }
3263
3264 /// Move the caret to the stop before it and consume the key — what
3265 /// Backspace does where the byte behind the caret is hidden markup it
3266 /// has no structural answer for, rather than take that markup apart.
3267 fn step_back_to_stop(&mut self) {
3268 // The map answers about offsets, so it has to be this revision's — see
3269 // `open_paragraph_at_block_edge`.
3270 self.rebuild_map();
3271 if let Some(off) = self
3272 .vmap
3273 .stop_before(self.caret)
3274 .filter(|&o| o >= self.caret_floor())
3275 {
3276 self.caret = off;
3277 self.anchor = None;
3278 self.goal_col = None;
3279 }
3280 }
3281
3282 /// Backspace's presentation behaviour: with the caret exactly at the start
3283 /// of a block's content, and that block's attributes spelled as hidden
3284 /// markup before it, strip the attributes. The peer of
3285 /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3286 /// reasoning: the `{.center}` line above a djot block and the
3287 /// `<div class="center">` around a Markdown one are what the byte behind
3288 /// the caret belongs to, and the rich view draws neither. The ordinary
3289 /// delete took the newline out of `{.center}\nhello` and left
3290 /// `{.center}hello` — the attribute line fused onto the text as prose —
3291 /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3292 ///
3293 /// The whole attribute set goes, the way the whole `#` marker does — the
3294 /// press is over the line that spells it, not over one key of it — and
3295 /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3296 /// djot line and unwraps the Markdown div. Where the block is the first of
3297 /// several in a div, twig has no sole child to unwrap and answers with a
3298 /// no-op, so the caret steps back to the stop before instead, as it does
3299 /// at a table's end. A later child of the div has an ordinary paragraph
3300 /// above it and is not this rule's.
3301 ///
3302 /// Returns whether it acted; `false` leaves Backspace its character delete.
3303 fn backspace_attributed_block_start(&mut self) -> bool {
3304 if !matches!(self.format, Format::Markdown | Format::Djot) {
3305 return false;
3306 }
3307 let caret = self.caret;
3308 let nodes = self.nodes();
3309 let Some(block) = nodes
3310 .iter()
3311 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3312 .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3313 else {
3314 return false;
3315 };
3316 match self.format {
3317 Format::Djot => {
3318 // twig records where the `{…}` block was written, so this is
3319 // the parser's own answer and not a scan for a `{` above the
3320 // block; `None` (a synthesized or merged set) is not a line
3321 // the caret is standing after.
3322 let spelled = self
3323 .editor
3324 .document()
3325 .ok()
3326 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3327 .is_some_and(|s| s.end <= caret);
3328 if !spelled {
3329 return false;
3330 }
3331 }
3332 _ => {
3333 let Some(div) = block
3334 .parent
3335 .and_then(|p| nodes.iter().find(|n| n.id == p))
3336 .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3337 else {
3338 return false;
3339 };
3340 let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3341 if kids.clone().any(|k| k.span.start < block.span.start) {
3342 return false;
3343 }
3344 if kids.nth(1).is_some() {
3345 self.step_back_to_stop();
3346 return true;
3347 }
3348 }
3349 }
3350 self.write_block_attrs("block attributes", Vec::new());
3351 true
3352 }
3353
3354 /// Backspace at the start of a block's content: join the block into the
3355 /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3356 /// the split Enter makes, spelled the format's way. Two paragraphs join
3357 /// on a soft break; a paragraph under a marker heading joins onto the
3358 /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3359 /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3360 /// taken as one; a quote's or an item's continuation prefix is written.
3361 /// The joined text takes the block above's presentation and containers,
3362 /// which is the rule every editor with a centred paragraph follows.
3363 ///
3364 /// Leaf used to join by deleting the one newline behind the caret, which
3365 /// is the right bytes for two Markdown paragraphs and nothing else: under
3366 /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3367 /// after a div it took the newline under the hidden `</div>`, which drew
3368 /// nothing different and took the tag apart on the next press. What a
3369 /// join is in each format is twig's to know, and now it does.
3370 ///
3371 /// Where twig refuses — the block above is a code block, a table or a
3372 /// rule with no text to join into, or the caret's block would have to
3373 /// leave a div that holds more after it — the caret steps back to the
3374 /// stop before instead, as it does at a table's end: the key moves the
3375 /// caret and takes no markup apart. Where nothing precedes the block, or
3376 /// the format cannot join at all, Backspace keeps its character delete.
3377 ///
3378 /// Returns whether it acted.
3379 fn backspace_joins_block(&mut self) -> bool {
3380 let caret = self.caret;
3381 if caret <= self.caret_floor() {
3382 return false;
3383 }
3384 let Some(text) = self.text_block_opening_at(caret) else {
3385 return false;
3386 };
3387 match self.join_blocks(caret) {
3388 Ok(change) => {
3389 // The caret keeps its place at the start of the text it stood
3390 // on, wherever the join put that text — after a soft break,
3391 // a space, or a quote's prefix. Found by the bytes, as
3392 // `block_content_in` finds a re-spelled block.
3393 let region = &self.source[change.new.clone()];
3394 let at = region
3395 .find(&text)
3396 .map_or(change.new.start, |i| change.new.start + i);
3397 self.land_after_join(at);
3398 true
3399 }
3400 Err(twig::Error::NotEditable) => {
3401 self.step_back_to_stop();
3402 true
3403 }
3404 Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3405 Err(e) => {
3406 self.status = Some(format!("join: {e}"));
3407 true
3408 }
3409 }
3410 }
3411
3412 /// Delete at the end of a block's content: join the block after it into
3413 /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3414 /// mirror, and the same twig gesture aimed at the next block. The caret
3415 /// stays where it was, which is where the joined text now begins after
3416 /// the separator. Where twig refuses, the caret steps forward to the next
3417 /// stop instead; where no block follows, Delete keeps its character
3418 /// delete.
3419 fn delete_forward_joins_block(&mut self) -> bool {
3420 let caret = self.caret;
3421 let at_end = self
3422 .nodes()
3423 .iter()
3424 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3425 .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3426 if !at_end {
3427 return false;
3428 }
3429 // The next stop, across a line end, in a block: what Delete at a
3430 // block's end points at. On the same line it is a hidden delimiter's
3431 // far side, which the ordinary delete handles; on a blank line it is
3432 // the empty paragraph the byte delete has always closed.
3433 self.rebuild_map();
3434 let Some(stop) = self.vmap.stop_after(caret) else {
3435 return false;
3436 };
3437 if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3438 return false;
3439 }
3440 match self.join_blocks(stop) {
3441 Ok(change) => {
3442 self.land_after_join(change.old.start);
3443 true
3444 }
3445 Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3446 self.caret = stop;
3447 self.anchor = None;
3448 self.goal_col = None;
3449 true
3450 }
3451 Err(twig::Error::UnsupportedFormat) => false,
3452 Err(e) => {
3453 self.status = Some(format!("join: {e}"));
3454 true
3455 }
3456 }
3457 }
3458
3459 /// The content bytes of the paragraph or heading whose content opens
3460 /// exactly at `off` — the block a Backspace there is at the start of.
3461 fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3462 self.nodes()
3463 .into_iter()
3464 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3465 .find_map(|n| {
3466 let c = n.content_span?;
3467 (c.start == off).then(|| self.source[c].to_string())
3468 })
3469 }
3470
3471 /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3472 /// plumbing every structural gesture has; the caret is the caller's to
3473 /// place from the change, via [`land_after_join`](Self::land_after_join).
3474 fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3475 if self.read_only {
3476 return Err(twig::Error::NotEditable);
3477 }
3478 self.record_caret();
3479 let change = self.editor.join_blocks(offset)?;
3480 self.last_edit_kind = None; // structural edit is its own undo step
3481 self.refresh();
3482 Ok(change)
3483 }
3484
3485 /// Finish a join: the caret at `at`, no selection, the map this
3486 /// revision's before the clamp — see `write_block_attrs` for why.
3487 fn land_after_join(&mut self, at: usize) {
3488 self.caret = at;
3489 self.anchor = None;
3490 self.goal_col = None;
3491 self.dirty = self.source != self.clean_source;
3492 self.status = None;
3493 self.rebuild_map();
3494 self.clamp_caret();
3495 self.record_caret();
3496 }
3497
3498 /// Backspace at a table's trailing stop: move onto the stop before it (the
3499 /// last cell's end) and consume the key. `false` anywhere else. See
3500 /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3501 /// not one to delete.
3502 fn backspace_at_table_end(&mut self) -> bool {
3503 // The map answers about offsets, so it has to be this revision's — see
3504 // `open_paragraph_at_block_edge`.
3505 self.rebuild_map();
3506 if !self.vmap.table_end_stop(self.caret) {
3507 return false;
3508 }
3509 if let Some(off) = self
3510 .vmap
3511 .stop_before(self.caret)
3512 .filter(|&o| o >= self.caret_floor())
3513 {
3514 self.caret = off;
3515 self.anchor = None;
3516 self.goal_col = None;
3517 }
3518 true
3519 }
3520
3521 /// Whether the caret's own source line holds nothing but whitespace — an
3522 /// empty paragraph, or the blank line a block boundary is spelled with. The
3523 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3524 /// no text of its own, so the newline before the caret belongs to the gap
3525 /// between blocks rather than to any word the caret is editing.
3526 fn caret_on_blank_line(&self) -> bool {
3527 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3528 let line_end = self.source[self.caret..]
3529 .find('\n')
3530 .map_or(self.source.len(), |i| self.caret + i);
3531 self.source[line_start..line_end].trim().is_empty()
3532 }
3533
3534 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3535 /// `edge` side — the byte range to delete whole. A table row is one source
3536 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3537 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3538 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3539 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3540 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3541 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
3542 /// apart — no ancestor walk needed. Rich view only; source view shows the
3543 /// literal tag and deletes it a byte at a time.
3544 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3545 let caret = self.caret;
3546 let nodes = self.nodes();
3547 let src = self.source.as_bytes();
3548 nodes
3549 .iter()
3550 .find(|n| {
3551 n.kind == Kind::HardBreak
3552 && n.span.start < n.span.end
3553 && src.get(n.span.start) == Some(&b'<')
3554 && match edge {
3555 BreakEdge::Backward => n.span.end == caret,
3556 BreakEdge::Forward => n.span.start == caret,
3557 }
3558 })
3559 .map(|n| (n.span.start, n.span.end))
3560 }
3561
3562 /// Whether the source byte at `off` is a backslash twig consumed as an escape
3563 /// (hidden in the rich view), as against a literal backslash (drawn). A
3564 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3565 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3566 /// round-trip needed.
3567 fn is_hidden_escape(&self, off: usize) -> bool {
3568 let b = self.source.as_bytes();
3569 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3570 }
3571
3572 /// Backspace's list behaviour: when the caret sits exactly at the start of a
3573 /// list item's content (right after its marker), outdent the item if it's
3574 /// nested, else strip the marker so it becomes a paragraph. Returns whether
3575 /// it acted — `false` leaves Backspace its ordinary character delete.
3576 fn backspace_list_start(&mut self) -> bool {
3577 let Some(marker) = self.list_marker_on_line(self.caret) else {
3578 return false;
3579 };
3580 // Only right after the marker. That the line opens a real item is
3581 // already settled: `list_marker_on_line` answers from the tree.
3582 if self.caret != marker.content_start() {
3583 return false;
3584 }
3585 if self.item_is_nested(marker.marker_start) {
3586 // Nested: give back one level, keeping the marker and carrying the
3587 // caret with it.
3588 self.outdent();
3589 } else {
3590 // Top level: drop the marker, leaving a paragraph, then renumber the
3591 // siblings the removed item was counted among. Only the marker goes —
3592 // a quote prefix in front of it still has a quote to hold up.
3593 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3594 self.renumber_here();
3595 }
3596 true
3597 }
3598
3599 /// Backspace's heading behaviour: with the caret exactly at the start of an
3600 /// ATX heading's content — right after the `#` marker the rich view hides —
3601 /// strip the marker so the line becomes a paragraph. The peer of
3602 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3603 /// reasoning: hidden block markup is structure, so the keystroke over it is
3604 /// structural.
3605 ///
3606 /// Without this the ordinary delete takes the space out of `# Title` and
3607 /// leaves `#Title`, which is no longer a heading at all — the hash the view
3608 /// had been hiding surfaces as literal text the user has to delete a second
3609 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3610 /// other end) goes with the marker for the same reason.
3611 ///
3612 /// Returns whether it acted; `false` leaves Backspace its character delete.
3613 fn backspace_heading_start(&mut self) -> bool {
3614 let caret = self.caret;
3615 // The heading whose content opens exactly at the caret. A bare `#` has no
3616 // content span at all — its content starts (and ends) where the line does.
3617 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3618 let (start, end) = match &n.content_span {
3619 Some(c) => (c.start, c.end),
3620 None => (n.span.end, n.span.end),
3621 };
3622 (n.kind == Kind::Heading && start == caret)
3623 .then(|| (n.span.clone(), end, n.marker_span.clone()))
3624 }) else {
3625 return false;
3626 };
3627 // twig reports the marker's own extent, so there is nothing to walk back
3628 // over and no `#` in this file. A setext heading has no marker — its
3629 // content opens the line — so it falls through to the ordinary delete,
3630 // as does anything else sitting at a content start.
3631 // `m.end == caret` is what excludes a setext heading, whose marker is the
3632 // underline *after* the content rather than a prefix before it.
3633 let Some(marker) = marker.filter(|m| m.end == caret) else {
3634 return false;
3635 };
3636 let start = marker.start;
3637 // A closing `#` sequence is hidden too, so it can't be left behind. Only
3638 // when the tail really is one: trailing spaces alone are nothing to strip.
3639 let tail = &self.source[content_end..span.end];
3640 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3641 let kept = self.source[caret..content_end].to_string();
3642 self.splice(start, span.end, &kept, EditKind::Other);
3643 // The splice leaves the caret past the text it re-wrote; the caret
3644 // belongs where the content now starts, which is where it already was.
3645 self.caret = start;
3646 self.record_caret();
3647 } else {
3648 self.splice(start, caret, "", EditKind::Other);
3649 }
3650 true
3651 }
3652
3653 pub fn delete_forward(&mut self) {
3654 if let Some((s, e)) = self.selection() {
3655 self.splice(s, e, "", EditKind::Other);
3656 } else if self.caret < self.source.len() {
3657 // The mirror of Backspace's: forward-delete in front of a picture
3658 // would eat the `!` off its markup and leave a link where a photo was.
3659 if self.view != View::Source && self.delete_around_block_media(true) {
3660 return;
3661 }
3662 // And of Backspace's join: at the end of a block's content, Delete
3663 // joins the next block into this one.
3664 if self.view != View::Source && self.delete_forward_joins_block() {
3665 return;
3666 }
3667 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3668 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3669 // strands a broken `<br` in the cell.
3670 if self.view != View::Source
3671 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3672 {
3673 self.splice(start, end, "", EditKind::Delete);
3674 return;
3675 }
3676 // The mirror of Backspace's two steps: from in front of a run's
3677 // opening `**` step into it, onto the first letter of its text, and
3678 // at the end of a run's text step out past its closing `**` to the
3679 // character beyond. Either way Delete takes the character it looks
3680 // like it is pointing at, and never a delimiter drawn as nothing.
3681 // The caret then settles back inside the run it was standing in —
3682 // see `settle_inside_close_delims`.
3683 let from = if self.view == View::Source {
3684 self.caret
3685 } else {
3686 let inside = self.step_inside_open_delims(self.caret);
3687 // The mirror of Backspace's empty-span rule: an attributed
3688 // span with no text goes whole, with the character after it.
3689 if let Some(span) = self.run_span_of_content(inside..inside) {
3690 let to = if self.source[span.end..].starts_with('\n') {
3691 span.end
3692 } else {
3693 next_boundary(&self.source, span.end)
3694 };
3695 self.splice(span.start, to, "", EditKind::Delete);
3696 return;
3697 }
3698 self.skip_trailing_close_delims(inside)
3699 };
3700 let next = next_boundary(&self.source, from);
3701 // And of its emptying rules: the span goes with its last letter,
3702 // and so does the block's div or `{…}` line.
3703 if self.view != View::Source
3704 && let Some(span) = self.run_span_of_content(from..next)
3705 {
3706 self.splice(span.start, span.end, "", EditKind::Delete);
3707 return;
3708 }
3709 if self.view != View::Source
3710 && let Some(block) = self.attributed_block_of_content(from..next)
3711 {
3712 self.splice(block.start, block.end, "", EditKind::Delete);
3713 return;
3714 }
3715 if from < next {
3716 self.splice(from, next, "", EditKind::Delete);
3717 }
3718 }
3719 }
3720
3721 /// Delete from the caret back to the start of the previous word (⌥⌫ /
3722 /// Ctrl+⌫). Deletes the selection instead when one is active.
3723 pub fn delete_word_back(&mut self) {
3724 if let Some((s, e)) = self.selection() {
3725 self.splice(s, e, "", EditKind::Other);
3726 } else {
3727 // A word back from just past a picture is a word *of its markup*, and
3728 // a word back from in front of one runs through the paragraph break
3729 // into the prose above — dissolving the picture either way. See
3730 // `delete_around_block_media`.
3731 if self.view != View::Source && self.delete_around_block_media(false) {
3732 return;
3733 }
3734 let start = self.word_left_from(self.caret).max(self.caret_floor());
3735 if start < self.caret {
3736 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3737 self.splice(s, e, "", EditKind::Delete);
3738 }
3739 }
3740 }
3741
3742 /// Delete from the caret forward to the end of the next word (⌥⌦ /
3743 /// Ctrl+Del). Deletes the selection instead when one is active.
3744 pub fn delete_word_forward(&mut self) {
3745 if let Some((s, e)) = self.selection() {
3746 self.splice(s, e, "", EditKind::Other);
3747 } else {
3748 // The mirror: a word forward from in front of a picture is its markup.
3749 if self.view != View::Source && self.delete_around_block_media(true) {
3750 return;
3751 }
3752 let end = self.word_right_from(self.caret);
3753 if end > self.caret {
3754 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3755 self.splice(s, e, "", EditKind::Delete);
3756 }
3757 }
3758 }
3759
3760 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3761 /// selection instead when one is active, as every other delete here does.
3762 ///
3763 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3764 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3765 /// stops at the first character and this takes the indentation with it, the
3766 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3767 /// leave an indent behind that nothing can then ask to delete, where a caret
3768 /// left at column 0 is one press of Home away from either.
3769 pub fn delete_to_line_start(&mut self) {
3770 if let Some((s, e)) = self.selection() {
3771 self.splice(s, e, "", EditKind::Other);
3772 return;
3773 }
3774 // Never back across the floor: hidden frontmatter isn't on this line, or
3775 // on any line the WYSIWYG caret can see.
3776 let (start, _) = self.line_span();
3777 let start = start.max(self.caret_floor());
3778 if start < self.caret {
3779 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3780 self.splice(s, e, "", EditKind::Delete);
3781 }
3782 }
3783
3784 /// Kill from the caret to the end of its line (^K). Deletes the selection
3785 /// instead when one is active.
3786 ///
3787 /// At the end of the line it does nothing, rather than pulling the line
3788 /// below up into this one. Joining has no meaning to give it in both views
3789 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3790 /// there is nothing there to delete, while the newline a *source* line ends
3791 /// with is only half of the blank line that separates two paragraphs —
3792 /// deleting one leaves a soft break, which is not the join it looks like.
3793 /// The views agreeing is worth more than emacs' second press, and Delete is
3794 /// already the key that joins.
3795 pub fn delete_to_line_end(&mut self) {
3796 if let Some((s, e)) = self.selection() {
3797 self.splice(s, e, "", EditKind::Other);
3798 return;
3799 }
3800 let (_, end) = self.line_span();
3801 if end > self.caret {
3802 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3803 self.splice(s, e, "", EditKind::Delete);
3804 }
3805 }
3806
3807 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3808 ///
3809 /// A glyph-space range covers what the user can see, which for `**bold**` is
3810 /// the word and never the delimiters around it — so deleting the word on its
3811 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3812 /// word, and the styling was the word's; the two go together. Only the
3813 /// node's delimiters are taken, and those are hidden here anyway, so nothing
3814 /// visible outside the range is lost.
3815 ///
3816 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3817 /// the strong, and only then is the strong empty too.
3818 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3819 if self.view == View::Source {
3820 return (start, end);
3821 }
3822 let nodes = self.nodes();
3823 let (mut s, mut e) = (start, end);
3824 loop {
3825 let mut grew = false;
3826 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3827 let Some(text) = inline_content_span(n, &self.source) else {
3828 continue;
3829 };
3830 // Some of its text survives, so the node still has a job.
3831 if text.start < s || text.end > e {
3832 continue;
3833 }
3834 if n.span.start < s || n.span.end > e {
3835 s = s.min(n.span.start);
3836 e = e.max(n.span.end);
3837 grew = true;
3838 }
3839 }
3840 if !grew {
3841 return (s, e);
3842 }
3843 }
3844 }
3845
3846 /// One splice of document text, keeping the **mark-edge rule**: an inline
3847 /// mark's content never begins or ends with whitespace. In Markdown and Djot
3848 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3849 /// is four literal asterisks around a word, and a rich view drawing the
3850 /// document faithfully has no choice but to show them. That is correct
3851 /// rendering of what the file says, and nobody typing a space after a bold
3852 /// word meant to say it.
3853 ///
3854 /// So the space goes *outside* the run instead — `**bold** ` — which is the
3855 /// same document to a reader and a live one to a parser. The caret follows it
3856 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3857 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3858 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3859 ///
3860 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3861 /// through here, so the rule holds however the whitespace arrives at the
3862 /// edge. The repair is decided *after* the plain edit, by asking whether the
3863 /// mark actually died: a code span's backticks aren't whitespace-sensitive
3864 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3865 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3866 let fix = self.mark_edge_fix(start, end, text);
3867 if !self.splice_exact(start, end, text, kind) {
3868 return false;
3869 }
3870 if let Some(fix) = fix {
3871 self.repair_mark_edges(fix);
3872 }
3873 if text.is_empty() && end > start {
3874 self.settle_inside_close_delims();
3875 }
3876 true
3877 }
3878
3879 /// After a delete, take a caret left standing past a run's closing delimiters
3880 /// back inside the run.
3881 ///
3882 /// A delete leaves the caret where the deleted bytes began, and when those
3883 /// bytes were the last thing after a marked phrase — the space the mark-edge
3884 /// rule pushed out of `**bold** `, say — that spot is the far side of the
3885 /// closing `**`. The rich view has nothing to draw there: the delimiters are
3886 /// hidden, so the caret shows at the end of the word either way, and the two
3887 /// offsets are one place on screen with two different meanings. Typing at the
3888 /// outer one lands past the run, so the writer who backspaced a space out of
3889 /// their bold phrase watches the next character come out plain, and the
3890 /// toolbar button go dark, with the caret never appearing to move.
3891 ///
3892 /// The end of the run's text is the caret's home there — a delete that took
3893 /// away everything after a phrase leaves the caret at the end of that phrase,
3894 /// which is inside it — so it settles onto that
3895 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3896 /// walk, through every mark closing at the point): the word stays bold, the
3897 /// button stays lit, and the next character carries on the phrase.
3898 ///
3899 /// Rich view only, and only where a mark really closes at the caret — mid-run
3900 /// or in plain prose no span ends there and the caret stays put. The opening
3901 /// edge is left alone on purpose: a caret in front of a run inherits from the
3902 /// text on its left, which is the plain text outside.
3903 fn settle_inside_close_delims(&mut self) {
3904 if self.view != View::Wysiwyg {
3905 return;
3906 }
3907 let at = self.step_inside_close_delims(self.caret);
3908 if at != self.caret {
3909 self.caret = at;
3910 self.clear_pending();
3911 self.record_caret();
3912 }
3913 }
3914
3915 /// The splice exactly as asked, with no mark-edge repair — for the callers
3916 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3917 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3918 /// the bytes they inserted.
3919 ///
3920 /// One `edit_range` through twig, then re-anchor the caret from the returned
3921 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3922 /// Markdown/Djot) leaves the document untouched and reports.
3923 ///
3924 /// Returns whether the edit landed — for a caller that has offsets of its
3925 /// own to place afterwards, which a rolled-back splice would leave pointing
3926 /// into text that never came to exist.
3927 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3928 // The read-only gate, for every edit at once — see the field.
3929 if self.read_only {
3930 return false;
3931 }
3932 // twig records an undo step for every edit; when this one continues a
3933 // run of the same kind (typing, deleting), tell twig to fold it into the
3934 // step before it so the whole run undoes at once.
3935 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3936 // Hand twig the pre-edit caret before the splice, so the undo step it
3937 // retires carries where the caret was standing.
3938 self.record_caret();
3939 match self.editor.edit_range(start, end, text) {
3940 Ok(change) => {
3941 if coalesce {
3942 let _ = self.editor.coalesce_last_undo();
3943 }
3944 self.last_edit_kind = Some(kind);
3945 self.refresh();
3946 self.caret = change.new.end;
3947 self.anchor = None;
3948 self.goal_col = None;
3949 self.clear_pending();
3950 self.dirty = self.source != self.clean_source;
3951 self.status = None;
3952 // And the post-edit caret, so a later redo restores it.
3953 self.record_caret();
3954 true
3955 }
3956 // The edit was rolled back, so twig's history did not move and
3957 // neither may ours: pushing here would leave a step with no edit
3958 // under it and shift every later undo onto the wrong caret.
3959 Err(e) => {
3960 self.status = Some(format!("edit: {e}"));
3961 false
3962 }
3963 }
3964 }
3965
3966 /// The re-spelling that would keep the mark-edge rule for the edit
3967 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3968 /// against a delimiter and the plain splice is already right. Computed
3969 /// *before* the edit, while the run's spans and delimiters can still be read
3970 /// off the document; applied afterwards, and only if the mark really died —
3971 /// see [`repair_mark_edges`](Self::repair_mark_edges).
3972 ///
3973 /// Rich view only. Source view is for typing raw markup, where a space put
3974 /// against a `**` is exactly the character it looks like.
3975 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3976 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3977 return None;
3978 }
3979 // Every inline mark standing over the edit, outermost first, with the
3980 // content span that says where its delimiters are.
3981 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3982 .editor
3983 .ancestors_at(start)
3984 .unwrap_or_default()
3985 .into_iter()
3986 .filter_map(|m| {
3987 let kind = inline_kind(&m.kind)?;
3988 let content = m.content_span.clone()?;
3989 Some((kind, m.span.clone(), content))
3990 })
3991 .collect();
3992 // The innermost run whose *content* holds the whole edit: the one whose
3993 // text is being changed, rather than one the edit merely sits under.
3994 let (kind, span, content) = chain
3995 .iter()
3996 .rev()
3997 .find(|(_, _, c)| c.start <= start && end <= c.end)?
3998 .clone();
3999 // What that content becomes. Whitespace at either end of it is what
4000 // would put out the mark.
4001 let body = format!(
4002 "{}{text}{}",
4003 &self.source[content.start..start],
4004 &self.source[end..content.end]
4005 );
4006 let (lead, trail) = if body.trim().is_empty() {
4007 // Nothing but whitespace left: there is no content to mark at all,
4008 // and the delimiters go with it rather than closing on a space.
4009 (body.len(), 0)
4010 } else {
4011 (
4012 body.len() - body.trim_start().len(),
4013 body.len() - body.trim_end().len(),
4014 )
4015 };
4016 // Nothing against a delimiter, and something still between them: the
4017 // plain edit stands. An emptied run is broken just as surely (`**b**`
4018 // with the `b` deleted is the literal `****`) and is re-spelt as the
4019 // nothing it now says.
4020 if lead == 0 && trail == 0 && !body.is_empty() {
4021 return None;
4022 }
4023 // Marks that open or close exactly where this one does — `***both***` is
4024 // two runs sharing an edge — spell their delimiters as one run of bytes,
4025 // so the whitespace has to clear all of them together.
4026 let (mut open_at, mut close_at) = (span.start, span.end);
4027 for _ in 0..chain.len() {
4028 match chain.iter().find(|(_, _, c)| c.start == open_at) {
4029 Some((_, s, _)) => open_at = s.start,
4030 None => break,
4031 }
4032 }
4033 for _ in 0..chain.len() {
4034 match chain.iter().find(|(_, _, c)| c.end == close_at) {
4035 Some((_, s, _)) => close_at = s.end,
4036 None => break,
4037 }
4038 }
4039 let open = &self.source[open_at..content.start];
4040 let close = &self.source[content.end..close_at];
4041 let core = &body[lead..body.len() - trail];
4042 let respelt = if core.is_empty() {
4043 body.clone()
4044 } else {
4045 format!(
4046 "{}{open}{core}{close}{}",
4047 &body[..lead],
4048 &body[body.len() - trail..]
4049 )
4050 };
4051 // The caret sits just past the inserted text within the new content —
4052 // which, when that lands in the whitespace, is now outside the delimiters.
4053 let pos = (start - content.start) + text.len();
4054 let caret = if core.is_empty() || pos <= lead {
4055 open_at + pos
4056 } else if pos >= lead + core.len() {
4057 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4058 } else {
4059 open_at + lead + open.len() + (pos - lead)
4060 };
4061 Some(MarkEdgeFix {
4062 kind,
4063 probe: content.start,
4064 start: open_at,
4065 end: close_at + text.len() - (end - start),
4066 text: respelt,
4067 caret,
4068 // The marks in force here, resolved against any armed sticky delta —
4069 // what the writer is typing in, and so what has to still be true on
4070 // the far side of the delimiter the caret just stepped over.
4071 want: chain
4072 .iter()
4073 .filter(|(_, s, _)| start < s.end)
4074 .map(|(k, _, _)| *k)
4075 .collect::<InlineMarks>()
4076 .xor(self.pending_here()),
4077 })
4078 }
4079
4080 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4081 /// did break the mark. Whether whitespace at a delimiter is fatal is the
4082 /// format's business, not leaf's: `**bold **` is no longer strong, while
4083 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4084 /// don't care either. Asking the parser afterwards settles it for every kind
4085 /// and format at once, and costs a re-spelling only where one is due.
4086 ///
4087 /// The repair rides along with the edit that caused it — one undo step puts
4088 /// back what the writer typed, not a delimiter shuffle they never saw.
4089 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4090 if fix.end > self.source.len() {
4091 return;
4092 }
4093 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4094 return; // still a mark: these delimiters don't mind the whitespace
4095 }
4096 let resumed = self.last_edit_kind;
4097 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4098 return;
4099 }
4100 let _ = self.editor.coalesce_last_undo();
4101 // The keystroke owns the undo step, so the run of typing it belongs to
4102 // keeps coalescing over the repair rather than breaking in two here.
4103 self.last_edit_kind = resumed;
4104 self.caret = fix.caret.min(self.source.len());
4105 self.anchor = None;
4106 self.goal_col = None;
4107 self.rearm(fix.want);
4108 self.clamp_caret();
4109 self.record_caret();
4110 }
4111
4112 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4113 /// writer is typing in, carried across an edit that moved the caret out of
4114 /// the run holding them. Arms nothing when the caret already stands in
4115 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4116 /// a clean delta here (see [`toggle`](Self::toggle)).
4117 fn rearm(&mut self, want: InlineMarks) {
4118 let here: InlineMarks = self
4119 .marks_at(self.caret)
4120 .into_iter()
4121 .map(|(k, _)| k)
4122 .collect();
4123 self.pending_marks = want.xor(here);
4124 self.pending_at = Some(self.caret);
4125 }
4126
4127 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4128 /// which backslash-escapes any character that would otherwise open markup in
4129 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4130 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4131 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4132 /// a collapsed point — a selection is deleted by the caller first, since
4133 /// `insert_literal` inserts rather than replaces.
4134 fn insert_literal_at(
4135 &mut self,
4136 at: usize,
4137 text: &str,
4138 kind: EditKind,
4139 force_coalesce: bool,
4140 ) -> bool {
4141 // The read-only gate: this door goes to twig directly, not through
4142 // `splice_exact`, so it guards itself — see the field.
4143 if self.read_only {
4144 return false;
4145 }
4146 // `force_coalesce` folds this into the immediately preceding edit (the
4147 // selection-delete of an overwrite) so the pair is one undo step; else it
4148 // coalesces only when it continues a run of the same-kind typing.
4149 let coalesce =
4150 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4151 // The mark-edge rule holds for typed text however it is spelled — see
4152 // `splice`. Only an insert twig passed through unchanged can use it,
4153 // since a fix is measured in the bytes that actually land, and an escape
4154 // adds bytes this couldn't have counted.
4155 let fix = self.mark_edge_fix(at, at, text);
4156 self.record_caret();
4157 match self.editor.insert_literal(at, text) {
4158 Ok(change) => {
4159 if coalesce {
4160 let _ = self.editor.coalesce_last_undo();
4161 }
4162 self.last_edit_kind = Some(kind);
4163 self.refresh();
4164 self.caret = change.new.end;
4165 self.anchor = None;
4166 self.goal_col = None;
4167 self.clear_pending();
4168 self.dirty = self.source != self.clean_source;
4169 self.status = None;
4170 self.record_caret();
4171 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4172 self.repair_mark_edges(fix);
4173 }
4174 true
4175 }
4176 Err(e) => {
4177 self.status = Some(format!("edit: {e}"));
4178 false
4179 }
4180 }
4181 }
4182
4183 /// After a structural list edit (a new item, a nest/unnest), renumber the
4184 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4185 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4186 /// renumber as its own edit; fold it into the edit that triggered it so the
4187 /// two undo as one, and only when it actually changed the source (a no-op or
4188 /// a caret outside any ordered list must not coalesce the real edit into the
4189 /// step before it).
4190 fn renumber_here(&mut self) {
4191 self.renumber_at(self.caret);
4192 }
4193
4194 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4195 /// caret — for an edit that leaves the caret one past the item it just wrote,
4196 /// where twig resolves no list to renumber.
4197 fn renumber_at(&mut self, off: usize) {
4198 // The read-only gate — this door reaches twig without the splice.
4199 if self.read_only {
4200 return;
4201 }
4202 let before = self.source.clone();
4203 if self.editor.renumber_ordered_lists(off).is_err() {
4204 return; // not inside an ordered list — nothing to renumber
4205 }
4206 self.refresh();
4207 if self.source != before {
4208 let _ = self.editor.coalesce_last_undo();
4209 self.dirty = self.source != self.clean_source;
4210 self.clamp_caret();
4211 self.record_caret();
4212 }
4213 }
4214
4215 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4216 /// sub-item written directly beneath a text line reparses that text as a
4217 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
4218 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4219 /// bullets can't underline anything, so swap the dash for a `*`: the item
4220 /// stays an empty nested bullet, the parent stays prose, and the source
4221 /// round-trips instead of hiding a heading the user never asked for. Folded
4222 /// into the triggering edit's undo step, the way renumbering is.
4223 ///
4224 /// Gated on the collapse having actually happened (the swapped dash was
4225 /// swallowed into a `heading`), so a real setext heading the author wrote —
4226 /// or a `- x` with content, which can't underline anything — is never
4227 /// touched. This has to live in the *edit*, not the renderer: leaving the
4228 /// hazardous bytes on disk and only painting over them would ship a file
4229 /// every other CommonMark tool reads as a heading.
4230 ///
4231 /// This one keeps its own byte scan, and has to: the hazard is precisely
4232 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4233 /// which asks twig which lines open an item — reports nothing here. There is
4234 /// no node to ask about. It is also the last Markdown spelling leaf writes on
4235 /// purpose rather than for want of an answer; once twig spells continuations
4236 /// itself, avoiding the trap becomes twig's, and this goes.
4237 ///
4238 /// [`list_marker_on_line`]: Self::list_marker_on_line
4239 fn avoid_setext_collapse(&mut self) {
4240 let caret = self.caret.min(self.source.len());
4241 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4242 let bytes = self.source.as_bytes();
4243 let mut dash = line_start;
4244 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4245 dash += 1;
4246 }
4247 // A dash bullet is the only marker that doubles as a setext underline.
4248 if bytes.get(dash) != Some(&b'-') {
4249 return;
4250 }
4251 // Only an *empty* item is a bare underline; `- x` carries content and
4252 // can't fold the line above into a heading.
4253 let line_end = self.source[dash..]
4254 .find('\n')
4255 .map_or(self.source.len(), |i| dash + i);
4256 if !self.source[dash + 1..line_end].trim().is_empty() {
4257 return;
4258 }
4259 // The tell: that dash was swallowed into a `heading`. A properly nested
4260 // empty item sits under a `list_item`, with no heading in reach. Probe
4261 // the dash byte itself (well inside the heading), not the caret, whose
4262 // end-of-line offset can fall on the half-open span boundary.
4263 let collapsed = self
4264 .editor
4265 .ancestors_at(dash)
4266 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4267 .unwrap_or(false);
4268 if !collapsed {
4269 return;
4270 }
4271 let caret = self.caret;
4272 if self.splice(dash, dash + 1, "*", EditKind::Other) {
4273 // Same width, so the caret keeps its column; fold into the edit that
4274 // triggered this so Tab stays one undo step.
4275 let _ = self.editor.coalesce_last_undo();
4276 self.caret = caret.min(self.source.len());
4277 self.clamp_caret();
4278 self.record_caret();
4279 }
4280 }
4281
4282 fn snapshot(&self) -> CaretState {
4283 CaretState {
4284 caret: self.caret,
4285 anchor: self.anchor,
4286 }
4287 }
4288
4289 /// Hand twig the current caret and selection as the blob for the live
4290 /// document state. Called before an edit — so the step twig retires records
4291 /// where the caret was, and undo can restore it — and again once the op has
4292 /// placed the caret, so redo restores where the edit left it.
4293 ///
4294 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4295 /// caret through its own history, so coalescing falls out for free (folding
4296 /// two twig steps into one drops the intermediate blob, keeping the run's
4297 /// first) and the parallel stacks that had to march in lockstep — and could
4298 /// silently drift out of it — are gone.
4299 fn record_caret(&mut self) {
4300 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4301 }
4302
4303 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4304 /// the toggled region selected so a second press cleanly reverses it.
4305 pub fn toggle(&mut self, kind: InlineKind) {
4306 // The read-only gate — this door reaches twig without the splice.
4307 if self.read_only {
4308 return;
4309 }
4310 // Ahead of the no-selection branch below: arming a mark for text not yet
4311 // typed is a promise `insert` cannot keep in a format with no delimiters
4312 // to spell it with. Per *kind*, not per format — Markdown spells five
4313 // of the eight marks (highlight among them, under the `highlight`
4314 // extension leaf parses with), djot all eight, HTML seven.
4315 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4316 return;
4317 }
4318 let Some((s, e)) = self.selection() else {
4319 // No selection: arm the mark for the next text typed here, the way a
4320 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4321 // in the flow of typing without ever selecting anything — the delta
4322 // is realised onto the freshly typed text by `insert`. A fresh caret
4323 // position starts the delta over from the marks actually in force.
4324 if self.pending_at != Some(self.caret) {
4325 self.pending_marks = InlineMarks::empty();
4326 self.pending_at = Some(self.caret);
4327 }
4328 self.pending_marks.flip(kind);
4329 self.status = None;
4330 return;
4331 };
4332 // Whitespace at the edge of a selection is not part of what was chosen —
4333 // a double-click takes the space after the word with it — and a mark
4334 // cannot close against one anyway: `**word **` is four literal asterisks
4335 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4336 let picked = &self.source[s..e];
4337 let (s, e) = (
4338 s + (picked.len() - picked.trim_start().len()),
4339 e - (picked.len() - picked.trim_end().len()),
4340 );
4341 if s >= e {
4342 self.status = Some(format!("{kind:?}: nothing selected to mark"));
4343 return;
4344 }
4345 // Styling a selection is a one-shot act, not a sticky mode.
4346 self.clear_pending();
4347 self.record_caret();
4348 match self.editor.toggle_inline(s, e, kind) {
4349 Ok(change) => {
4350 self.last_edit_kind = None; // structural edit is its own undo step
4351 self.refresh();
4352 self.anchor = Some(change.new.start);
4353 self.caret = change.new.end;
4354 self.dirty = self.source != self.clean_source;
4355 self.status = None;
4356 self.record_caret();
4357 }
4358 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4359 }
4360 }
4361
4362 /// Whether the caret stands in a highlight — what a frontend asks to enable
4363 /// or disable its highlight-colour controls, the way
4364 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4365 ///
4366 /// A fact about the *caret*, and the other half of
4367 /// [`Capabilities::mark_color`], which is the fact about the format. A
4368 /// frontend needs both: djot spells a highlight and no colour for it, so a
4369 /// caret standing in `{=word=}` answers `true` here and still has no palette
4370 /// to offer.
4371 ///
4372 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4373 /// the palette appears exactly where the Highlight button is lit — with one
4374 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4375 /// into lights the button and answers `false` here, because there is no node
4376 /// to colour until the text exists.
4377 pub fn caret_in_mark(&mut self) -> bool {
4378 self.mark_offset().is_some()
4379 }
4380
4381 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4382 /// standing in the highlight the gesture means — or `None` when neither end
4383 /// of what is selected is in one.
4384 ///
4385 /// The caret first, and the selection's *start* after it, because of what
4386 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4387 /// whole, with the caret at its far edge, one past the closing `==` and so
4388 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4389 /// and colour it — the two presses a coloured highlight is made of — would
4390 /// otherwise refuse on the second, having just written the highlight the
4391 /// author is pointing at.
4392 fn mark_offset(&mut self) -> Option<usize> {
4393 let in_mark = |d: &mut Self, off: usize| {
4394 d.marks_at(off)
4395 .into_iter()
4396 .any(|(k, _)| k == InlineKind::Mark)
4397 .then_some(off)
4398 };
4399 let caret = self.caret.min(self.source.len());
4400 in_mark(self, caret).or_else(|| {
4401 let start = self.selection()?.0;
4402 in_mark(self, start)
4403 })
4404 }
4405
4406 /// The colour of the highlight at the caret — `None` both when the caret is
4407 /// in no highlight and when the highlight it is in names no colour, which
4408 /// are the same answer to "which swatch is lit".
4409 ///
4410 /// The innermost mark, by span, for the same reason
4411 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4412 /// what the caret is *in* is the deepest node containing it. A `data-color`
4413 /// naming a colour this build has no variant for reads as `None` — the
4414 /// renderer already draws that as a plain highlight rather than guessing,
4415 /// and the toolbar agrees with the renderer.
4416 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4417 let at = self.mark_offset()?;
4418 self.mark_color_at(at)
4419 }
4420
4421 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4422 /// the innermost `mark` covering it, and the colour it names.
4423 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4424 self.nodes()
4425 .into_iter()
4426 .filter(|n| n.kind == Kind::Mark)
4427 .filter(|n| n.span.start <= off && off < n.span.end)
4428 .min_by_key(|n| n.span.end - n.span.start)
4429 .and_then(|n| MarkColor::from_attrs(&n.attrs))
4430 }
4431
4432 /// Colour the highlight at the caret, or clear its colour with `None` — the
4433 /// palette behind a toolbar's Highlight button.
4434 ///
4435 /// Markdown only, and the one gesture whose availability is a fact about the
4436 /// *parse extensions* rather than about the format alone: the colour is
4437 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4438 /// records as the mark's `data-color`, and only an editor parsing with
4439 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4440 /// document) reads it back that way. Djot spells the highlight and no colour
4441 /// for it, so this refuses there — see [`Capabilities::mark_color`].
4442 ///
4443 /// **A colour is a property of a highlight that already exists.** There is
4444 /// no "highlight this in red" here, because that is two splices and would be
4445 /// two undo steps under one press; a frontend that wants it calls
4446 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4447 /// order the two buttons already sit in. With no highlight at the caret this
4448 /// says so in the status line and writes nothing.
4449 ///
4450 /// The caret keeps its place in the *text*: the splice is entirely in the
4451 /// prefix between the opening `==` and the first word, so an offset past it
4452 /// rides the emoji's width, and one standing on the prefix itself lands
4453 /// where the prefix now ends.
4454 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4455 // The read-only gate — this door reaches twig without the splice.
4456 if self.read_only {
4457 return;
4458 }
4459 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4460 return;
4461 }
4462 let Some(at) = self.mark_offset() else {
4463 self.status = Some("highlight colour: no highlight at the caret".into());
4464 return;
4465 };
4466 // Clearing a colour a highlight hasn't got is twig's one *successful*
4467 // no-op, and the `Change` it hands back then describes whatever edit came
4468 // before it — a stale span that would drag the caret somewhere it never
4469 // was. Answer it here, where the question is cheap, rather than trusting
4470 // a change that isn't one.
4471 if color.is_none() && self.mark_color_at(at).is_none() {
4472 self.status = None;
4473 return;
4474 }
4475 self.record_caret();
4476 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4477 Ok(change) => {
4478 // Re-anchored from the offsets as they were, *before* `refresh`
4479 // sees the new bytes: the caret it clamps is one standing inside
4480 // a prefix that didn't exist a moment ago, and walking it back to
4481 // a char boundary of the emoji loses the place this is restoring.
4482 let caret = reanchor(self.caret, &change);
4483 let anchor = self.anchor.map(|a| reanchor(a, &change));
4484 self.last_edit_kind = None; // structural edit is its own undo step
4485 self.refresh();
4486 self.caret = caret;
4487 self.anchor = anchor;
4488 self.dirty = self.source != self.clean_source;
4489 self.status = None;
4490 self.clamp_caret();
4491 self.record_caret();
4492 }
4493 Err(e) => self.status = Some(format!("highlight colour: {e}")),
4494 }
4495 }
4496
4497 /// One press of a colour swatch: colour the highlight at the caret, or —
4498 /// over a selection that isn't highlighted yet — highlight it and colour it,
4499 /// as **one** undo step.
4500 ///
4501 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4502 /// one splice; this is the compound every toolbar actually presses, and it
4503 /// lives here rather than in each frontend because the rule it encodes —
4504 /// what a swatch means when there is no highlight under it yet — is one
4505 /// answer, not one per frontend. The two splices are folded into a single
4506 /// history step, so the press that made a red highlight is taken back by a
4507 /// single undo rather than leaving an uncoloured one behind.
4508 ///
4509 /// `None` clears the colour, and over an unhighlighted selection means
4510 /// simply "highlight this" — the same thing the Highlight button does.
4511 /// A bare caret in no highlight is left alone with a status line, because
4512 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4513 /// colour cannot be armed with it.
4514 pub fn highlight(&mut self, color: Option<MarkColor>) {
4515 if self.caret_in_mark() || self.selection().is_none() {
4516 self.set_mark_color(color);
4517 return;
4518 }
4519 self.toggle(InlineKind::Mark);
4520 // The format may not spell a highlight at all (`toggle` said so), and
4521 // there is nothing to colour if it doesn't.
4522 if self.status.is_some() {
4523 return;
4524 }
4525 let before = self.revision;
4526 self.set_mark_color(color);
4527 // Only fold when the colour really spliced. `highlight(None)` over a
4528 // fresh highlight is a no-op by design, and coalescing there would eat
4529 // the *previous* edit into the toggle instead.
4530 if self.revision != before {
4531 let _ = self.editor.coalesce_last_undo();
4532 }
4533 }
4534
4535 // ── the presentation vocabulary ─────────────────────────────────────────
4536 //
4537 // Six gestures and five queries over twig's two attribute ops. Each gesture
4538 // edits **one key and keeps the rest**: it reads the node's attributes,
4539 // removes its own key (and, for alignment, its own tokens out of `class`),
4540 // adds the new value or nothing, and passes the list back whole — twig's
4541 // contract is replace-not-merge, so the read is the caller's job. A
4542 // paragraph that came in as `class="lead center" id="intro"
4543 // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4544 // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4545 // touched, which is what lets a document from elsewhere pass through the
4546 // editor unharmed.
4547 //
4548 // Clearing is the same gesture with `None`: the key goes, and an empty list
4549 // at the end unwraps the span or the Markdown div, which twig does.
4550
4551 /// Set — or with `None` clear — the alignment of the block the caret is in.
4552 ///
4553 /// A block property, so the gesture is `set_block_attrs` on the caret's
4554 /// block **whatever is selected**: a line is a block's, and "centre this"
4555 /// with three words selected means the paragraph, not the words. The
4556 /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4557 /// same `class` are kept.
4558 ///
4559 /// In Markdown the attributes live on a `<div>` around the block — twig has
4560 /// no paragraph attribute syntax to write — and this reads them back off
4561 /// that div when the block is its sole child, so a second press rewrites
4562 /// the div rather than nesting a second one.
4563 pub fn set_alignment(&mut self, align: Option<Align>) {
4564 let attrs = self.block_attrs_at_caret();
4565 if align.is_none()
4566 && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4567 {
4568 return;
4569 }
4570 let attrs = with_class_token(
4571 &attrs,
4572 |t| Align::from_token(t).is_some(),
4573 align.map(Align::name),
4574 );
4575 self.write_block_attrs("alignment", attrs);
4576 }
4577
4578 /// Set — or with `None` clear — the line spacing of the block the caret is
4579 /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4580 /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4581 /// three names or an exact ratio, written in its canonical spelling.
4582 pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4583 let attrs = self.block_attrs_at_caret();
4584 if spacing.is_none()
4585 && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4586 LineHeight::from_attrs(a).is_some()
4587 })
4588 {
4589 return;
4590 }
4591 let spelling = spacing.map(LineHeight::name);
4592 let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4593 self.write_block_attrs("line spacing", attrs);
4594 }
4595
4596 /// Set — or with `None` clear — the size of the selected run, or of the
4597 /// caret's whole block when nothing is selected.
4598 ///
4599 /// Size, face and colour are the *run's*, and the block's when no run is
4600 /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4601 /// the range in an attributed span or re-styles the span it already lies in
4602 /// (never nesting a second, and unwrapping it when the last key goes). With
4603 /// no selection it is `set_block_attrs` on the caret's block, so that "make
4604 /// this paragraph larger" is a click with the caret in it rather than a
4605 /// select-all first.
4606 ///
4607 /// The walker reads the key at both levels with the nearer winning, so a
4608 /// span's `data-size` inside a block carrying its own applies to the span.
4609 ///
4610 /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4611 /// what a menu offers first because a step reads as a step up under every
4612 /// theme, or the point size an author asked for, which is exact and is all
4613 /// it is. Either is written in its canonical spelling, so a size set twice
4614 /// from the same field writes the same bytes both times.
4615 pub fn set_font_size(&mut self, size: Option<FontSize>) {
4616 let spelling = size.map(FontSize::name);
4617 self.set_run_attr("size", "data-size", spelling.as_deref());
4618 }
4619
4620 /// Set — or with `None` clear — the face of the selected run, or of the
4621 /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4622 /// [`FontFace`] under `data-font` — one of the four generics, or the family
4623 /// the author named, which the frontends resolve through the platform's
4624 /// font registry and fall back to the body face without.
4625 pub fn set_font_family(&mut self, font: Option<FontFace>) {
4626 let spelling = font.as_ref().map(FontFace::name);
4627 self.set_run_attr("font", "data-font", spelling.as_deref());
4628 }
4629
4630 /// Set — or with `None` clear — the *text* colour of the selected run, or of
4631 /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4632 /// with [`TextColor`] under `data-color` — one of the seven names, whose
4633 /// two inks the theme owns, or the triple the author picked, which is
4634 /// painted as written in both appearances.
4635 ///
4636 /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4637 /// writes, and a different thing: that one colours a highlight's
4638 /// *background* and rides the `mark` node twig owns the spelling of, this
4639 /// one colours the letters and rides an attributed span. The two never
4640 /// collide, because a `mark` is a `mark` and a span is a span — and they
4641 /// share a vocabulary on purpose, so that a frontend with a red for a
4642 /// highlight has a red for text and both are *that* red.
4643 pub fn set_text_color(&mut self, color: Option<TextColor>) {
4644 let spelling = color.map(TextColor::name);
4645 self.set_run_attr("text colour", "data-color", spelling.as_deref());
4646 }
4647
4648 /// Insert a page break at the caret — `::page-break`, a leaf directive with
4649 /// no label and no attributes, which twig spells in every format that names
4650 /// a leaf container (Markdown under the `directives` extension
4651 /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4652 /// page-break` fence).
4653 ///
4654 /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4655 /// places a rule, and for the same reason: a directive is a block, so twig
4656 /// alone has nowhere to put one mid-paragraph and lands it after the
4657 /// caret's whole block. A bare paragraph is therefore parted at the caret
4658 /// first and the break aimed at the *first* half. See that method for the
4659 /// whole of the rule, including why a code block, a list item, a table and
4660 /// a setext heading are left unsplit.
4661 ///
4662 /// The frontends that paginate read the row's
4663 /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4664 /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4665 /// directive gets.
4666 pub fn insert_page_break(&mut self) {
4667 if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4668 return;
4669 }
4670 self.caret = self.skip_trailing_close_delims(self.caret);
4671 // A selection is replaced by the break, as a rule replaces one.
4672 if let Some((s, e)) = self.selection() {
4673 self.splice(s, e, "", EditKind::Other);
4674 }
4675 self.anchor = None;
4676 self.record_caret();
4677 let at = self.caret;
4678 if self.caret_parts_bare_paragraph() {
4679 // A failure here is not fatal: the break still lands after the
4680 // block, which is what this call was trying to improve on.
4681 let _ = self.editor.split_block(at);
4682 }
4683 match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4684 Ok(change) => {
4685 self.last_edit_kind = None;
4686 self.refresh();
4687 self.anchor = None;
4688 self.caret = change.new.end;
4689 self.dirty = self.source != self.clean_source;
4690 self.status = None;
4691 self.clamp_caret();
4692 self.record_caret();
4693 }
4694 Err(e) => self.status = Some(format!("page break: {e}")),
4695 }
4696 }
4697
4698 /// The alignment in force at the caret, or `None` for the theme's default —
4699 /// which swatch of an alignment control is lit.
4700 ///
4701 /// Read off the nearest node that names one: the block the caret is in, and
4702 /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4703 /// shape, one property along.
4704 pub fn alignment_at_caret(&mut self) -> Option<Align> {
4705 self.presentation_chain()
4706 .iter()
4707 .find_map(|attrs| Align::from_attrs(attrs))
4708 }
4709
4710 /// The line spacing in force at the caret, or `None` for the theme's own.
4711 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4712 pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
4713 self.presentation_chain()
4714 .iter()
4715 .find_map(|attrs| LineHeight::from_attrs(attrs))
4716 }
4717
4718 /// The size in force at the caret, or `None` for the theme's own — the
4719 /// entry a size menu shows ticked.
4720 ///
4721 /// Run-level, so the chain starts one node deeper: the attributed span the
4722 /// caret stands in, then its block, then the `div`s around it. The nearest
4723 /// wins, which is the rule the walker draws by.
4724 ///
4725 /// A name or a value, whichever the nearest node wrote. A `data-size` the
4726 /// grammar does not cover — a `huge` from elsewhere — is not a size this
4727 /// can answer, so the answer is `None` and the menu ticks *Default*, the
4728 /// same thing it did before the vocabulary opened.
4729 pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
4730 self.presentation_chain()
4731 .iter()
4732 .find_map(|attrs| FontSize::from_attrs(attrs))
4733 }
4734
4735 /// The face in force at the caret, or `None` for the theme's body face.
4736 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4737 pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
4738 self.presentation_chain()
4739 .iter()
4740 .find_map(|attrs| FontFace::from_attrs(attrs))
4741 }
4742
4743 /// The *text* colour in force at the caret, or `None` for the theme's.
4744 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4745 /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4746 /// highlight's background off a `mark`, and a `mark` is never in this chain.
4747 pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
4748 self.presentation_chain()
4749 .iter()
4750 .find_map(|attrs| TextColor::from_attrs(attrs))
4751 }
4752
4753 /// The selection-or-caret half of the three run-level gestures: a span over
4754 /// a real selection, the caret's block over none.
4755 fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4756 match self.selection() {
4757 Some((start, end)) => {
4758 let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4759 self.write_run_attrs(what, start, end, attrs);
4760 }
4761 None => {
4762 let own = self.block_attrs_at_caret();
4763 if value.is_none()
4764 && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4765 {
4766 return;
4767 }
4768 let attrs = with_attr(&own, key, value);
4769 self.write_block_attrs(what, attrs);
4770 }
4771 }
4772 }
4773
4774 /// A clear this gesture cannot carry out, said out loud instead of written:
4775 /// the node it rewrites — the caret's block, or the `<div>` around it that
4776 /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4777 /// — does not name the property at all, and a `div` further out does.
4778 ///
4779 /// Handing twig the block's attributes with the key already absent changes
4780 /// no byte, and the query goes on answering `Some` off the div: the menu
4781 /// entry the author pressed stays unticked, and nothing says why. Twig's
4782 /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4783 /// write on a node it is not rewriting — the honest answer is the status
4784 /// line, in the voice the other refusals use.
4785 ///
4786 /// `names` is the property's own reading of an attribute list, because
4787 /// alignment lives in a `class` token rather than a key of its own. Spans
4788 /// are skipped: one inside the block is not what a *block* gesture writes
4789 /// either, but neither is it "the div around the block", and the run-level
4790 /// gestures reach it through a selection.
4791 fn refuse_clear_from_div(
4792 &mut self,
4793 what: &str,
4794 own: &Attrs,
4795 names: impl Fn(&Attrs) -> bool,
4796 ) -> bool {
4797 if names(own) {
4798 return false;
4799 }
4800 let caret = self.caret.min(self.source.len());
4801 if !self
4802 .attr_chain_at(caret)
4803 .iter()
4804 .any(|(span, attrs)| !span && names(attrs))
4805 {
4806 return false;
4807 }
4808 self.status = Some(format!("{what}: set on the div around the block"));
4809 true
4810 }
4811
4812 /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4813 /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4814 /// has.
4815 ///
4816 /// **The caret keeps its place in the text, not its byte offset.** How a
4817 /// format spells a block's attributes is markup written *around* the block
4818 /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4819 /// it in Markdown, a longer opening tag in HTML — and every one of those
4820 /// grows or shrinks above the author's own bytes. Where twig's change
4821 /// rewrites the block whole (Markdown's div is spliced as one region, block
4822 /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4823 /// and parks the caret at the end of the splice, past the closing `</div>`:
4824 /// the caret is then in no block at all, so a second press of the same menu
4825 /// answers "no block at the caret" and the toolbar's queries read nothing.
4826 /// [`reanchor_in_block`] is what carries it across instead — the block's
4827 /// content span before and after, which is the one thing the respelling
4828 /// leaves alone.
4829 ///
4830 /// Read *before* the splice and applied *after* `refresh`, because both
4831 /// halves of that mapping are facts about a tree twig is between: the
4832 /// block's old bytes are gone once the edit lands, and its new ones are not
4833 /// in `self.source` until the refresh puts them there.
4834 fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4835 if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4836 return;
4837 }
4838 // A blank line has no block to carry an attribute, and twig answers
4839 // `NotFound` there — say so in leaf's own words instead.
4840 let Some(at) = self.block_offset_for_caret() else {
4841 self.status = Some(format!("{what}: no block at the caret"));
4842 return;
4843 };
4844 self.record_caret();
4845 let pairs = attr_pairs(&attrs);
4846 let was = self.block_content_at(at);
4847 let text = was.clone().map(|s| self.source[s].to_string());
4848 match self.editor.set_block_attrs(at, &pairs) {
4849 Ok(change) => {
4850 let (caret, anchor) = (self.caret, self.anchor);
4851 self.last_edit_kind = None; // structural edit is its own undo step
4852 self.refresh();
4853 let now = self.block_content_in(&change.new, text.as_deref());
4854 // A block the two halves cannot both name — a code block, a
4855 // caret in a list's marker — takes the plain arithmetic, which
4856 // is what it had before.
4857 let block = was.as_ref().zip(now.as_ref());
4858 self.caret = reanchor_in_block(caret, &change, block);
4859 self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4860 self.dirty = self.source != self.clean_source;
4861 self.status = None;
4862 // The clamp reads the caret floor off the map, and this edit
4863 // can move the floor: taking the `{…}` line off a djot
4864 // document's first block moves the first rendered offset to 0,
4865 // and a floor read from the old map stood the caret past the
4866 // block's text. So the map is this revision's before the clamp
4867 // — see `open_paragraph_at_block_edge`.
4868 self.rebuild_map();
4869 self.clamp_caret();
4870 self.record_caret();
4871 }
4872 Err(e) => self.status = Some(format!("{what}: {e}")),
4873 }
4874 }
4875
4876 /// The content span of the innermost paragraph or heading covering `off` —
4877 /// the author's own bytes, without the `# ` or the `<p>` that spells the
4878 /// block around them.
4879 ///
4880 /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4881 /// reads, so that what a gesture re-anchors by is the block it wrote to.
4882 fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4883 self.nodes()
4884 .into_iter()
4885 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4886 .filter(|n| n.span.start <= off && off <= n.span.end)
4887 .min_by_key(|n| n.span.end - n.span.start)
4888 .map(|n| n.content_span.unwrap_or(n.span))
4889 }
4890
4891 /// [`block_content_at`](Self::block_content_at)'s other half: the content
4892 /// span of the block `region` holds now, found by the bytes it held before.
4893 ///
4894 /// Matched on the text rather than taken as the first block in the region,
4895 /// because a rewritten region is markup and all — `<div class="center">`
4896 /// carries words of its own — and because the block this gesture moved is
4897 /// the one whose content the respelling did not touch. `None` where the
4898 /// region holds no block at all, which is djot's every case: the `{…}` line
4899 /// is spliced above the block and the block itself never moves through the
4900 /// change at all, only past it.
4901 fn block_content_in(
4902 &mut self,
4903 region: &Range<usize>,
4904 text: Option<&str>,
4905 ) -> Option<Range<usize>> {
4906 let text = text?;
4907 let spans: Vec<Range<usize>> = self
4908 .nodes()
4909 .into_iter()
4910 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4911 .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4912 .map(|n| n.content_span.unwrap_or(n.span))
4913 .collect();
4914 spans
4915 .into_iter()
4916 .find(|s| self.source.get(s.clone()) == Some(text))
4917 }
4918
4919 /// Hand `attrs` to twig as the attribute set of the span over `[start,
4920 /// end)` — wrapping one, or re-styling the one the range already lies in,
4921 /// or unwrapping it when `attrs` is empty.
4922 ///
4923 /// What the splice leaves selected is the span's **content** — the author's
4924 /// words — and not the whole of `change.new`, which is markup and all:
4925 /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4926 /// selection reaching past the node's own span lies in no span at all, so a
4927 /// second press of the menu would nest a fresh one instead of re-styling
4928 /// the one just written.
4929 fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4930 if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4931 return;
4932 }
4933 self.record_caret();
4934 let pairs = attr_pairs(&attrs);
4935 match self.editor.wrap_range_attrs(start, end, &pairs) {
4936 Ok(change) => {
4937 self.last_edit_kind = None;
4938 self.refresh();
4939 let content = self.span_content_in(&change.new);
4940 self.anchor = Some(content.start);
4941 self.caret = content.end;
4942 self.dirty = self.source != self.clean_source;
4943 self.status = None;
4944 self.clamp_caret();
4945 self.record_caret();
4946 }
4947 Err(e) => self.status = Some(format!("{what}: {e}")),
4948 }
4949 }
4950
4951 /// The content range of the attributed span `spliced` now holds — the
4952 /// outermost one inside it, since that is the one just written — or
4953 /// `spliced` itself where the splice left no span, which is what an unwrap
4954 /// leaves behind.
4955 fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4956 self.nodes()
4957 .into_iter()
4958 .filter(wysiwyg::is_run_span)
4959 .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4960 .max_by_key(|n| n.span.end - n.span.start)
4961 .and_then(|n| n.content_span)
4962 .unwrap_or_else(|| spliced.clone())
4963 }
4964
4965 /// The attribute set `set_block_attrs` is about to **replace** at the caret
4966 /// — which is the block's own, except in Markdown, where twig writes a
4967 /// block's attributes onto a `<div>` around it and rewrites that div when
4968 /// the block is its sole child. Reading the paragraph there would hand back
4969 /// an empty list and quietly drop everything the div said.
4970 ///
4971 /// Empty when the caret is in no block at all, which is the same list a
4972 /// block carrying no attributes gives — and the right one either way, since
4973 /// the gesture then refuses on its own.
4974 fn block_attrs_at_caret(&mut self) -> Attrs {
4975 let Some(off) = self.block_offset_for_caret() else {
4976 return Vec::new();
4977 };
4978 let nodes = self.nodes();
4979 let Some(block) = nodes
4980 .iter()
4981 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4982 .filter(|n| n.span.start <= off && off <= n.span.end)
4983 .min_by_key(|n| n.span.end - n.span.start)
4984 else {
4985 return Vec::new();
4986 };
4987 if self.format == Format::Markdown
4988 && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4989 && wysiwyg::element_tag(parent) == Some("div")
4990 && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4991 {
4992 return parent.attrs.clone();
4993 }
4994 block.attrs.clone()
4995 }
4996
4997 /// The attribute set `wrap_range_attrs` is about to **replace** over
4998 /// `[start, end)` — the innermost attributed span the range lies inside,
4999 /// which twig re-styles rather than nesting a second one in. Empty when the
5000 /// range lies in no span, where the gesture mints a fresh one.
5001 fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5002 self.nodes()
5003 .into_iter()
5004 .filter(wysiwyg::is_run_span)
5005 .filter(|n| n.span.start <= start && end <= n.span.end)
5006 .min_by_key(|n| n.span.end - n.span.start)
5007 .map(|n| n.attrs)
5008 .unwrap_or_default()
5009 }
5010
5011 /// The attribute lists that bear on a presentation query, **nearest first**:
5012 /// the attributed spans the caret stands in (innermost first), then its
5013 /// block, then the `div`s around it. A `find_map` down this is the whole of
5014 /// each query, and the order is the rule the walker draws by.
5015 ///
5016 /// Read at the caret, and at the selection's *start* when the caret stands
5017 /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5018 /// caret one past the span it just wrote — `toggle`'s convention — so
5019 /// asking the menu which entry that press just ticked must not answer
5020 /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5021 fn presentation_chain(&mut self) -> Vec<Attrs> {
5022 let caret = self.caret.min(self.source.len());
5023 let mut chain = self.attr_chain_at(caret);
5024 if !chain.iter().any(|(span, _)| *span)
5025 && let Some((start, _)) = self.selection()
5026 {
5027 let alt = self.attr_chain_at(start);
5028 if alt.iter().any(|(span, _)| *span) {
5029 chain = alt;
5030 }
5031 }
5032 chain.into_iter().map(|(_, attrs)| attrs).collect()
5033 }
5034
5035 /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5036 /// node bearing the vocabulary that covers it, innermost first, each paired
5037 /// with whether it is an attributed span (which is what tells the caller
5038 /// its run-level answer came from a run).
5039 ///
5040 /// Sorted by span length, which *is* the nesting order: a span lies inside
5041 /// its block and a block inside its div, so shortest-first is
5042 /// nearest-first without a second tree walk.
5043 fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5044 let off = off.min(self.source.len());
5045 let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5046 for n in self.nodes() {
5047 let span = wysiwyg::is_run_span(&n);
5048 let block = matches!(n.kind, Kind::Para | Kind::Heading);
5049 let div = wysiwyg::element_tag(&n) == Some("div");
5050 if !(span || block || div) {
5051 continue;
5052 }
5053 // A span is half-open, the way a mark is: the offset one past it is
5054 // the text after it. A block and a div claim their end too, so a
5055 // caret resting at the end of a line still reads its paragraph.
5056 let inside = if span {
5057 n.span.start <= off && off < n.span.end
5058 } else {
5059 n.span.start <= off && off <= n.span.end
5060 };
5061 if !inside {
5062 continue;
5063 }
5064 hits.push((n.span.end - n.span.start, span, n.attrs));
5065 }
5066 hits.sort_by_key(|(len, _, _)| *len);
5067 hits.into_iter()
5068 .map(|(_, span, attrs)| (span, attrs))
5069 .collect()
5070 }
5071
5072 /// Convert the block at the caret to a heading level or paragraph.
5073 pub fn set_block(&mut self, kind: BlockKind) {
5074 // The read-only gate — this door reaches twig without the splice.
5075 if self.read_only {
5076 return;
5077 }
5078 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5079 return;
5080 }
5081 self.record_caret();
5082 // A blank line has no node to convert, and twig opens a block there
5083 // rather than declining — so the caret's own offset is the right thing
5084 // to hand it when `block_offset_for_caret` finds nothing.
5085 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5086 match self.editor.set_block(offset, kind) {
5087 Ok(change) => {
5088 self.last_edit_kind = None;
5089 self.refresh();
5090 // Opening a block on a blank line writes a marker the caret
5091 // belongs *after*; converting an existing one moves nothing.
5092 self.caret = self.caret.max(change.new.end);
5093 self.clamp_caret();
5094 self.anchor = None;
5095 self.dirty = self.source != self.clean_source;
5096 self.status = None;
5097 self.record_caret();
5098 }
5099 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5100 }
5101 }
5102
5103 /// Whether `off` is inside a text block (paragraph, heading, code block…).
5104 fn has_block_at(&mut self, off: usize) -> bool {
5105 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5106 chain
5107 .iter()
5108 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5109 })
5110 }
5111
5112 /// The offset to hand twig's `set_block`: the caret when it is already inside
5113 /// a block, otherwise nudged onto the previous character (a caret at a line
5114 /// end sits at the doc level, outside the block). `None` when the caret is on
5115 /// a blank line — a new paragraph with no block node to convert.
5116 fn block_offset_for_caret(&mut self) -> Option<usize> {
5117 let caret = self.caret.min(self.source.len());
5118 if self.has_block_at(caret) {
5119 return Some(caret);
5120 }
5121 // Nudge to the previous character — but never across a newline: that would
5122 // target the previous block, and a blank line genuinely has no block.
5123 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5124 && ch != '\n'
5125 && self.has_block_at(i)
5126 {
5127 return Some(i);
5128 }
5129 None
5130 }
5131
5132 /// The heading level of the text block at the caret, or `None` when that
5133 /// block is not a heading.
5134 pub fn current_heading_level(&mut self) -> Option<u32> {
5135 let caret = self.caret;
5136 self.nodes()
5137 .into_iter()
5138 .filter(|n| n.kind == Kind::Heading)
5139 .find(|n| n.span.start <= caret && caret <= n.span.end)
5140 .and_then(|n| n.level)
5141 }
5142
5143 /// The inline marks in force at the caret (or over the selection) — what a
5144 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5145 /// inline counterpart. Cheap enough to call every frame: one twig
5146 /// `ancestors_at` query per caret (two with a selection), each walking root
5147 /// → deepest node at one offset. It never snapshots the tree the way
5148 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5149 /// the only allocation is twig's own small ancestor `Vec`.
5150 ///
5151 /// **A selection reports a mark only when the mark covers *all* of it.**
5152 /// That's what every real toolbar means by an active button — Bold lit over
5153 /// a half-bold selection would claim a press turns bold *off*, when
5154 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5155 /// Whole-coverage is asked as "is the same mark node standing over both the
5156 /// first and the last character?": inline nodes are contiguous, so one node
5157 /// covering both ends covers every byte between them. Two touching runs
5158 /// (`**a****b**`) are two nodes, and correctly light nothing.
5159 ///
5160 /// At a bare caret a mark is active when the caret stands inside the mark's
5161 /// span — `span.start <= caret < span.end`, delimiters included, which is
5162 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5163 /// opening `*` (2) through the last byte of the closing `**` (9) are all
5164 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5165 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5166 /// typing would actually land inside the marked run. The offset one past the
5167 /// mark (10) is the text after it and reports nothing, at the end of the
5168 /// buffer exactly as in the middle.
5169 pub fn active_inline_marks(&mut self) -> InlineMarks {
5170 let Some((start, end)) = self.selection() else {
5171 // The marks actually in force at the caret, flipped by any armed
5172 // sticky delta — so `⌘b` at a bare caret lights the Bold button
5173 // immediately, before a single character is typed.
5174 let base: InlineMarks = self
5175 .marks_at(self.caret)
5176 .into_iter()
5177 .map(|(k, _)| k)
5178 .collect();
5179 return base.xor(self.pending_here());
5180 };
5181 // The selection's *last character*, not its exclusive end: `end` is the
5182 // offset one past the selection, which for a selection ending exactly at
5183 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5184 // entirely bold, but offset 10 is the space after).
5185 let last = prev_boundary(&self.source, end);
5186 let head = self.marks_at(start);
5187 let tail = self.marks_at(last);
5188 head.into_iter()
5189 .filter(|m| tail.contains(m))
5190 .map(|(k, _)| k)
5191 .collect()
5192 }
5193
5194 /// The inline marks whose span covers `off`, each with the id of the node
5195 /// carrying it — the id is what lets a selection tell one mark node from
5196 /// another of the same kind.
5197 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5198 let off = off.min(self.source.len());
5199 self.editor
5200 .ancestors_at(off)
5201 .unwrap_or_default()
5202 .into_iter()
5203 // `span.end` is the offset one *past* the mark, so it isn't in it.
5204 // twig already resolves a boundary to whatever starts there — in
5205 // `**bold** x` offset 8 is the following text, not the strong — but
5206 // when nothing follows, the tie has nobody to break for and the
5207 // chain still ends at the mark. That would make the answer at the
5208 // last offset of the document depend on whether the file happens to
5209 // end in a newline; the rule is `span.start <= off < span.end`, and
5210 // it's the same rule at the end of a buffer as in the middle.
5211 .filter(|m| off < m.span.end)
5212 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5213 .collect()
5214 }
5215
5216 /// Toggle a heading at the caret: if the block is already this heading level,
5217 /// revert it to a paragraph; otherwise convert it to this heading level.
5218 /// This gives the heading commands the same toggle feel as bold/italic/code —
5219 /// re-applying a heading a line already has turns it back into body text.
5220 pub fn toggle_heading(&mut self, level: u32) {
5221 if self.current_heading_level() == Some(level) {
5222 self.set_block(BlockKind::Paragraph);
5223 } else {
5224 self.set_block(BlockKind::Heading(level));
5225 }
5226 }
5227
5228 /// Toggle a block quote around the selection, or around the block at the
5229 /// caret — the toolbar's Quote button.
5230 pub fn toggle_blockquote(&mut self) {
5231 self.toggle_container(BlockContainerKind::BlockQuote);
5232 }
5233
5234 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5235 /// over the block at the caret — one op with the kind as a flag, the way
5236 /// `toggle_heading` takes its level, so a frontend needs no twig type to
5237 /// name the two buttons.
5238 ///
5239 /// Pressing the *other* list's button while in a list converts in place
5240 /// rather than nesting, so the pair reads as one three-state control
5241 /// (bulleted / numbered / neither) rather than two independent wrappers.
5242 pub fn toggle_list(&mut self, ordered: bool) {
5243 self.toggle_container(if ordered {
5244 BlockContainerKind::OrderedList
5245 } else {
5246 BlockContainerKind::BulletList
5247 });
5248 }
5249
5250 // ── Task list items ──────────────────────────────────────────────────────
5251 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5252 // inline content of the item's first paragraph rather than part of its
5253 // marker, so adding or removing one must leave the item's continuation
5254 // indentation alone, and an item inside a quote is found past the quote
5255 // markers. leaf names the gesture and the offset; the spelling is twig's.
5256
5257 /// Whether the list item at the caret carries a checkbox, and which way it
5258 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5259 /// item or no item at all. What a toolbar reads to light its checkbox button.
5260 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5261 self.task_checked_at(self.caret)
5262 }
5263
5264 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5265 /// offset — what a frontend asks before deciding a click landed on a box.
5266 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5267 self.innermost_list_item(offset.min(self.source.len()))?
5268 .checked
5269 }
5270
5271 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5272 /// A no-op with a reported reason when the caret is in no task item — minting
5273 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5274 pub fn toggle_task_checked(&mut self) {
5275 self.toggle_task_at(self.caret);
5276 }
5277
5278 /// Tick or untick the task item covering `offset` — what a *click* on a
5279 /// rendered checkbox is. Separate from the caret form because a click carries
5280 /// its own offset and must not first move the caret there: ticking a box
5281 /// three paragraphs away should not take the cursor with it.
5282 pub fn toggle_task_at(&mut self, offset: usize) {
5283 // The read-only gate — this door reaches twig without the splice.
5284 if self.read_only {
5285 return;
5286 }
5287 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5288 return;
5289 }
5290 let offset = offset.min(self.source.len());
5291 self.record_caret();
5292 match self.editor.toggle_task_checked(offset) {
5293 Ok(_) => self.after_task_edit(),
5294 Err(e) => self.status = Some(format!("task: {e}")),
5295 }
5296 }
5297
5298 /// Give the list item at the caret a checkbox, or take its checkbox away —
5299 /// the gesture that converts between a plain bullet and a task. A new box
5300 /// arrives unticked.
5301 pub fn toggle_task_item(&mut self) {
5302 // The read-only gate — this door reaches twig without the splice.
5303 if self.read_only {
5304 return;
5305 }
5306 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5307 return;
5308 }
5309 let caret = self.caret.min(self.source.len());
5310 self.record_caret();
5311 match self.editor.toggle_task_item(caret) {
5312 Ok(_) => self.after_task_edit(),
5313 Err(e) => self.status = Some(format!("task: {e}")),
5314 }
5315 }
5316
5317 /// Settle after a task gesture. The caret rides its old byte offset and is
5318 /// clamped back in: a box is three or four bytes on the item's first line, so
5319 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5320 /// real stop either way.
5321 fn after_task_edit(&mut self) {
5322 self.last_edit_kind = None;
5323 self.refresh();
5324 self.anchor = None;
5325 self.dirty = self.source != self.clean_source;
5326 self.status = None;
5327 self.clamp_caret();
5328 self.record_caret();
5329 }
5330
5331 // ── Tables ───────────────────────────────────────────────────────────────
5332 // A table is a grid, and twig edits it as one — add/remove/move a row or
5333 // column, set a column's alignment — re-spelling the whole table in a single
5334 // splice. Every gesture is anchored at the caret's cell. leaf just names the
5335 // gesture and re-reads the result; the whole table's numbering, borders, and
5336 // delimiter are twig's to keep straight.
5337
5338 /// Whether the caret is inside a table — what a frontend asks to enable or
5339 /// disable its table controls.
5340 ///
5341 /// An HTML `<table>` still answers `true`: the caret really is in a table,
5342 /// and the reason the grid controls stay dark there is
5343 /// [`Capabilities::table`], which is a fact about the document's format
5344 /// rather than about the caret. A frontend needs both.
5345 pub fn caret_in_table(&mut self) -> bool {
5346 let caret = self.caret.min(self.source.len());
5347 self.editor
5348 .ancestors_at(caret)
5349 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5350 .unwrap_or(false)
5351 }
5352
5353 /// One grid op, guarded and settled — the shared body of the seven below.
5354 ///
5355 /// The guard is why this exists rather than seven copies of the same three
5356 /// lines, and it is the one guard leaf cannot delegate to twig. The table
5357 /// editor is the gesture family that consults no `Syntax` table (it spells a
5358 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5359 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5360 /// grid as a *pipe table* and reports success, swapping the element out for
5361 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5362 /// downstream could tell that from a successful edit — the splice is real,
5363 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5364 /// stopping at the door rather than detecting after the fact. See
5365 /// [`spells_pipe_tables`].
5366 fn table_op(
5367 &mut self,
5368 what: &str,
5369 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5370 ) {
5371 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5372 return;
5373 }
5374 self.record_caret();
5375 let at = self.caret;
5376 let r = op(&mut self.editor, at);
5377 self.apply_table(r, what);
5378 }
5379
5380 /// Insert an empty row below (`below`) or above the caret's row.
5381 pub fn table_insert_row(&mut self, below: bool) {
5382 self.table_op("table row", |e, at| e.table_insert_row(at, below));
5383 }
5384
5385 /// Delete the caret's row (not the header, not the last body row).
5386 pub fn table_delete_row(&mut self) {
5387 self.table_op("table row", |e, at| e.table_delete_row(at));
5388 }
5389
5390 /// Insert an empty column right (`right`) or left of the caret's column.
5391 pub fn table_insert_column(&mut self, right: bool) {
5392 self.table_op("table column", |e, at| e.table_insert_column(at, right));
5393 }
5394
5395 /// Delete the caret's column (unless it is the only one).
5396 pub fn table_delete_column(&mut self) {
5397 self.table_op("table column", |e, at| e.table_delete_column(at));
5398 }
5399
5400 /// Set the caret's column to `alignment`.
5401 pub fn table_set_alignment(&mut self, alignment: Alignment) {
5402 self.table_op("table alignment", |e, at| {
5403 e.table_set_alignment(at, alignment)
5404 });
5405 }
5406
5407 /// Move the caret's row one place down (`down`) or up, within the body rows.
5408 pub fn table_move_row(&mut self, down: bool) {
5409 self.table_op("table row", |e, at| e.table_move_row(at, down));
5410 }
5411
5412 /// Move the caret's column one place right (`right`) or left.
5413 pub fn table_move_column(&mut self, right: bool) {
5414 self.table_op("table column", |e, at| e.table_move_column(at, right));
5415 }
5416
5417 /// Settle the caret and document flags after a table op (or report its
5418 /// error). twig re-spells the whole table, so the caret rides its old byte
5419 /// offset and is clamped back into the rebuilt bytes — near enough to where
5420 /// it was, since the op preserves the cells' content and order around it.
5421 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5422 match result {
5423 Ok(()) => {
5424 self.last_edit_kind = None;
5425 self.refresh();
5426 self.anchor = None;
5427 self.clamp_caret();
5428 self.dirty = self.source != self.clean_source;
5429 self.status = None;
5430 self.record_caret();
5431 }
5432 Err(e) => self.status = Some(format!("{what}: {e}")),
5433 }
5434 }
5435
5436 /// One `toggle_block_container` over the block-level target.
5437 ///
5438 /// leaf says *where*; twig decides everything else — which blocks the range
5439 /// covers, whether that means wrapping, unwrapping, nesting or converting,
5440 /// and how this document's format spells the prefix. The rule that a
5441 /// container only comes off when the range covers every block it holds is
5442 /// what the re-anchoring below is built around.
5443 fn toggle_container(&mut self, kind: BlockContainerKind) {
5444 // The read-only gate — this door reaches twig without the splice.
5445 if self.read_only {
5446 return;
5447 }
5448 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5449 return;
5450 }
5451 let selected = self.selection();
5452 // A blank line holds no block, and twig opens an *empty* container on one
5453 // — since 3.2.0; it used to decline the range with `NotFound`, which is
5454 // why this used to lend it a scratch paragraph to wrap. Worth knowing
5455 // here because the line-for-line caret mapping below cannot describe it:
5456 // opening one under a paragraph writes the blank line the format needs
5457 // above the marker too, so the rewritten region has a line the old one
5458 // didn't, and "the same line, the same distance from its end" lands on
5459 // that new blank instead of in the container.
5460 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5461 // Without a selection the target is the caret's own block, resolved the
5462 // way `set_block` resolves it — a caret at a line end sits at the doc
5463 // level and has to be nudged back onto the block it looks like it's in.
5464 // An empty range is enough: twig widens to the whole lines it touches.
5465 let (start, end) = match selected {
5466 Some(range) => range,
5467 None => {
5468 let off = self.block_offset_for_caret().unwrap_or(self.caret);
5469 (off, off)
5470 }
5471 };
5472 self.record_caret();
5473 match self.editor.toggle_block_container(start, end, kind) {
5474 Ok(change) => {
5475 // Read the caret's place out of the *pre-edit* source, before
5476 // `refresh` swaps that source out from under it.
5477 let place = (selected.is_none() && !opened_empty)
5478 .then(|| self.caret_line_tail(&change.old));
5479 self.last_edit_kind = None; // structural edit is its own undo step
5480 self.refresh();
5481 match place {
5482 // Both land the caret at the far end of what twig wrote, and
5483 // differ only in what they leave selected.
5484 //
5485 // From a selection: select what the container now holds, the
5486 // way `toggle` keeps its marked region selected — and for a
5487 // stronger reason than symmetry: a container comes *off* only
5488 // a range covering every block it holds, so a selection left
5489 // on its old bytes (now short by a prefix per line) would nest
5490 // on the second press instead of reversing the first.
5491 //
5492 // From a blank line: nothing to select, and the end of the
5493 // region is exactly past the bare `> ` / `- ` twig wrote —
5494 // the caret standing inside the container that was asked for.
5495 None => {
5496 self.anchor = (!opened_empty).then_some(change.new.start);
5497 self.caret = change.new.end;
5498 }
5499 Some(place) => {
5500 self.anchor = None;
5501 self.caret = self.line_tail_offset(&change.new, place);
5502 }
5503 }
5504 self.dirty = self.source != self.clean_source;
5505 self.status = None;
5506 self.clamp_caret();
5507 self.record_caret();
5508 }
5509 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5510 }
5511 }
5512
5513 /// The caret's place inside the region a container toggle is rewriting, in
5514 /// the only terms the rewrite preserves: which of the region's lines it sits
5515 /// on, and how many bytes of that line lie ahead of it.
5516 ///
5517 /// A container's markup goes in at column 0 and never touches what follows
5518 /// on the line, so that pair survives the edit exactly where a byte offset
5519 /// does not — a caret left on its old offset slides back by one prefix per
5520 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
5521 /// `> ` it just asked for.
5522 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
5523 let caret = self.caret.clamp(old.start, old.end);
5524 let line = self.source[old.start..caret].matches('\n').count();
5525 let end = self.source[caret..old.end]
5526 .find('\n')
5527 .map_or(old.end, |i| caret + i);
5528 (line, end - caret)
5529 }
5530
5531 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
5532 /// region: the offset `tail` bytes back from the end of the region's `line`.
5533 ///
5534 /// Both walks are clamped rather than trusted, because the one op that does
5535 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
5536 /// the items back apart with blank lines between them — and a caret landing
5537 /// on the nearest line of the right item beats one landing out of the region
5538 /// entirely.
5539 fn line_tail_offset(
5540 &self,
5541 new: &std::ops::Range<usize>,
5542 (line, tail): (usize, usize),
5543 ) -> usize {
5544 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
5545 let mut start = 0;
5546 for _ in 0..line {
5547 match region[start..].find('\n') {
5548 Some(i) => start += i + 1,
5549 None => break,
5550 }
5551 }
5552 let end = region[start..]
5553 .find('\n')
5554 .map_or(region.len(), |i| start + i);
5555 new.start + end.saturating_sub(tail).max(start)
5556 }
5557
5558 /// Link the selection to `destination` — the toolbar's Link button. With no
5559 /// selection it acts at the caret, which re-points a link the caret is
5560 /// already standing in (twig replaces an existing link's destination and
5561 /// keeps its text) and otherwise spells a link that has no text of its own:
5562 /// an autolink (`<https://x.dev>`) where the destination is one, and
5563 /// `[destination](destination)` where it isn't.
5564 ///
5565 /// `destination` reaches twig raw. Escaping it is format knowledge and the
5566 /// two formats genuinely disagree — Markdown ends a destination at the first
5567 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
5568 /// itself — so the side holding the document is the side that gets to spell
5569 /// it. A destination twig can't carry at all (one with a newline) comes back
5570 /// as an error rather than a quietly rewritten URL.
5571 pub fn insert_link(&mut self, destination: &str) {
5572 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
5573 return;
5574 }
5575 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5576 self.record_caret();
5577 match self.editor.insert_link(start, end, destination) {
5578 Ok(change) => {
5579 self.last_edit_kind = None;
5580 self.refresh();
5581 match self.link_text_span(change.new.start) {
5582 // A link with text of its own: select it, so typing replaces
5583 // a `[dest](dest)`'s stand-in label and a second press
5584 // re-points what the first one linked.
5585 Some(text) => {
5586 self.anchor = (text.start != text.end).then_some(text.start);
5587 self.caret = text.end;
5588 }
5589 // An autolink is finished the moment it's written — its text
5590 // *is* the URL. Leaving it selected would aim the next press
5591 // at the one shape twig still wraps instead of re-points.
5592 None => {
5593 self.anchor = None;
5594 self.caret = change.new.end;
5595 }
5596 }
5597 self.dirty = self.source != self.clean_source;
5598 self.status = None;
5599 self.clamp_caret();
5600 self.record_caret();
5601 }
5602 Err(e) => self.status = Some(format!("link: {e}")),
5603 }
5604 }
5605
5606 /// Insert a block-level image at the caret: ``. Any
5607 /// selection becomes the alt text (so "select a caption, insert image" labels
5608 /// it); with no selection, `alt` is used — empty for none. The caret lands
5609 /// just past the inserted image.
5610 ///
5611 /// Both halves go through twig (`insert_literal` for the alt text,
5612 /// `insert_image` for the image), so neither is spelled here. That used to be a
5613 /// `format!`, and it was wrong the first time an app inserted a real filename:
5614 /// Markdown ends a destination at the first space, so `` is
5615 /// not an image at all — and the fix is per-format, since moving into the
5616 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
5617 pub fn insert_image(&mut self, destination: &str, alt: &str) {
5618 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5619 return;
5620 }
5621 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5622 self.record_caret();
5623 // With no selection and an explicit `alt`, the alt text has to exist in the
5624 // document before it can be the image's — and it is raw caller input, so
5625 // it goes in through `insert_literal`, which escapes it for the format
5626 // rather than letting a `]` in someone's caption close the image early.
5627 let (start, end) = if start == end && !alt.is_empty() {
5628 match self.editor.insert_literal(start, alt) {
5629 Ok(change) => (change.new.start, change.new.end),
5630 Err(e) => {
5631 self.status = Some(format!("image: {e}"));
5632 return;
5633 }
5634 }
5635 } else {
5636 (start, end)
5637 };
5638 match self.editor.insert_image(start, end, destination) {
5639 Ok(change) => {
5640 self.last_edit_kind = None;
5641 self.refresh();
5642 // Just past the image, nothing selected — where a caret belongs
5643 // after inserting one.
5644 self.anchor = None;
5645 self.caret = change.new.end;
5646 self.dirty = self.source != self.clean_source;
5647 self.status = None;
5648 self.clamp_caret();
5649 self.record_caret();
5650 }
5651 Err(e) => self.status = Some(format!("image: {e}")),
5652 }
5653 }
5654
5655 /// Insert a block-level image, video, or audio at the caret. The image case
5656 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5657 /// HTML elements, which is the only spelling Markdown and Djot have for them:
5658 ///
5659 /// ```text
5660 /// <video src="clip.mp4" controls>alt</video>
5661 /// <audio src="take.mp3" controls>alt</audio>
5662 /// ```
5663 ///
5664 /// HTML rather than a `::video{…}` directive deliberately. A directive means
5665 /// something only to an app that knows the vocabulary, so the document would
5666 /// read as literal punctuation everywhere else; `<video>` is what every other
5667 /// renderer already understands, and what leaf's own reader picks back up
5668 /// through `html_elements` promotion (see [`parse_extensions`]).
5669 ///
5670 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5671 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5672 /// `html_elements`. Before that only the multi-line form parsed as a block at
5673 /// all, and this wrote three lines to work around it.
5674 ///
5675 /// `controls` is always written: a player with no transport is a still frame
5676 /// the reader can't do anything with. Any selection becomes the element's
5677 /// fallback text, exactly as it becomes an image's alt.
5678 ///
5679 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5680 /// applies, and bites harder here: a `"` in `destination` closes the
5681 /// attribute. A frontend taking these from a file picker is fine; one taking
5682 /// them from free text should keep them tame.
5683 ///
5684 /// [`MediaInfo`]: crate::MediaInfo
5685 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5686 if kind == MediaKind::Image {
5687 return self.insert_image(destination, alt);
5688 }
5689 // Gated on the *image* gesture, not on one of its own — there isn't one,
5690 // since the bytes below are spelled here rather than by twig, and an HTML
5691 // document would in fact parse them. The button is one control with three
5692 // kinds behind it, and two of them working in a format where the third
5693 // cannot is a worse surface than three that agree — especially as
5694 // `insert_image` is the kind anyone reaches for first.
5695 if self.refuse_unsupported("media", Gesture::InsertImage) {
5696 return;
5697 }
5698 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5699 let alt_text = self
5700 .selected_text()
5701 .map(str::to_string)
5702 .unwrap_or_else(|| alt.to_string());
5703 let tag = match kind {
5704 MediaKind::Audio => "audio",
5705 _ => "video",
5706 };
5707 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5708 self.edit(start, end, &markup);
5709 }
5710
5711 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5712 /// button. Spelling and placement are both twig's; leaf used to write `---`
5713 /// itself, which was the Markdown spelling in a djot document too.
5714 ///
5715 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5716 /// mid-paragraph and lands it after the caret's whole block. To get a rule
5717 /// *at* the caret — the paragraph parted in two around it, which is what a
5718 /// rule button is understood to do — the paragraph is first divided with
5719 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5720 /// the offset `split_block` returns puts the rule after the *second* half
5721 /// instead, which is a rule in the right document and the wrong place.
5722 ///
5723 /// Only a plain paragraph is split, and only where there is something to
5724 /// part: at the paragraph's end the split has no second half to mint and
5725 /// would write the separator anyway — a blank line and the empty slot Enter
5726 /// leaves for the next paragraph, which the rule then lands above and
5727 /// nothing fills — so there the rule goes straight after the paragraph,
5728 /// which is where the split-and-aim was sending it regardless. At the
5729 /// paragraph's *start* the split is kept, though it parts nothing either:
5730 /// `|para` becomes `\npara` with the caret on the new blank line, and a
5731 /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5732 /// "before the paragraph" is said through a gesture that only knows
5733 /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5734 /// else the rule simply lands after the block, which is both twig's own
5735 /// answer and the better one: splitting a fenced code block would leave two
5736 /// fences with a rule between them, and splitting a list item would mint an
5737 /// item nobody asked for on the way to a rule that lands after the list
5738 /// regardless. A table and a setext heading refuse the split outright, so
5739 /// they take the same path by themselves.
5740 pub fn insert_thematic_break(&mut self) {
5741 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5742 {
5743 return;
5744 }
5745 self.caret = self.skip_trailing_close_delims(self.caret);
5746 // A selection is replaced by the rule, so collapse it first and let the
5747 // split-and-rule below run from the caret it leaves behind.
5748 if let Some((s, e)) = self.selection() {
5749 self.splice(s, e, "", EditKind::Other);
5750 }
5751 self.anchor = None;
5752 self.record_caret();
5753 let at = self.caret;
5754 if self.caret_parts_bare_paragraph() {
5755 // A failure here is not fatal: the rule still lands after the block,
5756 // which is exactly what this call was trying to improve on.
5757 let _ = self.editor.split_block(at);
5758 }
5759 match self.editor.insert_thematic_break(at) {
5760 Ok(change) => {
5761 self.last_edit_kind = None;
5762 self.refresh();
5763 self.anchor = None;
5764 self.caret = change.new.end;
5765 self.dirty = self.source != self.clean_source;
5766 self.status = None;
5767 self.clamp_caret();
5768 self.record_caret();
5769 }
5770 Err(e) => self.status = Some(format!("thematic break: {e}")),
5771 }
5772 }
5773
5774 /// Insert a fresh table at the caret — the toolbar's Table button. One
5775 /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5776 /// the document's own dialect and placed the way its thematic break is:
5777 /// after the caret's block, blank-separated. A bare paragraph is parted
5778 /// around the caret first, exactly as
5779 /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5780 /// table lands *at* the caret rather than after everything the caret's
5781 /// paragraph says.
5782 ///
5783 /// The caret ends in the first header cell, selected the way Tab selects
5784 /// a cell — the natural next act is to type the heading, and Tab then
5785 /// walks the grid. That cell is read back from the rebuilt table map
5786 /// rather than computed from the splice, because twig's blank line and
5787 /// quote prefix put the first bar at an offset only the reparse knows.
5788 ///
5789 /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5790 /// rows or columns is twig's refusal (a header with nothing under it is
5791 /// what its row delete refuses to leave), reported through `status`.
5792 pub fn insert_table(&mut self, rows: usize, cols: usize) {
5793 if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5794 return;
5795 }
5796 self.caret = self.skip_trailing_close_delims(self.caret);
5797 if let Some((s, e)) = self.selection() {
5798 self.splice(s, e, "", EditKind::Other);
5799 }
5800 self.anchor = None;
5801 self.record_caret();
5802 let at = self.caret;
5803 if self.caret_parts_bare_paragraph() {
5804 let _ = self.editor.split_block(at);
5805 }
5806 match self.editor.insert_table(at, rows, cols) {
5807 Ok(change) => {
5808 self.last_edit_kind = None;
5809 self.refresh();
5810 self.anchor = None;
5811 self.caret = change.new.end;
5812 self.dirty = self.source != self.clean_source;
5813 self.status = None;
5814 self.clamp_caret();
5815 // Into the first header cell of the table just written: the
5816 // first table whose grid begins inside the splice.
5817 self.rebuild_map();
5818 let first_cell = self
5819 .vmap
5820 .tables
5821 .iter()
5822 .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5823 .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5824 .map(|cell| (cell.start, cell.end));
5825 if let Some((start, end)) = first_cell {
5826 self.select_cell(start, end);
5827 }
5828 self.record_caret();
5829 }
5830 Err(e) => self.status = Some(format!("table: {e}")),
5831 }
5832 }
5833
5834 /// Whether the caret sits in a paragraph and nothing else — no list item, no
5835 /// quote, no fence, no table — with paragraph text still ahead of it. The
5836 /// one shape where parting the block around the caret is unambiguously what
5837 /// a rule button means; see
5838 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5839 /// container is left to take the rule after itself.
5840 ///
5841 /// The "text ahead" half is what keeps `split_block` from running at the
5842 /// one edge where its output composes badly. At a paragraph's end twig
5843 /// cannot mint the empty second half (no format spells an empty
5844 /// paragraph), so it writes only the separator — a blank line and the
5845 /// slot Enter leaves for the paragraph to come — and a block then aimed at
5846 /// the first half lands above a slot that nothing fills: `para\n` with the
5847 /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
5848 /// as nothing ahead, since the split would shed it as the second half's
5849 /// leading indent and leave the same slot. Which end of the newline a
5850 /// paragraph's span stops at differs between the formats (Markdown before
5851 /// it, djot after), which is why this reads the remaining bytes rather
5852 /// than comparing offsets. The paragraph's
5853 /// start is deliberately not the same case — see
5854 /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
5855 /// split is kept.
5856 fn caret_parts_bare_paragraph(&mut self) -> bool {
5857 let caret = self.caret.min(self.source.len());
5858 let Ok(chain) = self.editor.ancestors_at(caret) else {
5859 return false;
5860 };
5861 let mut para_end = None;
5862 for m in chain {
5863 match m.kind {
5864 Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
5865 Kind::ListItem
5866 | Kind::TaskListItem
5867 | Kind::BlockQuote
5868 | Kind::CodeBlock
5869 | Kind::Table => return false,
5870 _ => {}
5871 }
5872 }
5873 match para_end {
5874 Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
5875 _ => false,
5876 }
5877 }
5878
5879 /// The destination of the link under the caret — what a Link prompt shows so
5880 /// ⌘K on an existing link edits its URL instead of asking for it again.
5881 /// `None` when the caret stands in no link.
5882 ///
5883 /// An autolink carries no separate destination: its text *is* the URL, so
5884 /// that's what comes back for one.
5885 pub fn link_destination_at_caret(&mut self) -> Option<String> {
5886 self.link_destination_at(self.caret)
5887 }
5888
5889 /// The destination of the link at `off`.
5890 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
5891 /// the caret isn't.
5892 ///
5893 /// The offset form exists for the same reason
5894 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
5895 /// the document somewhere else — a footnote's text in a popover, say — has
5896 /// rows and runs but no caret in them, and still needs to know which of those
5897 /// runs a reader can follow.
5898 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
5899 self.nodes()
5900 .into_iter()
5901 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
5902 .filter(|n| n.span.start <= off && off < n.span.end)
5903 .max_by_key(|n| n.span.start)
5904 .and_then(|n| n.destination.or(n.text))
5905 }
5906
5907 /// Where the locator `id` lands in this document — the `#v2` half of a
5908 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
5909 /// answers to it.
5910 ///
5911 /// The other end of a link, and the reason this exists: without it a
5912 /// destination has only file granularity, so following a citation into a
5913 /// chapter drops the reader at the top of it to hunt for the verse. Which is
5914 /// also why it is a *document* query rather than a caret one — the document
5915 /// being asked is usually not the one the reader is in.
5916 ///
5917 /// Three readings, tried in order, because the same `#some-heading` is
5918 /// written three ways across the formats leaf opens:
5919 ///
5920 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
5921 /// the auto-ids djot mints for its headings. The only exact answer, so it
5922 /// goes first — a document that says `{#v1}` has settled the question.
5923 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
5924 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
5925 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
5926 /// authored anywhere land on a djot heading.
5927 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
5928 /// none and `{#custom}` is literal text in a Markdown heading — so for
5929 /// the format most vaults are written in, the heading's own words are the
5930 /// only thing a fragment can name. This is the rule every Markdown
5931 /// renderer already follows, which is what makes `#a-heading` mean in
5932 /// diaryx what it means on the web.
5933 ///
5934 /// Ties go to the earliest match, then to the widest: a duplicated id is the
5935 /// document's mistake and the first one is the answer every anchor
5936 /// implementation gives, while preferring the wider span picks the section
5937 /// over the heading that opens it — more for a peek to show, same place to
5938 /// land.
5939 pub fn locate(&mut self, id: &str) -> Option<Landing> {
5940 let id = id.trim();
5941 if id.is_empty() {
5942 return None;
5943 }
5944 let nodes = self.nodes();
5945
5946 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
5947 // is picking, among nodes that start together, the one that ends last.
5948 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
5949 matches
5950 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
5951 .map(|n| Landing {
5952 start: n.span.start,
5953 end: n.span.end,
5954 })
5955 };
5956
5957 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
5958 return Some(landing);
5959 }
5960 let want = slug(id);
5961 if want.is_empty() {
5962 return None;
5963 }
5964 if let Some(landing) = pick(
5965 &mut nodes
5966 .iter()
5967 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
5968 ) {
5969 return Some(landing);
5970 }
5971
5972 // A heading by its words. Its span is one line, so the end comes from
5973 // where the *section* it opens gives out — the next heading that is not
5974 // under it, or the end of the document. A Markdown heading has no
5975 // section node to ask (twig only builds those for djot), and a peek that
5976 // showed the heading alone would answer "what does that say" with the
5977 // title of the thing it says.
5978 let heading = nodes
5979 .iter()
5980 .filter(|n| n.kind == Kind::Heading)
5981 .filter(|n| {
5982 n.content_span
5983 .clone()
5984 .and_then(|s| self.source.get(s))
5985 .is_some_and(|text| slug(text) == want)
5986 })
5987 .min_by_key(|n| n.span.start)?;
5988 let level = heading.level.unwrap_or(u32::MAX);
5989 let end = nodes
5990 .iter()
5991 .filter(|n| n.kind == Kind::Heading)
5992 .filter(|n| n.span.start > heading.span.start)
5993 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
5994 .map(|n| n.span.start)
5995 .min()
5996 .unwrap_or(self.source.len());
5997 Some(Landing {
5998 start: heading.span.start,
5999 end,
6000 })
6001 }
6002
6003 /// Write a footnote at the caret — the toolbar's Footnote button, and the
6004 /// one gesture in the footnote story that *authors* rather than follows.
6005 ///
6006 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6007 /// the `[^1]:` definition at the end of the document. Half a footnote is not
6008 /// a footnote — a bare reference with nothing defining it renders as literal
6009 /// brackets — so a single button that wrote only the reference would leave
6010 /// the author to hand-spell the other half in a document that had just
6011 /// stopped showing them what the first half meant. One edit also means one
6012 /// undo takes both back.
6013 ///
6014 /// The definition's body is left empty and **the caret lands in it**, which
6015 /// is the whole point of pressing the button: nobody wants a reference to a
6016 /// note they have not written yet. Getting back to where they were writing
6017 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6018 /// the same return leg a reader following a reference already uses, so the
6019 /// author is left standing on the near end of a round trip that works.
6020 ///
6021 /// A selection collapses to its *end* rather than being replaced: a
6022 /// reference annotates the words before it, so "select the claim, add a
6023 /// footnote" should mark that claim, not consume it.
6024 pub fn insert_footnote(&mut self) {
6025 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6026 return;
6027 }
6028 let at = self.selection().map_or(self.caret, |(_, end)| end);
6029 self.anchor = None;
6030 self.caret = at;
6031 self.record_caret();
6032 let label = self.next_footnote_label();
6033 match self.editor.insert_footnote(at, &label) {
6034 Ok(change) => {
6035 self.last_edit_kind = None;
6036 self.refresh();
6037 self.anchor = None;
6038 // `change.new` runs from the reference to the end of the
6039 // document, so its start is the `[^1]` just written and
6040 // `footnote_at` resolves it to the note the same way a reader's
6041 // tap does — and to the note's *body*, which is already a caret
6042 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6043 // and has none), so this needs no snap on top. The fallback is
6044 // the reference's own offset: a format that spelled the pair some
6045 // way leaf can't read back should still leave the caret on the
6046 // edit rather than at the far end of a document it just grew.
6047 self.caret = self
6048 .footnote_at(change.new.start)
6049 .and_then(|note| note.offset)
6050 .unwrap_or(change.new.start);
6051 self.dirty = self.source != self.clean_source;
6052 self.status = None;
6053 self.clamp_caret();
6054 self.record_caret();
6055 }
6056 Err(e) => self.status = Some(format!("footnote: {e}")),
6057 }
6058 }
6059
6060 /// The label to give a footnote the author has not named: the lowest counting
6061 /// number no footnote in the document is already wearing.
6062 ///
6063 /// twig takes the label rather than minting one, because it holds no opinion
6064 /// about what a document's footnotes should be called — and it is right not
6065 /// to. Numbering them is what every author of a numbered note expects, and
6066 /// re-using a taken number would silently point the new reference at somebody
6067 /// else's note (twig reuses an existing definition rather than appending a
6068 /// second one, which is the right rule for citing a note twice on purpose and
6069 /// exactly the wrong accident to have by default).
6070 ///
6071 /// *References* are counted alongside definitions, not just definitions: a
6072 /// document carrying a dangling `[^2]` has a 2 that means something to
6073 /// whoever wrote it, and minting a definition for it here would answer a
6074 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6075 /// count entirely — they take no number, so they block none.
6076 fn next_footnote_label(&mut self) -> String {
6077 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6078 .into_iter()
6079 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6080 .filter_map(|label| label.parse().ok())
6081 .collect();
6082 taken.extend(
6083 self.nodes()
6084 .into_iter()
6085 .filter(|n| n.kind == Kind::FootnoteReference)
6086 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6087 .filter_map(|label| label.parse::<u32>().ok()),
6088 );
6089 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6090 }
6091
6092 /// The footnote reference under the caret, resolved to the note it names.
6093 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6094 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6095 self.footnote_at(self.caret)
6096 }
6097
6098 /// The footnote reference at `off`, resolved to the note it names — what a
6099 /// frontend shows when a reader activates a `[^1]`.
6100 ///
6101 /// A reference is not a link node, so
6102 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6103 /// (and should not) answer for one: a link names a destination to leave for,
6104 /// a reference names a note that is already in this document. Following one
6105 /// is a move within the page, which is why this hands back an `offset`
6106 /// rather than something to open.
6107 ///
6108 /// Offset-based rather than caret-only because the gesture that wants this
6109 /// most is the one that must not move the caret: a pointer hovering a `[1]`
6110 /// asks what note it names without disturbing where the reader was typing.
6111 /// The caret is just the offset a click already placed —
6112 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6113 ///
6114 /// `None` when `off` stands in no reference. A reference whose note the
6115 /// document never defines is *not* `None` — it answers with the label it
6116 /// looked for and no text, which is what lets a frontend say so instead of
6117 /// silently doing nothing.
6118 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6119 // Innermost-wins by latest start, the rule its link sibling uses.
6120 let span = self
6121 .nodes()
6122 .into_iter()
6123 .filter(|n| n.kind == Kind::FootnoteReference)
6124 .filter(|n| n.span.start <= off && off < n.span.end)
6125 .max_by_key(|n| n.span.start)?
6126 .span;
6127 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6128
6129 // The note itself. Definitions are roots beside `doc` rather than
6130 // children of it, so they're asked for directly — see
6131 // `wysiwyg::footnote_definitions`.
6132 let note = wysiwyg::footnote_definitions(&mut self.editor)
6133 .into_iter()
6134 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
6135 let Some(note) = note else {
6136 return Some(FootnoteRef {
6137 label,
6138 text: None,
6139 offset: None,
6140 end: None,
6141 });
6142 };
6143 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
6144 Some(FootnoteRef {
6145 label,
6146 text: body
6147 .clone()
6148 .and_then(|b| self.source.get(b))
6149 .map(str::to_string),
6150 // The body's start, not the definition's — see `FootnoteRef::offset`.
6151 offset: body.clone().map(|b| b.start),
6152 end: body.map(|b| b.end),
6153 })
6154 }
6155
6156 /// The footnote *definition* the caret stands in, and where the reference
6157 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6158 /// at the caret's offset.
6159 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6160 self.footnote_definition_at(self.caret)
6161 }
6162
6163 /// The footnote definition spanning `off`, and where the reference that
6164 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6165 ///
6166 /// The mirror image, deliberately: the same gesture that takes a reader from
6167 /// `[1]` down to the note takes them from the note back up to `[1]`, so
6168 /// following a footnote is a round trip rather than a fall. It needs no
6169 /// memory of how the reader arrived — the document says where the reference
6170 /// is — which is what makes it work for a reader who scrolled to the notes
6171 /// themselves, and what keeps it right after an edit moves either end.
6172 ///
6173 /// `None` when `off` stands in no definition. A definition nothing cites is
6174 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6175 /// its label and no offset, so a frontend can say "nothing refers to this"
6176 /// rather than offer a jump that goes nowhere.
6177 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6178 // Definitions are roots beside `doc`, so `nodes()` — which walks the
6179 // document body — never reports one. They're asked for directly, the way
6180 // `footnote_at` asks for the note it resolves to.
6181 //
6182 // Closed at the end, unlike the half-open test its neighbours use. A
6183 // definition's span stops at its last content byte — the newline ending
6184 // the line is outside it — so `span.end` is the caret stop at the end of
6185 // the note's own row, not the first byte of anything after. Excluding it
6186 // meant the one caret an author is guaranteed to have, the one left
6187 // sitting at the end of the note they just typed, was in no definition at
6188 // all: writing a note and then asking to go back to its reference
6189 // answered nothing. Two definitions in a row still can't both match —
6190 // there is a blank line between them — and `max_by_key` decides anyway.
6191 let note = wysiwyg::footnote_definitions(&mut self.editor)
6192 .into_iter()
6193 .filter(|m| m.span.start <= off && off <= m.span.end)
6194 .max_by_key(|m| m.span.start)?;
6195 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6196
6197 // The earliest reference carrying this label. `min` rather than a `find`,
6198 // because `nodes()` reports a flattened walk whose order is twig's
6199 // business, not document order. Bound first: the walk needs `&mut self`
6200 // and reading the labels back out needs `&self.source`.
6201 let nodes = self.nodes();
6202 let offset = nodes
6203 .into_iter()
6204 .filter(|n| n.kind == Kind::FootnoteReference)
6205 .filter(|n| {
6206 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6207 })
6208 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6209 .map(|n| n.span.start + 2)
6210 .min();
6211 Some(FootnoteDef { label, offset })
6212 }
6213
6214 /// The destination of the image under the caret — what an image prompt shows
6215 /// so editing an existing image starts from its current URL instead of blank,
6216 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6217 /// `None` when the caret stands in no image. A caret resting just after a
6218 /// block image (its trailing stop) is still "in" it — the half-open span test
6219 /// excludes that offset, which is the intended precision: past the image is
6220 /// past it.
6221 pub fn image_destination_at_caret(&mut self) -> Option<String> {
6222 let off = self.caret;
6223 self.nodes()
6224 .into_iter()
6225 .filter(|n| n.kind == Kind::Image)
6226 .filter(|n| n.span.start <= off && off < n.span.end)
6227 .max_by_key(|n| n.span.start)
6228 .and_then(|n| n.destination)
6229 }
6230
6231 /// The language of the fenced code block the caret stands in — what a
6232 /// language prompt shows so editing it starts from the current value rather
6233 /// than blank. `None` when the caret is in no code block, or in one whose
6234 /// fence carries no language (or an indented block, which has no fence).
6235 pub fn code_language_at_caret(&mut self) -> Option<String> {
6236 let start = self.code_block_start_at_caret()?;
6237 wysiwyg::code_language(&self.source, start)
6238 }
6239
6240 /// Whether the caret stands in a fenced code block — the one a language
6241 /// prompt could edit. A frontend gates its "set language" affordance on this
6242 /// (an indented block, which can't carry a language, reports `false`).
6243 pub fn caret_in_fenced_code(&mut self) -> bool {
6244 self.code_block_start_at_caret()
6245 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6246 }
6247
6248 /// Set (or clear, with `""`) the language of the fenced code block the caret
6249 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6250 /// block, and a reported error for a language the format's fence cannot
6251 /// carry.
6252 ///
6253 /// twig rewrites the info string, so the fence's own width — measured
6254 /// against a body neither side touches — is kept, and a language holding a
6255 /// space, a line end or the fence character is refused rather than written
6256 /// out to reparse as something else. Leaf used to splice over the info span
6257 /// itself and `trim()` the input, which handled the one bad case it had
6258 /// thought of.
6259 pub fn set_code_language(&mut self, lang: &str) {
6260 // The read-only gate — this door reaches twig without the splice.
6261 if self.read_only {
6262 return;
6263 }
6264 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6265 return;
6266 }
6267 if self.code_block_start_at_caret().is_none() {
6268 return;
6269 }
6270 let lang = lang.trim();
6271 // `None` clears the info string; `Some("")` asks for an empty one. Both
6272 // write a bare fence, and the prompt's empty value means "clear".
6273 let want = (!lang.is_empty()).then_some(lang);
6274 self.record_caret();
6275 match self.editor.set_code_language(self.caret, want) {
6276 Ok(_) => {
6277 self.last_edit_kind = None;
6278 self.refresh();
6279 self.anchor = None;
6280 self.dirty = self.source != self.clean_source;
6281 self.status = None;
6282 self.clamp_caret();
6283 self.record_caret();
6284 }
6285 Err(e) => self.status = Some(format!("code language: {e}")),
6286 }
6287 }
6288
6289 /// The `span.start` of the code block covering the caret — the anchor
6290 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6291 /// in none.
6292 fn code_block_start_at_caret(&mut self) -> Option<usize> {
6293 let off = self.caret;
6294 self.nodes()
6295 .into_iter()
6296 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6297 .max_by_key(|n| n.span.start)
6298 .map(|n| n.span.start)
6299 }
6300
6301 /// The source range of the text inside the link covering `off` — what sits
6302 /// between its `[` and `]`. `None` when twig reports no link there.
6303 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6304 self.nodes()
6305 .into_iter()
6306 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6307 // the other's `span.start`; the link that starts latest at or before
6308 // `off` is the one `off` is actually in.
6309 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6310 .max_by_key(|n| n.span.start)
6311 .and_then(|n| n.content_span)
6312 }
6313
6314 // ── undo / redo ───────────────────────────────────────────────────────────
6315 // twig owns the history of *bytes* (it owns the buffer) and now carries the
6316 // caret through it too: `record_caret` stashes each state's caret in twig's
6317 // opaque per-step blob, and undo/redo hand it back with the source they
6318 // restore. So leaf keeps no history of its own — no parallel stacks to march
6319 // in lockstep and silently drift out of it.
6320
6321 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6322 /// where they were when that step began.
6323 pub fn undo(&mut self) {
6324 if self.read_only {
6325 return;
6326 }
6327 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6328 match self.editor.undo() {
6329 Ok(Some(change)) => {
6330 self.after_history(change);
6331 // `refresh` counted the restore as an edit; it was a step back.
6332 self.undo_steps = undone.saturating_sub(1);
6333 self.redo_steps = redoable + 1;
6334 }
6335 Ok(None) => {
6336 self.undo_steps = 0;
6337 self.status = Some("nothing to undo".into());
6338 }
6339 Err(e) => self.status = Some(format!("undo: {e}")),
6340 }
6341 }
6342
6343 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6344 /// selection back where that step originally left them.
6345 pub fn redo(&mut self) {
6346 if self.read_only {
6347 return;
6348 }
6349 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6350 match self.editor.redo() {
6351 Ok(Some(change)) => {
6352 self.after_history(change);
6353 // `refresh` counted the restore as an edit; it was a step forward.
6354 self.undo_steps = undone + 1;
6355 self.redo_steps = redoable.saturating_sub(1);
6356 }
6357 Ok(None) => {
6358 self.redo_steps = 0;
6359 self.status = Some("nothing to redo".into());
6360 }
6361 Err(e) => self.status = Some(format!("redo: {e}")),
6362 }
6363 }
6364
6365 /// Refresh the cached source and put the caret back where the step being
6366 /// undone/redone had it, clearing any active run.
6367 ///
6368 /// The caret comes from twig's blob for the restored state (what
6369 /// `record_caret` stored). `change` is only the fallback for a state with no
6370 /// blob — a caret at the end of the restored text, which is where this always
6371 /// landed before the blobs were kept. It is the edit site, not where the user
6372 /// was standing, so it's a floor and not the behaviour: undoing should hand
6373 /// back the document *and* the place you were working, which for an edit made
6374 /// anywhere but under the caret are two different places.
6375 fn after_history(&mut self, change: Change) {
6376 self.refresh();
6377 match self
6378 .editor
6379 .caret_blob()
6380 .ok()
6381 .and_then(|b| CaretState::from_blob(&b))
6382 {
6383 Some(state) => {
6384 self.caret = state.caret.min(self.source.len());
6385 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6386 }
6387 None => {
6388 self.caret = change.new.end.min(self.source.len());
6389 self.anchor = None;
6390 }
6391 }
6392 self.goal_col = None;
6393 self.last_edit_kind = None;
6394 self.dirty = self.source != self.clean_source;
6395 self.status = None;
6396 self.clamp_caret();
6397 }
6398
6399 // ── the file ──────────────────────────────────────────────────────────────
6400
6401 #[cfg(feature = "fs")]
6402 pub fn save(&mut self) {
6403 if self.is_untitled() {
6404 // No path to write and no name to invent: ⌘S on an untitled document
6405 // is a Save As, and only a frontend has a picker to ask with. Say so
6406 // rather than failing at the filesystem with an empty path.
6407 self.status = Some("untitled — save as…".into());
6408 return;
6409 }
6410 let path = self.path.clone();
6411 if self.write(&path) {
6412 self.mark_saved();
6413 }
6414 }
6415
6416 /// Save As: write the document to `path` and *move* it there — `self.path`
6417 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6418 /// what Save As means; a copy would leave the user editing a document whose
6419 /// name is no longer where their keystrokes go.
6420 ///
6421 /// The move only happens if the bytes actually landed. A failed write leaves
6422 /// the path, `dirty`, and the disk watermark exactly as they were, with the
6423 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6424 /// must never come away believing it was saved.
6425 ///
6426 /// An existing `path` is overwritten, and the caller is the one that knows
6427 /// whether to ask first: a Save As picker has already run that prompt, and a
6428 /// second confirmation from down here would be the same question twice.
6429 ///
6430 /// `format` does **not** follow the new extension. The buffer is parsed as
6431 /// the format it was opened with, and re-reading it as another one is a
6432 /// conversion — a different, lossy operation that would throw away the undo
6433 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6434 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6435 /// until it's reopened.
6436 #[cfg(feature = "fs")]
6437 pub fn save_as(&mut self, path: PathBuf) {
6438 if !self.write(&path) {
6439 return;
6440 }
6441 self.path = path;
6442 self.mark_saved();
6443 }
6444
6445 /// Put `source` on disk at `path`, reporting whether it got there. The one
6446 /// place leaf writes a document, so a save and a Save As can't disagree
6447 /// about what a failure looks like.
6448 #[cfg(feature = "fs")]
6449 fn write(&mut self, path: &Path) -> bool {
6450 match std::fs::write(path, self.source.as_bytes()) {
6451 Ok(()) => true,
6452 Err(e) => {
6453 self.status = Some(format!("save failed: {e}"));
6454 false
6455 }
6456 }
6457 }
6458
6459 /// Re-base the document's saved watermark to the current bytes: clears
6460 /// `dirty`, records `source` as the new clean state (so undoing back to here
6461 /// clears the flag again), and re-stamps the on-disk hash.
6462 ///
6463 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
6464 /// the hook a **filesystem-free host** calls itself once it has persisted
6465 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
6466 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
6467 /// are already where that host wants them, and this just tells the model they
6468 /// are safe.
6469 pub fn mark_saved(&mut self) {
6470 self.clean_source = self.source.clone();
6471 self.dirty = false;
6472 // The bytes on disk are now ours, so this is the new watermark: without
6473 // re-stamping it, every save would report its own work as an external
6474 // change forever after.
6475 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
6476 self.status = Some(format!("saved {}", self.file_name()));
6477 }
6478
6479 /// What the file looks like now against the bytes leaf last read or wrote.
6480 ///
6481 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
6482 /// so this is a filesystem round-trip, not a per-frame question — ask it
6483 /// when a window regains focus, on a timer, or before a save.
6484 ///
6485 /// This *only* reports the file. Whether the document also has unsaved edits
6486 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
6487 /// [`DiskState::Changed`] means a save overwrites someone's work and a
6488 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
6489 /// it has no way to ask — so it hands a frontend both halves and lets it put
6490 /// the question to the person who can answer it.
6491 #[cfg(feature = "fs")]
6492 pub fn disk_state(&self) -> DiskState {
6493 let Some(want) = self.disk_hash else {
6494 return DiskState::Untitled;
6495 };
6496 match std::fs::read(&self.path) {
6497 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
6498 Ok(_) => DiskState::Changed,
6499 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
6500 Err(_) => DiskState::Unreadable,
6501 }
6502 }
6503
6504 /// Re-read the file and replace the document with what's there — the other
6505 /// answer to a [`DiskState::Changed`].
6506 ///
6507 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
6508 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
6509 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
6510 /// document shouldn't have to argue with a guard.
6511 ///
6512 /// **The undo history survives, and the reload is one step in it.** The
6513 /// whole buffer is spliced with the file's bytes through the same door every
6514 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
6515 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
6516 /// swapped the document out from under a reader gives them back what they
6517 /// were looking at, marked dirty, and ^Z again carries on into whatever they
6518 /// had done before it. This used to build a fresh parse and drop the stack,
6519 /// on the reasoning that twig's history belongs to the buffer and these are
6520 /// different bytes; that is true of *rebasing* a step onto them and not of
6521 /// recording the swap itself as one, which is all this is. A splice twig
6522 /// won't take falls back to the fresh parse, and only that path still costs
6523 /// the history.
6524 ///
6525 /// The caret keeps its byte offset, clamped to the new length; the selection
6526 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
6527 /// file changed, so it can't know where the caret "still" is. Clamping keeps
6528 /// it where the user left it in the common case (a change further down the
6529 /// file, or none in the text they're sitting in), and never puts it
6530 /// somewhere invalid. A selection has two such offsets and no such excuse —
6531 /// silently reinterpreting one over changed bytes would arm the *next*
6532 /// keystroke to delete something the user never selected.
6533 ///
6534 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
6535 /// document alone with a status.
6536 #[cfg(feature = "fs")]
6537 pub fn reload(&mut self) {
6538 if self.is_untitled() {
6539 self.status = Some("no file to reload".into());
6540 return;
6541 }
6542 let bytes = match std::fs::read(&self.path) {
6543 Ok(b) => b,
6544 Err(e) => {
6545 self.status = Some(format!("reload failed: {e}"));
6546 return;
6547 }
6548 };
6549 let Ok(source) = String::from_utf8(bytes) else {
6550 self.status = Some("reload failed: file is not UTF-8".into());
6551 return;
6552 };
6553 // Already these bytes — someone saved a file back unchanged, or leaf's
6554 // own write is being read back. Re-baseline against it and stop: a
6555 // splice of the text onto itself would put an undo step on the stack for
6556 // something nobody did.
6557 if source == self.source {
6558 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6559 self.clean_source = source;
6560 self.dirty = false;
6561 self.status = Some(format!("reloaded {}", self.file_name()));
6562 return;
6563 }
6564 let caret = self.caret;
6565 // The pre-reload caret, so undoing the swap puts it back where the
6566 // reader was standing — the same bracketing `splice_exact` does.
6567 self.record_caret();
6568 if self
6569 .editor
6570 .edit_range(0, self.source.len(), &source)
6571 .is_ok()
6572 {
6573 self.refresh();
6574 } else {
6575 // twig wouldn't take the splice. Start over from the bytes, which is
6576 // what this always did, and is the one path that still costs the
6577 // history — `format` is the format this document *is*, not what the
6578 // (unchanged) name now says, see `save_as`.
6579 match new_editor(source.as_bytes(), self.format) {
6580 Ok(editor) => {
6581 self.editor = editor;
6582 self.source = source.clone();
6583 // Not going through `refresh`, so the revision has to move
6584 // here or every frontend keeps painting the old file from
6585 // cache.
6586 self.revision += 1;
6587 }
6588 Err(e) => {
6589 self.status = Some(format!("reload failed: {e}"));
6590 return;
6591 }
6592 }
6593 }
6594 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6595 self.clean_source = self.source.clone();
6596 self.caret = caret.min(self.source.len());
6597 self.anchor = None;
6598 self.goal_col = None;
6599 self.last_edit_kind = None;
6600 self.dirty = false;
6601 self.status = Some(format!("reloaded {}", self.file_name()));
6602 self.clamp_caret();
6603 // And the post-reload caret, so a redo restores it.
6604 self.record_caret();
6605 }
6606
6607 /// Re-read the source from twig after it has changed the document. The one
6608 /// funnel every edit, undo, and redo comes through — so it's where the
6609 /// revision moves, and anything cached against the text dies here.
6610 fn refresh(&mut self) {
6611 if let Ok(s) = self.editor.source_str() {
6612 self.source = s;
6613 }
6614 self.revision += 1;
6615 // An edit is a step onto the history and the end of anything undone;
6616 // `undo`/`redo` come through here too and correct this after.
6617 self.undo_steps += 1;
6618 self.redo_steps = 0;
6619 self.clamp_caret();
6620 }
6621
6622 /// Whether [`undo`](Self::undo) has a step to take back — for a native
6623 /// Edit menu to enable its item by. See the note on `undo_steps` for what
6624 /// "has" means here.
6625 pub fn can_undo(&self) -> bool {
6626 !self.read_only && self.undo_steps > 0
6627 }
6628
6629 /// Whether [`redo`](Self::redo) has an undone step to restore.
6630 pub fn can_redo(&self) -> bool {
6631 !self.read_only && self.redo_steps > 0
6632 }
6633
6634 // ── caret movement ─────────────────────────────────────────────────────────
6635 // `extend` grows the selection (Shift+motion): it pins the anchor on the
6636 // first extended step and moves only the caret; an un-extended motion drops
6637 // the selection.
6638
6639 /// Place the caret at byte `offset` (clamped to a char boundary), extending
6640 /// the selection when `extend` is set. The public form of `move_to`, for a
6641 /// frontend that hit-tests pixels straight to a source offset.
6642 pub fn place_caret(&mut self, offset: usize, extend: bool) {
6643 self.goal_col = None;
6644 let before = self.caret;
6645 // A pixel hit-test can land between the visible caret stops — in the
6646 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6647 // Snap to the nearest real stop so the caret can't come to rest where it
6648 // would draw in one place and type in another. The `(row, col)` click
6649 // path (`click`) already snaps this way through `offset_of_pos`; the
6650 // source view reaches every byte, so it snaps to nothing.
6651 let target = match self.view {
6652 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6653 // The source view reaches every byte, so there is no stop to snap
6654 // to — but "every byte" still means every *character* boundary. A
6655 // caret resting inside a multi-byte character draws nowhere real
6656 // and panics the next time anything slices there.
6657 View::Source => self.char_boundary_at_or_before(offset),
6658 };
6659 self.move_to(target, extend);
6660 self.clamp_caret();
6661 self.debug_assert_on_a_stop(before);
6662 }
6663
6664 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6665 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6666 /// grab the metadata) while the source view still selects the literal whole.
6667 pub fn select_all(&mut self) {
6668 self.anchor = Some(self.caret_floor());
6669 self.caret = self.source.len();
6670 self.goal_col = None;
6671 self.last_edit_kind = None;
6672 self.status = None;
6673 }
6674
6675 /// Select the word (or whitespace / punctuation run) at `offset` — the
6676 /// double-click gesture. Anchors on the run's start with the caret at its
6677 /// end so a following Shift-motion extends from the far edge.
6678 pub fn select_word_at(&mut self, offset: usize) {
6679 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6680 self.anchor = Some(s);
6681 self.caret = e;
6682 self.goal_col = None;
6683 self.last_edit_kind = None;
6684 self.status = None;
6685 self.clamp_caret();
6686 }
6687
6688 /// Select the whole enclosing text block (paragraph, heading, list item's
6689 /// text…) at `offset` — the triple-click gesture. Reads the range straight
6690 /// from the AST (twig's `content_span`), so it selects the entire *logical*
6691 /// paragraph even when that paragraph soft-wraps across several visual rows —
6692 /// where a visual-row-based select breaks down, because one source offset at
6693 /// a wrap boundary belongs to two rows at once.
6694 pub fn select_block_at(&mut self, offset: usize) {
6695 let off = offset.min(self.source.len());
6696 let range = self
6697 .editor
6698 .ancestors_at(off)
6699 .ok()
6700 .and_then(|chain| {
6701 // Ancestors run root → deepest; the deepest node that is neither
6702 // an inline span nor a multi-block container is the text block
6703 // the caret sits in (a paragraph, a heading, a code block…).
6704 chain
6705 .into_iter()
6706 .rev()
6707 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6708 .map(|m| m.content_span.unwrap_or(m.span))
6709 })
6710 .unwrap_or_else(|| source_line_range(&self.source, off));
6711 self.anchor = Some(range.start.min(self.source.len()));
6712 self.caret = range.end.min(self.source.len());
6713 self.goal_col = None;
6714 self.last_edit_kind = None;
6715 self.status = None;
6716 self.clamp_caret();
6717 }
6718
6719 /// Select the exact source range `[start, end)` — anchor at `start`, caret
6720 /// at `end` — without snapping either end to a visible caret stop.
6721 ///
6722 /// The one caret verb that takes a range it was *handed* rather than one it
6723 /// worked out, for a host that already knows the bytes it means: a search
6724 /// hit, an annotation's footprint, a quote re-anchored through
6725 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6726 /// wrong tool for that, and not by a little — it snaps to the nearest
6727 /// *visible* stop, and where a range butts up against a hidden delimiter
6728 /// the nearest stop is the one before it, so selecting the "needle" of
6729 /// `**needle**` comes back with "needl" and an edit against it strands the
6730 /// "e".
6731 ///
6732 /// What `place_caret` does that is bookkeeping rather than snapping still
6733 /// happens here, because a host handing in a range is not asking to opt out
6734 /// of the invariants:
6735 ///
6736 /// - both ends are clamped into the document and up to
6737 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6738 /// frontmatter is hidden, and a caret parked in it draws nowhere and
6739 /// types into the metadata;
6740 /// - both land on character boundaries, so nothing slices a `é` in half;
6741 /// - the sticky vertical goal column is dropped, and any armed inline mark
6742 /// disarmed, since a range from outside inherits neither.
6743 ///
6744 /// An empty range is a caret rather than a selection —
6745 /// [`selection`](Self::selection) reports `None` for it, as it does for any
6746 /// anchor that has met the caret.
6747 pub fn select_range(&mut self, start: usize, end: usize) {
6748 let floor = self.caret_floor();
6749 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6750 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6751 self.anchor = Some(anchor);
6752 self.caret = caret;
6753 self.goal_col = None;
6754 self.status = None;
6755 self.last_edit_kind = None;
6756 self.clear_pending();
6757 }
6758
6759 /// `offset` itself if it is a character boundary, else the boundary before
6760 /// it. An offset that isn't one draws nowhere real and panics the next time
6761 /// anything slices there.
6762 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6763 let mut o = offset.min(self.source.len());
6764 while o > 0 && !self.source.is_char_boundary(o) {
6765 o -= 1;
6766 }
6767 o
6768 }
6769
6770 /// The lowest source offset the caret may occupy in the active view. In
6771 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6772 /// the first rendered offset; the source view reaches everything, so it's 0.
6773 fn caret_floor(&self) -> usize {
6774 match self.view {
6775 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6776 View::Source => 0,
6777 }
6778 }
6779
6780 /// Land in a table cell with its whole content selected — the anchor at the
6781 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6782 /// like tabbing into a form field: the text comes up selected, so typing
6783 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6784 /// end`) collapses to a plain caret home (an empty selection is no selection).
6785 fn select_cell(&mut self, start: usize, end: usize) {
6786 self.select_range(start, end);
6787 }
6788
6789 fn move_to(&mut self, offset: usize, extend: bool) {
6790 if extend {
6791 if self.anchor.is_none() {
6792 self.anchor = Some(self.caret);
6793 }
6794 } else {
6795 self.anchor = None;
6796 }
6797 self.caret = offset.min(self.source.len()).max(self.caret_floor());
6798 self.status = None;
6799 // A caret move ends the current typing/deletion run, so the next edit
6800 // starts a fresh undo group rather than coalescing across the gap.
6801 self.last_edit_kind = None;
6802 // Moving away disarms any sticky mark — "start bold" applies only where
6803 // it was asked for, not wherever the caret next lands.
6804 self.clear_pending();
6805 }
6806
6807 // In the source view, motion walks source bytes / source lines. In the
6808 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6809 // what steps the caret cleanly over hidden delimiters.
6810
6811 pub fn move_left(&mut self, extend: bool) {
6812 self.goal_col = None;
6813 if !extend && let Some((s, _e)) = self.selection() {
6814 self.move_to(s, false);
6815 return;
6816 }
6817 let target = match self.view {
6818 View::Source => {
6819 if self.caret > 0 {
6820 prev_boundary(&self.source, self.caret)
6821 } else {
6822 0
6823 }
6824 }
6825 // Walks caret *stops*, not columns: decoration (a table border, a
6826 // cell's padding) is stepped over in one press, and a hidden
6827 // delimiter never holds the caret up — though the end of a mark's
6828 // content is a stop of its own (`VisualMap::mark_ends`), so
6829 // leaving `**bold**` from past its `**` is a press onto the end of
6830 // the bold and another onto the `d`.
6831 View::Wysiwyg => self
6832 .vmap
6833 .caret_stop_before(self.caret)
6834 .unwrap_or(self.caret),
6835 };
6836 let before = self.caret;
6837 self.move_to(target, extend);
6838 self.debug_assert_on_a_stop(before);
6839 }
6840
6841 pub fn move_right(&mut self, extend: bool) {
6842 self.goal_col = None;
6843 if !extend && let Some((_s, e)) = self.selection() {
6844 self.move_to(e, false);
6845 return;
6846 }
6847 let target = match self.view {
6848 View::Source => {
6849 if self.caret < self.source.len() {
6850 next_boundary(&self.source, self.caret)
6851 } else {
6852 self.caret
6853 }
6854 }
6855 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
6856 };
6857 let before = self.caret;
6858 self.move_to(target, extend);
6859 self.debug_assert_on_a_stop(before);
6860 }
6861
6862 /// Move to the start of the previous word (⌥← / Ctrl+←).
6863 pub fn move_word_left(&mut self, extend: bool) {
6864 self.goal_col = None;
6865 let before = self.caret;
6866 let target = self.word_left_from(self.caret);
6867 self.move_to(target, extend);
6868 self.debug_assert_on_a_stop(before);
6869 }
6870
6871 /// Move to the end of the next word (⌥→ / Ctrl+→).
6872 pub fn move_word_right(&mut self, extend: bool) {
6873 self.goal_col = None;
6874 let before = self.caret;
6875 let target = self.word_right_from(self.caret);
6876 self.move_to(target, extend);
6877 self.debug_assert_on_a_stop(before);
6878 }
6879
6880 // Word boundaries are found in the space the *view* is in. The source view
6881 // walks the source, because there the source is what's rendered. WYSIWYG
6882 // walks the rendered text instead: `**` is invisible to the user, so it has
6883 // to be invisible to word motion too — a caret parked inside one draws in
6884 // the column after `bold` and types two bytes earlier, and a word-delete
6885 // that stops there shreds the markup into `a ** c`.
6886
6887 /// The word boundary to the left of `off` in the active view's space.
6888 fn word_left_from(&self, off: usize) -> usize {
6889 match self.view {
6890 View::Source => prev_word(&self.source, off),
6891 View::Wysiwyg => self.glyph_word_left(off),
6892 }
6893 }
6894
6895 /// The word boundary to the right of `off` in the active view's space.
6896 fn word_right_from(&self, off: usize) -> usize {
6897 match self.view {
6898 View::Source => next_word(&self.source, off),
6899 View::Wysiwyg => self.glyph_word_right(off),
6900 }
6901 }
6902
6903 /// The character class of the glyph drawn at stop `off`.
6904 ///
6905 /// Read from the source, because a stop points at the source byte its glyph
6906 /// came from — the source *is* where the rendered character is written. What
6907 /// makes the walk glyph space rather than source space is that it only ever
6908 /// visits stops, and the hidden bytes between them have none.
6909 fn class_at(&self, off: usize) -> Class {
6910 self.source
6911 .get(off..)
6912 .and_then(|s| s.chars().next())
6913 .map_or(Class::Space, classify)
6914 }
6915
6916 /// [`next_word`] in glyph space: skip any leading separators, then consume
6917 /// the following word run, with the stop table standing in for the source's
6918 /// characters.
6919 fn glyph_word_right(&self, from: usize) -> usize {
6920 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
6921 return from;
6922 };
6923 let mut in_word = false;
6924 loop {
6925 match self.class_at(off) {
6926 Class::Word => in_word = true,
6927 _ if in_word => return off,
6928 _ => {}
6929 }
6930 match self.vmap.stop_after(off) {
6931 Some(next) => off = next,
6932 None => return off,
6933 }
6934 }
6935 }
6936
6937 /// [`prev_word`] in glyph space: skip separators walking left, then consume
6938 /// the preceding word run.
6939 fn glyph_word_left(&self, from: usize) -> usize {
6940 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
6941 return from;
6942 };
6943 let mut in_word = false;
6944 while let Some(prev) = self.vmap.stop_before(off) {
6945 match self.class_at(prev) {
6946 Class::Word => in_word = true,
6947 _ if in_word => return off,
6948 _ => {}
6949 }
6950 off = prev;
6951 }
6952 off
6953 }
6954
6955 /// After a motion that walks the visual map, the caret must be *on* the map.
6956 /// A stop is the only offset where the caret draws and edits in the same
6957 /// place, and it's the invariant both a caret parked inside an emoji and one
6958 /// parked inside a `**` were quietly breaking.
6959 ///
6960 /// Only when the caret actually moved: a walk with nowhere to go leaves it
6961 /// where it was, which is wherever the floor or a frontend put it rather
6962 /// than somewhere this motion chose.
6963 fn debug_assert_on_a_stop(&self, before: usize) {
6964 debug_assert!(
6965 self.view != View::Wysiwyg
6966 || self.vmap.num_rows() == 0
6967 || self.caret == before
6968 || self.vmap.is_stop(self.caret),
6969 "motion left the caret at {}, which is not a caret stop: it would draw in \
6970 one place and type in another",
6971 self.caret
6972 );
6973 }
6974
6975 // Up and Down run off the ends of the document rather than stopping dead at
6976 // them: Up from the first row lands at the document's start, Down from the
6977 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
6978 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
6979 // reaching the end of the text is what a reader means by it.
6980 //
6981 // The views used to disagree here by accident rather than by decision: the
6982 // source view fell into the edge behaviour through `row_col_to_offset`
6983 // clamping an out-of-range row to the end of the string, while WYSIWYG had
6984 // no row below to walk to and did nothing at all. They share the rule now,
6985 // each in its own space — the source view reaches every byte, WYSIWYG only
6986 // the offsets it draws.
6987
6988 pub fn move_up(&mut self, extend: bool) {
6989 let (row, col) = self.caret_pos();
6990 let goal = self.goal_col.unwrap_or(col);
6991 let target = match self.view {
6992 View::Source => match row.checked_sub(1) {
6993 Some(r) => row_col_to_offset(&self.source, r, goal),
6994 None => self.reachable_start(),
6995 },
6996 // A table's border rules are drawn but hold no caret, so Up steps
6997 // over them to the row that does.
6998 View::Wysiwyg => match self.vmap.navigable_above(row) {
6999 Some(r) => self.row_target(r, goal),
7000 None => self.reachable_start(),
7001 },
7002 };
7003 self.step_vertical(target, goal, extend);
7004 }
7005
7006 pub fn move_down(&mut self, extend: bool) {
7007 let (row, col) = self.caret_pos();
7008 let goal = self.goal_col.unwrap_or(col);
7009 let target = match self.view {
7010 View::Source => match self.source_row_below(row) {
7011 Some(r) => row_col_to_offset(&self.source, r, goal),
7012 None => self.reachable_end(),
7013 },
7014 View::Wysiwyg => match self.vmap.navigable_below(row) {
7015 Some(r) => self.row_target(r, goal),
7016 None => self.reachable_end(),
7017 },
7018 };
7019 self.step_vertical(target, goal, extend);
7020 }
7021
7022 /// Land a vertical motion at `target`, latching the `goal` column it aimed
7023 /// with so the rest of the run keeps aiming there.
7024 ///
7025 /// A motion with nowhere to go changes *nothing*, the goal column included:
7026 /// the latch used to run before the early return at the top of the document,
7027 /// so an Up that did nothing still armed a column, and the next Down aimed
7028 /// at one the caret had never been in.
7029 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
7030 let before = self.caret;
7031 if target == before {
7032 return;
7033 }
7034 self.goal_col = Some(goal);
7035 self.move_to(target, extend);
7036 self.debug_assert_on_a_stop(before);
7037 }
7038
7039 /// The source line below `row`, or `None` when `row` is the last one. Lines
7040 /// are counted by newline, so a trailing one leaves a real, empty last line
7041 /// for the caret to sit on — the document ends below it, not on it.
7042 fn source_row_below(&self, row: usize) -> Option<usize> {
7043 let last = self.source.bytes().filter(|&b| b == b'\n').count();
7044 (row < last).then_some(row + 1)
7045 }
7046
7047 /// Where a vertical motion aiming at the `goal` column lands on visual row
7048 /// `r`: the column clamped to the row, mapped to its offset, then held
7049 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
7050 /// column belongs to the row below, and a gutter's column 0 points at the
7051 /// block rather than at this row.
7052 fn row_target(&self, r: usize, goal: usize) -> usize {
7053 let (start, end) = self.row_bounds(r);
7054 self.vmap
7055 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
7056 .clamp(start, end)
7057 }
7058
7059 /// The first and last offsets the caret can reach in the active view.
7060 ///
7061 /// Not the same span in both: the source view shows every byte, so it can
7062 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
7063 /// sits below the first stop, and a document's trailing newline is drawn
7064 /// nowhere and so sits past the last.
7065 fn reachable_start(&self) -> usize {
7066 match self.view {
7067 View::Source => 0,
7068 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
7069 }
7070 }
7071
7072 fn reachable_end(&self) -> usize {
7073 match self.view {
7074 View::Source => self.source.len(),
7075 View::Wysiwyg => self
7076 .vmap
7077 .stop_at_or_before(self.source.len())
7078 .unwrap_or(self.caret),
7079 }
7080 }
7081
7082 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
7083 /// including the space a soft wrap ate off its end, which is drawn on this
7084 /// row however much the offset past it belongs to the next one.
7085 fn row_span(&self, r: usize) -> (usize, usize) {
7086 let start = self
7087 .vmap
7088 .row_start(r)
7089 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
7090 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
7091 (start.min(end), end)
7092 }
7093
7094 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
7095 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
7096 /// this row's last position is the one before it — the offset before the
7097 /// space the wrap ate, where the caret draws just past the row's last word
7098 /// and types there too.
7099 ///
7100 /// Aiming at the shared offset instead is what stalled End: it is the row's
7101 /// last *column*, so End pressed on the row reached it and then read back as
7102 /// the row below's start, where a second press ran on to that row's end and
7103 /// the next to the one after — End walking down the paragraph a row a press.
7104 fn row_bounds(&self, r: usize) -> (usize, usize) {
7105 let (start, end) = self.row_span(r);
7106 let wraps = self
7107 .vmap
7108 .navigable_below(r)
7109 .and_then(|b| self.vmap.row_start(b))
7110 .is_some_and(|off| off == end);
7111 match wraps {
7112 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
7113 false => (start, end),
7114 }
7115 }
7116
7117 /// The `[start, end]` of the line Home and End aim at: the visual row in
7118 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
7119 ///
7120 /// A soft-wrapped row is a line here, because it is one to the eye and the
7121 /// eye is what these keys are aimed by — a reader pressing End means the end
7122 /// of the line they can see. (`select_block_at` wants the opposite and reads
7123 /// the AST for it: a triple-click grabs the whole paragraph, however many
7124 /// rows it folds into.)
7125 fn line_bounds(&self) -> (usize, usize) {
7126 let (row, _) = self.caret_pos();
7127 match self.view {
7128 View::Source => {
7129 let start = line_start(&self.source, row);
7130 (start, line_end_from(&self.source, start))
7131 }
7132 View::Wysiwyg => self.row_bounds(row),
7133 }
7134 }
7135
7136 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
7137 /// *drawn* — what a kill takes.
7138 ///
7139 /// The two part only at a soft wrap, over the space the wrap ate: the caret
7140 /// can't stand after it (that offset opens the row below, and End stopping
7141 /// there would walk), but it is on this row, and a kill that spared it would
7142 /// leave a double space behind where the row's text had been. Deleting it
7143 /// joins nothing — a wrap is drawn, not written.
7144 fn line_span(&self) -> (usize, usize) {
7145 let (row, _) = self.caret_pos();
7146 match self.view {
7147 View::Source => self.line_bounds(),
7148 View::Wysiwyg => self.row_span(row),
7149 }
7150 }
7151
7152 /// The first offset in `[start, end]` holding something other than
7153 /// whitespace, or `end` when the line holds nothing else — where Home aims.
7154 ///
7155 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7156 /// so a hidden delimiter is never taken for the line's first character (nor
7157 /// landed on), and the source view steps the source it is showing.
7158 fn first_non_space(&self, start: usize, end: usize) -> usize {
7159 let mut off = start;
7160 while off < end {
7161 if self.class_at(off) != Class::Space {
7162 return off;
7163 }
7164 off = match self.view {
7165 View::Source => next_boundary(&self.source, off),
7166 View::Wysiwyg => match self.vmap.stop_after(off) {
7167 Some(next) => next,
7168 None => return end,
7169 },
7170 };
7171 }
7172 end
7173 }
7174
7175 /// Home: to the first character on the line, or to column 0 when the caret
7176 /// is already on it — the two-press toggle every editor spells this way.
7177 /// The indentation is somewhere the caret has to be able to reach and almost
7178 /// never where a reader is headed, so it costs the second press.
7179 pub fn move_home(&mut self, extend: bool) {
7180 self.goal_col = None;
7181 let (start, end) = self.line_bounds();
7182 let text = self.first_non_space(start, end);
7183 let target = if self.caret == text { start } else { text };
7184 let before = self.caret;
7185 self.move_to(target, extend);
7186 self.debug_assert_on_a_stop(before);
7187 }
7188
7189 /// End: to the end of the line.
7190 pub fn move_end(&mut self, extend: bool) {
7191 self.goal_col = None;
7192 let (_, end) = self.line_bounds();
7193 let before = self.caret;
7194 self.move_to(end, extend);
7195 self.debug_assert_on_a_stop(before);
7196 }
7197
7198 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7199 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7200 /// when the caret isn't in a table, or is already in the last/first cell — the
7201 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7202 /// meaning everywhere else.
7203 pub fn cell_hop(&mut self, forward: bool) -> bool {
7204 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7205 return false;
7206 };
7207 // Flatten to document (row-major) order and step one cell either way.
7208 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7209 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7210 let next = if forward {
7211 i.checked_add(1)
7212 } else {
7213 i.checked_sub(1)
7214 };
7215 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7216 return false; // at the table's edge; leave Tab to the frontend
7217 };
7218 self.select_cell(start, end);
7219 true
7220 }
7221
7222 /// Move the caret to the cell directly above (`down == false`) or below in
7223 /// the same column, landing with the cell's whole content selected (see
7224 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7225 /// when the caret isn't in a table), so the frontend can fall through — the
7226 /// vertical counterpart of [`Self::cell_hop`].
7227 ///
7228 /// A ragged row that is short a column clamps to its last cell, so Down never
7229 /// falls out of the table over a gap the row above happened to have.
7230 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7231 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7232 return false;
7233 };
7234 let target = match down {
7235 true => r + 1,
7236 false if r == 0 => return false,
7237 false => r - 1,
7238 };
7239 let Some(row) = grid.get(target) else {
7240 return false;
7241 };
7242 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7243 return false;
7244 };
7245 self.select_cell(start, end);
7246 true
7247 }
7248
7249 /// The table containing `off` as a row-major grid of `(start, end)` cell
7250 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7251 /// isn't in a table. Read straight off the visual map's laid-out grid, so
7252 /// every cell (an empty one included, whose derived home twig gives no
7253 /// `content_span` for) is present and in the order Tab walks them.
7254 // Grid, row, column — three returns that only ever travel together, and a
7255 // named type for the pair of them would be read at one call site.
7256 #[allow(clippy::type_complexity)]
7257 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7258 for t in &self.vmap.tables {
7259 let mut pos = None;
7260 let grid: Vec<Vec<(usize, usize)>> = t
7261 .grid
7262 .iter()
7263 .enumerate()
7264 .map(|(r, row)| {
7265 row.cells
7266 .iter()
7267 .enumerate()
7268 .map(|(c, cell)| {
7269 if pos.is_none() && off >= cell.start && off <= cell.end {
7270 pos = Some((r, c));
7271 }
7272 (cell.start, cell.end)
7273 })
7274 .collect()
7275 })
7276 .collect();
7277 if let Some((r, c)) = pos {
7278 return Some((grid, r, c));
7279 }
7280 }
7281 None
7282 }
7283
7284 // ── table key policy ──────────────────────────────────────────────────────
7285 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7286 // as one policy every frontend shares, rather than each re-deriving it. Each
7287 // reports whether it acted *as a table key*; a `false` hands the key back to
7288 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7289 // everywhere else.
7290
7291 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7292 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7293 /// back and simply stays put at the very first cell. `false` when the caret
7294 /// isn't in a table.
7295 pub fn cell_tab(&mut self, forward: bool) -> bool {
7296 if !self.caret_in_table() {
7297 return false;
7298 }
7299 if self.cell_hop(forward) {
7300 return true;
7301 }
7302 // Off the last cell: grow the table by a row and step into its first
7303 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7304 if forward {
7305 self.append_row_and_enter(0);
7306 }
7307 true
7308 }
7309
7310 /// Return inside a table: drop to the cell below in the same column,
7311 /// appending a new row when the caret is already in the last one. `false`
7312 /// when the caret isn't in a table, so the frontend inserts a newline.
7313 pub fn cell_return(&mut self) -> bool {
7314 if !self.caret_in_table() {
7315 return false;
7316 }
7317 if self.cell_move_vertical(true) {
7318 return true;
7319 }
7320 // Already on the last row: grow one below and drop into the same column.
7321 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7322 self.append_row_and_enter(col);
7323 true
7324 }
7325
7326 /// Append a row below the caret's (last) row and land in `col` of it. The
7327 /// caret is in the last row, so twig's "insert below" makes the fresh row the
7328 /// table's new last — but twig re-spells the whole table, moving every byte,
7329 /// so the destination is read back from the rebuilt grid by the table's
7330 /// position (stable across a row insert), not from the pre-edit caret.
7331 fn append_row_and_enter(&mut self, col: usize) {
7332 let table = self.caret_table_index();
7333 self.table_insert_row(true);
7334 self.rebuild_map();
7335 let Some((start, end)) = table
7336 .and_then(|ti| self.vmap.tables.get(ti))
7337 .and_then(|t| t.grid.last())
7338 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7339 .map(|cell| (cell.start, cell.end))
7340 else {
7341 return;
7342 };
7343 self.select_cell(start, end);
7344 }
7345
7346 /// The index, among the document's tables, of the one the caret sits in —
7347 /// `None` when it's in none. Used to re-find a table after an edit re-spells
7348 /// it (a row insert leaves the table order unchanged).
7349 fn caret_table_index(&self) -> Option<usize> {
7350 let off = self.caret;
7351 self.vmap.tables.iter().position(|t| {
7352 t.grid
7353 .iter()
7354 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7355 })
7356 }
7357
7358 /// Shift+Return inside a table: insert a hard line break *within* the current
7359 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7360 /// table, so the frontend inserts an ordinary line break.
7361 ///
7362 /// A table row is a single source line, so the newline-spelled hard break
7363 /// can't live in a cell. twig spells the in-cell break the format's way
7364 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7365 /// break round-trips as structure the renderer reads back as a line — not the
7366 /// opaque raw HTML the old raw-splice left behind.
7367 ///
7368 /// Djot has no idiomatic in-cell break, so twig refuses it
7369 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7370 /// would render as the literal text `<br>`. The gesture is still *consumed*
7371 /// there — returning `false` would let the frontend insert a real newline,
7372 /// which splits the one-line row — it just leaves the cell unchanged and says
7373 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7374 ///
7375 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7376 /// have to be read together: djot is not the only `false`, and naming it in
7377 /// the message was already a guess that HTML — which spells the break as its
7378 /// own `<br>` — would have made wrong.
7379 pub fn cell_line_break(&mut self) -> bool {
7380 if self.read_only || !self.caret_in_table() {
7381 return false;
7382 }
7383 self.record_caret();
7384 match self.editor.insert_line_break(self.caret) {
7385 Ok(change) => {
7386 self.last_edit_kind = None;
7387 self.refresh();
7388 self.caret = change.new.end;
7389 self.anchor = None;
7390 self.goal_col = None;
7391 self.clamp_caret();
7392 self.dirty = self.source != self.clean_source;
7393 self.status = None;
7394 self.record_caret();
7395 }
7396 Err(twig::Error::UnsupportedFormat) => {
7397 self.status = Some(format!(
7398 "in-cell line breaks aren't supported in {}",
7399 self.format_name()
7400 ));
7401 }
7402 Err(_) => {}
7403 }
7404 true
7405 }
7406
7407 /// Rebuild the visual map at the width the last build used. A structural edit
7408 /// bumps the revision and swaps the source in, but leaves the *map* stale;
7409 /// when a single gesture edits and then moves over the result (Tab appending
7410 /// a row, then stepping into it), the move needs the map to already show the
7411 /// edit rather than waiting for the frontend's next frame.
7412 fn rebuild_map(&mut self) {
7413 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7414 self.build_map(wrap);
7415 }
7416
7417 /// Move the caret to the very start of the document (⌘↑ on macOS,
7418 /// Ctrl+Home on Windows/Linux).
7419 pub fn move_doc_start(&mut self, extend: bool) {
7420 self.goal_col = None;
7421 self.move_to(0, extend);
7422 }
7423
7424 /// Move the caret to the very end of the document (⌘↓ on macOS,
7425 /// Ctrl+End on Windows/Linux).
7426 pub fn move_doc_end(&mut self, extend: bool) {
7427 self.goal_col = None;
7428 let end = self.source.len();
7429 self.move_to(end, extend);
7430 }
7431
7432 /// Point the caret at the body cell `(row, col)` the mouse landed on —
7433 /// `col` being a cell of the terminal grid, which is what a display column
7434 /// is. A click on the far cell of a wide character lands at that
7435 /// character's start; the mapping's own doc-comments carry the rule.
7436 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
7437 self.goal_col = None;
7438 let target = match self.view {
7439 View::Source => row_col_to_offset(&self.source, row, col),
7440 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
7441 };
7442 let before = self.caret;
7443 self.move_to(target, extend);
7444 self.debug_assert_on_a_stop(before);
7445 }
7446
7447 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
7448 /// screen if it has moved since the last frame, and never scroll past the
7449 /// last of `rows`.
7450 ///
7451 /// Only if it has *moved* — that's the whole point. Revealing the caret on
7452 /// every frame ties the viewport to it, and a scroll wheel that fights the
7453 /// caret for the viewport loses: the view snaps back the instant it tries to
7454 /// pass the caret's row, so the document can't be scrolled beyond what's
7455 /// already on screen. A caret move is the frontend's cue to follow; a scroll
7456 /// with the caret sitting still is the reader's cue to leave it alone.
7457 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
7458 if self.drawn_caret != Some(self.caret) {
7459 if caret_row < self.scroll {
7460 self.scroll = caret_row;
7461 } else if height > 0 && caret_row >= self.scroll + height {
7462 self.scroll = caret_row + 1 - height;
7463 }
7464 self.drawn_caret = Some(self.caret);
7465 }
7466 self.scroll = self.scroll.min(rows.saturating_sub(1));
7467 }
7468
7469 /// The caret's screen position `(row, col)` in the active view's grid, with
7470 /// `col` a display column: the cell to draw the caret in, which on a line of
7471 /// `你好` or emoji is not the count of characters before it.
7472 pub fn caret_pos(&self) -> (usize, usize) {
7473 match self.view {
7474 View::Source => offset_to_row_col(&self.source, self.caret),
7475 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
7476 }
7477 }
7478
7479 fn clamp_caret(&mut self) {
7480 if self.caret > self.source.len() {
7481 self.caret = self.source.len();
7482 }
7483 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
7484 // any selection anchor) to the first rendered offset.
7485 let floor = self.caret_floor();
7486 if self.caret < floor {
7487 self.caret = floor;
7488 }
7489 if let Some(a) = self.anchor
7490 && a < floor
7491 {
7492 self.anchor = Some(floor);
7493 }
7494 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
7495 self.caret -= 1;
7496 }
7497 }
7498}
7499
7500// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
7501
7502// Left/right motion and backspace/delete step by *grapheme cluster*, not
7503// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
7504// marks moves and deletes as the single character a user sees. Grapheme
7505// boundaries are a superset of char boundaries, so the caret stays valid for twig.
7506
7507/// How an insert of `text` groups for undo: a single typed character folds into
7508/// the run of typing around it, while a newline or a multi-character insert is a
7509/// step of its own.
7510fn typed_edit_kind(text: &str) -> EditKind {
7511 if text.chars().take(2).count() == 1 && text != "\n" {
7512 EditKind::Insert
7513 } else {
7514 EditKind::Other
7515 }
7516}
7517
7518fn prev_boundary(s: &str, i: usize) -> usize {
7519 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7520 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
7521}
7522
7523fn next_boundary(s: &str, i: usize) -> usize {
7524 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7525 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
7526}
7527
7528// ── word boundaries ──────────────────────────────────────────────────────────
7529// The shared primitive behind word-wise motion, word deletion, and
7530// double-click-to-select-a-word. A "word" is a maximal run of one character
7531// class; whitespace and punctuation are their own classes, so motion skips
7532// cleanly between them the way native text fields do.
7533
7534#[derive(PartialEq, Eq, Clone, Copy)]
7535enum Class {
7536 Word,
7537 Space,
7538 Other,
7539}
7540
7541/// The source range of an inline node's own visible text — the part of it a
7542/// WYSIWYG caret can reach, as against the delimiters that only spell it.
7543/// `None` for a node with no interior to empty (a `str`, a break).
7544///
7545/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
7546/// one delimiter in from the span — the same place the renderer maps it to. A
7547/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
7548/// against the source rather than trusted: a range guessed wrong here is text
7549/// deleted wrong.
7550fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
7551 if let Some(span) = n.content_span.clone() {
7552 return Some(span);
7553 }
7554 match n.kind.as_str() {
7555 "verbatim" | "inline_math" => {
7556 let text = n.text.as_ref()?;
7557 let start = n.span.start + 1;
7558 let range = start..start + text.len();
7559 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
7560 }
7561 _ => None,
7562 }
7563}
7564
7565/// The `id` a node declares, or `None` for one that declares none — the
7566/// attribute djot writes for a `{#v1}` and mints for a heading.
7567///
7568/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
7569/// names nothing, so it reads as absent rather than as the empty string.
7570fn declared_id(n: &FlatNode) -> Option<&str> {
7571 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
7572}
7573
7574/// A heading's words reduced to the form a link fragment spells them in:
7575/// lowercase, runs of anything else collapsed to a single `-`, with none left
7576/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
7577///
7578/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
7579/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
7580/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
7581/// any other language is still a heading someone will link to. Underscores
7582/// survive for the same reason they do on the web: they are word characters
7583/// wherever identifiers are written.
7584fn slug(text: &str) -> String {
7585 let mut out = String::new();
7586 let mut pending = false;
7587 for c in text.chars() {
7588 if c.is_alphanumeric() || c == '_' {
7589 if pending && !out.is_empty() {
7590 out.push('-');
7591 }
7592 pending = false;
7593 out.extend(c.to_lowercase());
7594 } else {
7595 pending = true;
7596 }
7597 }
7598 out
7599}
7600
7601fn is_block_container(kind: &Kind) -> bool {
7602 matches!(
7603 kind,
7604 Kind::Doc
7605 | Kind::Section
7606 | Kind::BlockQuote
7607 | Kind::BulletList
7608 | Kind::OrderedList
7609 | Kind::TaskList
7610 | Kind::ListItem
7611 | Kind::TaskListItem
7612 // Every `container` — a directive in any of its three forms, or a
7613 // promoted HTML element. A *text* directive is really inline, so
7614 // claiming it here is a small overreach, and the deliberate one this
7615 // function's kind-only peer `is_inline_kind` documents: the pair is
7616 // consulted together, and answering "block container" for something
7617 // inline is what keeps an ancestor walk from stopping short of the
7618 // paragraph that actually holds it.
7619 | Kind::Container
7620 )
7621}
7622
7623/// The `[start, end)` byte range of the source line containing `off` (newline
7624/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7625/// line between paragraphs).
7626fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7627 let off = off.min(s.len());
7628 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7629 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7630 start..end
7631}
7632
7633/// How many leading bytes an outdent takes off `line`: a whole indent level
7634/// where the line has one, and whatever it has where it has less.
7635///
7636/// A leading tab counts as a level on its own. It's indentation some other
7637/// editor wrote, and one tab is one level everywhere it came from — measuring it
7638/// in spaces it doesn't contain would leave it untouchable.
7639fn outdent_width(line: &str, unit: usize) -> usize {
7640 if line.starts_with('\t') {
7641 return 1;
7642 }
7643 line.bytes().take(unit).take_while(|b| *b == b' ').count()
7644}
7645
7646/// A list marker found at the head of a line, together with everything before it
7647/// that a sibling line has to repeat.
7648///
7649/// The three offsets differ only inside a block quote, where `> - b` opens with
7650/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7651/// `line_start == marker_start`, and `text` is the plain `" - "`.
7652#[derive(Clone, Debug)]
7653struct ListMarker {
7654 /// The line's first byte.
7655 line_start: usize,
7656 /// Where the marker proper begins, past any quote prefix. The offset to hand
7657 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7658 marker_start: usize,
7659 /// `line_start` through the marker's trailing space — quote prefix, indent
7660 /// and bullet together, which is what the next item's line opens with.
7661 text: String,
7662}
7663
7664impl ListMarker {
7665 /// Where the item's content starts — one past the marker's trailing space.
7666 fn content_start(&self) -> usize {
7667 self.line_start + self.text.len()
7668 }
7669}
7670
7671fn classify(c: char) -> Class {
7672 if c == '_' || c.is_alphanumeric() {
7673 Class::Word
7674 } else if c.is_whitespace() {
7675 Class::Space
7676 } else {
7677 Class::Other
7678 }
7679}
7680
7681/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7682/// skip any leading separators, then consume the following word run.
7683fn next_word(s: &str, i: usize) -> usize {
7684 let mut off = i;
7685 let mut in_word = false;
7686 for c in s[i..].chars() {
7687 if classify(c) == Class::Word {
7688 in_word = true;
7689 } else if in_word {
7690 break;
7691 }
7692 off += c.len_utf8();
7693 }
7694 off
7695}
7696
7697/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7698/// skip separators walking left, then consume the preceding word run.
7699fn prev_word(s: &str, i: usize) -> usize {
7700 let mut off = i;
7701 let mut in_word = false;
7702 for c in s[..i].chars().rev() {
7703 if classify(c) == Class::Word {
7704 in_word = true;
7705 } else if in_word {
7706 break;
7707 }
7708 off -= c.len_utf8();
7709 }
7710 off
7711}
7712
7713/// The `[start, end)` run of same-class characters surrounding `off` — the
7714/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7715/// the run ending there is used.
7716fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7717 if s.is_empty() {
7718 return (0, 0);
7719 }
7720 let off = off.min(s.len());
7721 let reference = if off < s.len() {
7722 s[off..].chars().next()
7723 } else {
7724 s[..off].chars().next_back()
7725 };
7726 let Some(rc) = reference else {
7727 return (off, off);
7728 };
7729 let class = classify(rc);
7730
7731 let mut start = off;
7732 for c in s[..start].chars().rev() {
7733 if classify(c) == class {
7734 start -= c.len_utf8();
7735 } else {
7736 break;
7737 }
7738 }
7739 let mut end = off;
7740 for c in s[end..].chars() {
7741 if classify(c) == class {
7742 end += c.len_utf8();
7743 } else {
7744 break;
7745 }
7746 }
7747 (start, end)
7748}
7749
7750/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7751/// the line's start — terminal cells, not characters, so the column names the
7752/// cell the caret is drawn in even on a line of `你好` or emoji.
7753fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7754 let off = off.min(s.len());
7755 let mut row = 0;
7756 let mut line_start = 0;
7757 for (i, &b) in s.as_bytes().iter().enumerate() {
7758 if i >= off {
7759 break;
7760 }
7761 if b == b'\n' {
7762 row += 1;
7763 line_start = i + 1;
7764 }
7765 }
7766 (row, wysiwyg::text_width(&s[line_start..off]))
7767}
7768
7769/// The byte offset at display column `col` of `row` (clamped to that line's
7770/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
7771///
7772/// A column landing *inside* a character — the second cell of `你`, or any cell
7773/// but the first of an emoji — resolves to that character's start, which is the
7774/// column the caret would have been drawn at to begin with. So both cells of a
7775/// wide character mean the character, and every offset survives the round trip
7776/// out to a column and back. The walk steps by grapheme cluster for the same
7777/// reason the caret does: a cluster is the character, and the cells belong to it
7778/// rather than to the codepoints spelling it.
7779fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
7780 let start = line_start(s, row);
7781 let end = line_end_from(s, start);
7782 let mut off = start;
7783 let mut at = 0; // the display column `off` sits at
7784 while off < end {
7785 let next = next_boundary(s, off).min(end);
7786 let cells = wysiwyg::text_width(&s[off..next]);
7787 if at + cells > col {
7788 break; // `col` is one of this cluster's own cells
7789 }
7790 at += cells;
7791 off = next;
7792 }
7793 off
7794}
7795
7796fn line_start(s: &str, row: usize) -> usize {
7797 if row == 0 {
7798 return 0;
7799 }
7800 let mut r = 0;
7801 for (i, &b) in s.as_bytes().iter().enumerate() {
7802 if b == b'\n' {
7803 r += 1;
7804 if r == row {
7805 return i + 1;
7806 }
7807 }
7808 }
7809 s.len()
7810}
7811
7812fn line_end_from(s: &str, start: usize) -> usize {
7813 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
7814}
7815
7816/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
7817/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
7818/// twig applies writing the mark out, so the toolbar can light the same button
7819/// that made the node.
7820///
7821/// `None` for every other kind, including the inline nodes that aren't marks at
7822/// all (`str`, `link`, `image`, the math and break kinds): they're things a
7823/// caret stands in, not formatting a button toggles.
7824/// Whether a match from an ancestor chain is an inline run whose delimiters
7825/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
7826/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
7827/// span is a [`Kind::Container`], which the kind alone cannot tell from a
7828/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
7829/// answer is the node's own. Every delete and caret step that walks over a
7830/// `**` walks over a span's tags by this test; without it Backspace after
7831/// `</span>` took the `>` and left the paragraph unparseable.
7832fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
7833 inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
7834}
7835
7836fn inline_kind(kind: &Kind) -> Option<InlineKind> {
7837 Some(match kind {
7838 Kind::Strong => InlineKind::Strong,
7839 Kind::Emph => InlineKind::Emph,
7840 Kind::Verbatim => InlineKind::Verbatim,
7841 Kind::Mark => InlineKind::Mark,
7842 Kind::Superscript => InlineKind::Superscript,
7843 Kind::Subscript => InlineKind::Subscript,
7844 Kind::Insert => InlineKind::Insert,
7845 Kind::Delete => InlineKind::Delete,
7846 _ => return None,
7847 })
7848}
7849
7850/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
7851/// a highlight's opening `==`.
7852///
7853/// Two enums for one closed vocabulary, and the duplication is the boundary
7854/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
7855/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
7856/// the editor writes. Spelled as a match rather than routed through the two
7857/// crates' name strings so that a colour added on either side is a compile
7858/// error here, where the pairing is decided, rather than a runtime `None` that
7859/// would read as "clear the colour".
7860fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
7861 match color {
7862 MarkColor::Red => twig::MarkColor::Red,
7863 MarkColor::Orange => twig::MarkColor::Orange,
7864 MarkColor::Yellow => twig::MarkColor::Yellow,
7865 MarkColor::Green => twig::MarkColor::Green,
7866 MarkColor::Blue => twig::MarkColor::Blue,
7867 MarkColor::Purple => twig::MarkColor::Purple,
7868 MarkColor::Brown => twig::MarkColor::Brown,
7869 }
7870}
7871
7872/// Where an offset lands after a splice it didn't make — twig's own rule, from
7873/// [`Change`]: shift anything at or past the replaced range's end by the length
7874/// the replacement gained or lost, and leave anything before it alone.
7875///
7876/// An offset *inside* the replaced range has no text of its own to ride any
7877/// more, and lands at the end of what replaced it: for
7878/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
7879/// the prefix is cleared, which then sits where the highlighted text begins.
7880/// One node's attribute list, twig's own `(key, value)` pairs owned — what
7881/// every presentation gesture reads, edits one key of, and passes back whole.
7882type Attrs = Vec<(String, Option<String>)>;
7883
7884/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
7885/// writes it and the walker draws it, and a frontend that paginates matches a
7886/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
7887/// stated once.
7888pub const PAGE_BREAK: &str = "page-break";
7889
7890/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
7891/// every other attribute kept in its place — the read-edit-write half of twig's
7892/// replace-not-merge contract for a `data-` key.
7893///
7894/// **A key that is already there is rewritten where it stands**, and only a key
7895/// the node did not have goes on the end. That is what makes the proposal's
7896/// worked example true: `class="lead center" id="intro"
7897/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
7898/// data-line-height="1.5"` — the same document with one token changed, and a
7899/// one-line diff. Removing the key and pushing it back would reorder the
7900/// author's attributes on every press, so a document that passed through the
7901/// editor came out shuffled even where nothing about it had changed.
7902///
7903/// A duplicate key — which no format leaf opens can spell, but twig reports
7904/// verbatim — collapses onto the first of its copies, since twig is handed one
7905/// value for one key either way.
7906fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
7907 let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
7908 let mut written = false;
7909 for (k, v) in attrs {
7910 if k != key {
7911 out.push((k.clone(), v.clone()));
7912 continue;
7913 }
7914 if let Some(new) = value.filter(|_| !written) {
7915 out.push((k.clone(), Some(new.to_string())));
7916 written = true;
7917 }
7918 }
7919 if let Some(new) = value.filter(|_| !written) {
7920 out.push((key.to_string(), Some(new.to_string())));
7921 }
7922 out
7923}
7924
7925/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
7926/// `token` added, with the rest of the list kept in order.
7927///
7928/// `class` is a space-separated token list, and leaf owns three of the tokens in
7929/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
7930/// goes out as `class="lead right"`; one whose last owned token goes and which
7931/// carried nothing else loses the key, so a block that has lost its whole
7932/// vocabulary is spelled bare again. `class` itself keeps its place among the
7933/// attributes, because [`with_attr`] does the writing.
7934fn with_class_token(
7935 attrs: &[(String, Option<String>)],
7936 mine: impl Fn(&str) -> bool,
7937 token: Option<&str>,
7938) -> Attrs {
7939 let kept: Vec<&str> = attrs
7940 .iter()
7941 .find(|(k, _)| k == "class")
7942 .and_then(|(_, v)| v.as_deref())
7943 .unwrap_or_default()
7944 .split_whitespace()
7945 .filter(|t| !mine(t))
7946 .collect();
7947 let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
7948 with_attr(
7949 attrs,
7950 "class",
7951 (!class.is_empty()).then_some(class.as_str()),
7952 )
7953}
7954
7955/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
7956///
7957/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
7958/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
7959/// (djot has no bare attribute, so no format reads one back everywhere), and
7960/// `hidden=""` is the same document where `hidden` is; dropping it instead
7961/// would lose what the author wrote, which is the one thing these gestures
7962/// promise not to do.
7963fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
7964 attrs
7965 .iter()
7966 .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
7967 .collect()
7968}
7969
7970fn reanchor(off: usize, change: &Change) -> usize {
7971 if off < change.old.start {
7972 return off;
7973 }
7974 if off < change.old.end {
7975 return change.new.end;
7976 }
7977 (off + change.new.end).saturating_sub(change.old.end)
7978}
7979
7980/// [`reanchor`] for an edit that respells the markup *around* a block and
7981/// leaves the block's own bytes alone — which is every attribute gesture.
7982///
7983/// `block` is that block's content span before and after the splice, so an
7984/// offset standing in the text keeps its distance from the text's start and how
7985/// many bytes twig wrote above it never enters the arithmetic. That is the whole
7986/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
7987/// on the same div lengthens the attribute line, clearing the last one takes the
7988/// div away entirely, and both are the same sum. `None` where the splice named
7989/// no block at either end, which is every djot case — the `{…}` line is written
7990/// above the block, and the block itself only shifts past it.
7991///
7992/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
7993/// shifted by its delta after it, and at the splice's end for an offset that
7994/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
7995/// text to keep.
7996fn reanchor_in_block(
7997 off: usize,
7998 change: &Change,
7999 block: Option<(&Range<usize>, &Range<usize>)>,
8000) -> usize {
8001 if let Some((was, now)) = block
8002 && was.start <= off
8003 && off <= was.end
8004 {
8005 return now.start + (off - was.start).min(now.end - now.start);
8006 }
8007 reanchor(off, change)
8008}
8009
8010/// A watermark for a file's contents (see `Doc::disk_hash`).
8011///
8012/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
8013/// watermark is compared only against one taken by the same process moments
8014/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
8015/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
8016fn hash_bytes(bytes: &[u8]) -> u64 {
8017 use std::hash::{Hash, Hasher};
8018 let mut h = std::collections::hash_map::DefaultHasher::new();
8019 bytes.hash(&mut h);
8020 h.finish()
8021}
8022
8023#[cfg(feature = "fs")]
8024fn detect_format(path: &Path) -> Result<Format> {
8025 let ext = path
8026 .extension()
8027 .and_then(|e| e.to_str())
8028 .unwrap_or("")
8029 .to_ascii_lowercase();
8030 Ok(match ext.as_str() {
8031 "dj" | "djot" => Format::Djot,
8032 "md" | "markdown" => Format::Markdown,
8033 "xml" => Format::Xml,
8034 "html" | "htm" => Format::Html,
8035 other => return Err(anyhow!("unknown document extension: .{other}")),
8036 })
8037}
8038
8039#[cfg(test)]
8040mod tests {
8041 use super::*;
8042 use crate::style::{FontFamily, LineSpacing, SizeStep};
8043
8044 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
8045 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
8046 /// without one is a view no user is ever in.
8047 fn doc_in(view: View, name: &str, body: &str) -> Doc {
8048 // The fixture name doubles as the temp file's, so two tests picking the
8049 // same one raced under the parallel runner and read each other's body —
8050 // a green suite proving the wrong thing. The counter makes that
8051 // unreachable rather than asking every future caller to notice.
8052 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8053 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8054 let mut p = std::env::temp_dir();
8055 p.push(format!("leaf_test_{name}_{seq}.md"));
8056 std::fs::write(&p, body).unwrap();
8057 let mut d = Doc::open(p).unwrap();
8058 d.view = view;
8059 if view == View::Wysiwyg {
8060 d.build_visual(80);
8061 }
8062 d
8063 }
8064
8065 // Source-view document for the source-behaviour tests. `Doc::open` now
8066 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
8067 // `wysiwyg_doc` builds the rich-text variant on top of this.
8068 fn doc_with(name: &str, body: &str) -> Doc {
8069 doc_in(View::Source, name, body)
8070 }
8071
8072 /// Every visual row's drawn text — what the reader actually sees, which is
8073 /// the only thing the reveal preference is supposed to change.
8074 fn drawn_rows(d: &Doc) -> Vec<String> {
8075 d.vmap
8076 .rows
8077 .iter()
8078 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8079 .collect()
8080 }
8081
8082 /// Put the caret at the first byte of `needle` and rebuild, so the row under
8083 /// it becomes the revealed line.
8084 fn caret_at(d: &mut Doc, needle: &str) {
8085 d.caret = d.source.find(needle).expect("needle in source");
8086 d.build_visual(80);
8087 }
8088
8089 #[test]
8090 fn blockquote_after_a_list_is_not_bulleted() {
8091 // twig nests a following top-level block quote under the `bullet_list`
8092 // (a direct child, not a `list_item`). The map must render it de-nested —
8093 // `│ quote`, never `• │ quote` — with a blank separator, like any block
8094 // that follows a list. Regression for the "combined list + blockquote" bug.
8095 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
8096 d.build_visual(80);
8097 let rows: Vec<String> = d
8098 .vmap
8099 .rows
8100 .iter()
8101 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8102 .collect();
8103 assert!(
8104 rows.iter().any(|r| r == "│ quote"),
8105 "block quote should render on its own gutter, got rows: {rows:?}"
8106 );
8107 assert!(
8108 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
8109 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
8110 );
8111 }
8112
8113 // ── the map is built at most once per (revision, wrap) ───────────────────
8114 //
8115 // A frontend repaints for reasons that have nothing to do with the text — a
8116 // blinking caret, a scroll — and rebuilding the map is O(document). These
8117 // pin *that the cache fires*, which a passing suite can't tell you: a cache
8118 // that never hits is invisible to every other test in this file.
8119 //
8120 // The probe is to wreck the built map and ask for it again. A rebuild
8121 // repairs it; a cache hit hands the wreckage straight back. Nothing else
8122 // can distinguish the two from outside.
8123
8124 #[test]
8125 fn a_rebuild_with_nothing_changed_reuses_the_map() {
8126 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
8127 d.build_visual(80);
8128 assert!(!d.vmap.rows.is_empty());
8129 d.vmap.rows.clear(); // wreck it
8130 d.build_visual(80);
8131 assert!(
8132 d.vmap.rows.is_empty(),
8133 "the map was rebuilt though nothing changed — the cache never fired"
8134 );
8135 }
8136
8137 #[test]
8138 fn an_edit_rebuilds_the_map() {
8139 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
8140 d.build_visual(80);
8141 let before = d.revision();
8142 d.vmap.rows.clear();
8143 d.insert("x");
8144 d.build_visual(80);
8145 assert!(d.revision() > before, "an edit must move the revision");
8146 assert!(
8147 !d.vmap.rows.is_empty(),
8148 "an edited document must not paint from a stale map"
8149 );
8150 }
8151
8152 #[test]
8153 fn a_width_change_rebuilds_the_map() {
8154 // The map is a function of the wrap width too, so a resize is a miss
8155 // even though the text is untouched.
8156 let mut d = doc_in(
8157 View::Wysiwyg,
8158 "cache_width",
8159 "one two three four five six\n",
8160 );
8161 d.build_visual(80);
8162 d.vmap.rows.clear();
8163 d.build_visual(12);
8164 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8165 // And the unwrapped map is its own key, not the same as any width.
8166 d.vmap.rows.clear();
8167 d.build_visual_unwrapped();
8168 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8169 }
8170
8171 #[test]
8172 fn a_motion_does_not_rebuild_the_map() {
8173 // The whole point: moving the caret changes nothing the map is built
8174 // from. If a motion bumped the revision, every arrow key would cost a
8175 // full rebuild and the cache would be worthless.
8176 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8177 d.build_visual(80);
8178 let rev = d.revision();
8179 d.move_right(false);
8180 d.move_right(true);
8181 d.move_down(false);
8182 assert_eq!(d.revision(), rev, "a motion must not move the revision");
8183 d.vmap.rows.clear();
8184 d.build_visual(80);
8185 assert!(
8186 d.vmap.rows.is_empty(),
8187 "a motion should not rebuild the map"
8188 );
8189 }
8190
8191 #[test]
8192 fn saving_does_not_rebuild_the_map() {
8193 // Saving changes `dirty`, not the text.
8194 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8195 d.insert("x");
8196 d.build_visual(80);
8197 let rev = d.revision();
8198 d.save();
8199 assert_eq!(d.revision(), rev, "a save must not move the revision");
8200 assert!(!d.dirty, "the save should have cleaned the document");
8201 }
8202
8203 #[test]
8204 fn a_reload_rebuilds_the_map() {
8205 // Reload replaces the text without going through `refresh`, so it has to
8206 // move the revision itself — else the editor paints the old file.
8207 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8208 d.build_visual(80);
8209 let rev = d.revision();
8210 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8211 d.reload();
8212 assert!(d.revision() > rev, "a reload must move the revision");
8213 d.build_visual(80);
8214 let text: String = d
8215 .vmap
8216 .rows
8217 .iter()
8218 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8219 .collect();
8220 assert!(
8221 text.contains("wholly new"),
8222 "the reloaded text should be on screen, got {text:?}"
8223 );
8224 }
8225
8226 // ── golden-case harness ──────────────────────────────────────────────────
8227 // The pattern the whole parity suite can reuse: write a fixture with the
8228 // caret marked by `|`, run one action, and compare the rendered result —
8229 // also caret-marked — against the expected string. One readable line per
8230 // behavior, and it exercises the exact `Doc` ops both frontends call.
8231
8232 /// Split a `|`-marked fixture into `(source, caret_offset)`.
8233 fn parse_caret(marked: &str) -> (String, usize) {
8234 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8235 (marked.replacen('|', "", 1), caret)
8236 }
8237
8238 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8239 /// selection) so a result reads like the fixtures.
8240 fn render_caret(d: &Doc) -> String {
8241 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8242 // so the caret always renders inside its own selection.
8243 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8244 if let Some((s, e)) = d.selection() {
8245 marks.push((s, 0, '['));
8246 marks.push((e, 2, ']'));
8247 }
8248 // Insert right-to-left: descending offset, then descending rank.
8249 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8250 let mut out = d.source.clone();
8251 for (at, _, ch) in marks {
8252 out.insert(at, ch);
8253 }
8254 out
8255 }
8256
8257 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8258 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8259 golden_in(View::Source, name, marked, action)
8260 }
8261
8262 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8263 /// the same fixture has to read the same way in both.
8264 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8265 let (src, caret) = parse_caret(marked);
8266 let mut d = doc_in(view, name, &src);
8267 d.caret = caret;
8268 action(&mut d);
8269 render_caret(&d)
8270 }
8271
8272 #[test]
8273 fn word_motion_walks_word_by_word() {
8274 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8275 assert_eq!(
8276 g("hello wor|ld", |d| d.move_word_left(false)),
8277 "hello |world"
8278 );
8279 assert_eq!(
8280 g("hello| world", |d| d.move_word_left(false)),
8281 "|hello world"
8282 );
8283 assert_eq!(
8284 g("hel|lo world", |d| d.move_word_right(false)),
8285 "hello| world"
8286 );
8287 assert_eq!(
8288 g("hello| world", |d| d.move_word_right(false)),
8289 "hello world|"
8290 );
8291 // Punctuation is its own class, so motion stops at the boundary.
8292 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8293 }
8294
8295 #[test]
8296 fn word_motion_extends_the_selection_when_asked() {
8297 assert_eq!(
8298 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8299 "hello [world|]"
8300 );
8301 }
8302
8303 #[test]
8304 fn delete_word_removes_a_whole_word() {
8305 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8306 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8307 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8308 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8309 }
8310
8311 // ── Home / End ───────────────────────────────────────────────────────────
8312
8313 #[test]
8314 fn home_toggles_between_the_line_s_text_and_its_margin() {
8315 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8316 // indent to the markup it spells everywhere it means one, so the fixture
8317 // with whitespace left to walk is a code block, which is verbatim.
8318 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8319 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
8320 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
8321 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
8322 // A line with no indentation has one place to go, so the toggle is a
8323 // no-op rather than a trip to nowhere.
8324 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8325 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8326
8327 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
8328 let indent = d.source.find(" indented").unwrap();
8329 d.caret = indent + 6; // inside "indented"
8330 d.move_home(false);
8331 assert_eq!(
8332 d.caret,
8333 indent + 4,
8334 "wysiwyg: Home aims at the code line's text"
8335 );
8336 d.move_home(false);
8337 assert_eq!(
8338 d.caret, indent,
8339 "wysiwyg: the second press takes the indent"
8340 );
8341 d.move_home(false);
8342 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8343 }
8344
8345 #[test]
8346 fn end_takes_the_line_the_view_is_showing() {
8347 // The line differs by view for the same document, and that is the point:
8348 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8349 // as a space on one row and the source view as two lines.
8350 let mut d = doc_with("end_src", "one two\nthree\n");
8351 d.caret = 1;
8352 d.move_end(false);
8353 assert_eq!(d.caret, 7, "source: the end of the source line");
8354
8355 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8356 d.caret = 1;
8357 d.move_end(false);
8358 assert_eq!(
8359 d.caret, 13,
8360 "wysiwyg: the end of the row, soft break and all"
8361 );
8362 }
8363
8364 #[test]
8365 fn home_and_end_extend_the_selection_when_asked() {
8366 for (view, tag) in VIEWS {
8367 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
8368 d.caret = 6;
8369 d.move_end(true);
8370 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
8371 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
8372 d.caret = 6;
8373 d.move_home(true);
8374 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
8375 }
8376 }
8377
8378 // ── kill to the line's start / end ───────────────────────────────────────
8379
8380 #[test]
8381 fn kill_to_the_line_start_and_end_in_both_views() {
8382 for (view, tag) in VIEWS {
8383 // The gap that reads as a paragraph break in each view: the source
8384 // view's lines are the renderer's rows only where the source says so.
8385 let gap = if view == View::Source { "\n" } else { "\n\n" };
8386 let mut d = doc_in(
8387 view,
8388 &format!("kill_end_{tag}"),
8389 &format!("one two{gap}three\n"),
8390 );
8391 d.caret = 3;
8392 d.delete_to_line_end();
8393 assert_eq!(
8394 d.source,
8395 format!("one{gap}three\n"),
8396 "{tag}: ^K to the line's end"
8397 );
8398 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
8399
8400 let mut d = doc_in(
8401 view,
8402 &format!("kill_start_{tag}"),
8403 &format!("one two{gap}three\n"),
8404 );
8405 d.caret = 7; // the end of the first line
8406 d.delete_to_line_start();
8407 assert_eq!(
8408 d.source,
8409 format!("{gap}three\n"),
8410 "{tag}: ⌘⌫ to the line's start"
8411 );
8412 assert_eq!(d.caret, 0, "{tag}");
8413 }
8414 }
8415
8416 #[test]
8417 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
8418 // The decision: at the boundary both kills do nothing, rather than
8419 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
8420 // at a soft wrap as often as at a newline, where there is nothing
8421 // written to delete — and a source newline is only half of the blank
8422 // line between two paragraphs, so taking it leaves a soft break rather
8423 // than the join it looks like. Backspace and Delete are the keys for it.
8424 for (view, tag) in VIEWS {
8425 let gap = if view == View::Source { "\n" } else { "\n\n" };
8426 let src = format!("one{gap}three\n");
8427 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
8428 d.caret = 3; // the end of "one"
8429 d.delete_to_line_end();
8430 assert_eq!(
8431 d.source, src,
8432 "{tag}: ^K at the line's end joined it to the next"
8433 );
8434
8435 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
8436 d.caret = 3 + gap.len(); // the start of "three"
8437 d.delete_to_line_start();
8438 assert_eq!(
8439 d.source, src,
8440 "{tag}: ⌘⌫ at the line's start joined it to the last"
8441 );
8442 }
8443 }
8444
8445 #[test]
8446 fn a_kill_takes_the_selection_when_there_is_one() {
8447 // What every other delete here does with one, so these two as well.
8448 for (view, tag) in VIEWS {
8449 for (name, kill) in [
8450 (
8451 "end",
8452 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
8453 ),
8454 ("start", |d: &mut Doc| d.delete_to_line_start()),
8455 ] {
8456 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
8457 d.anchor = Some(4);
8458 d.caret = 7; // "two"
8459 kill(&mut d);
8460 assert_eq!(
8461 d.source, "one three\n",
8462 "{tag}: {name} ignored the selection"
8463 );
8464 assert_eq!(d.selection(), None, "{tag}: {name}");
8465 }
8466 }
8467 }
8468
8469 #[test]
8470 fn a_kill_takes_the_markup_it_empties_with_it() {
8471 // The same hazard a word-delete has: a WYSIWYG range covers what the
8472 // user can see, which for `**bold**` is the word and never the
8473 // delimiters, so a kill that stopped at the text would leave `a ****` —
8474 // markup wrapped around nothing.
8475 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
8476 d.caret = d.source.find("bold").unwrap();
8477 d.delete_to_line_end();
8478 assert_eq!(d.source, "a \n");
8479 }
8480
8481 #[test]
8482 fn a_kill_is_undone_in_one_step() {
8483 for (view, tag) in VIEWS {
8484 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
8485 d.caret = 3;
8486 d.delete_to_line_end();
8487 assert_eq!(d.source, "one\n", "{tag}");
8488 d.undo();
8489 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
8490 }
8491 }
8492
8493 #[test]
8494 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
8495 // Regression: triple-click used move_home/move_end over visual rows, so
8496 // it only worked on a paragraph's first row (a wrap-boundary offset maps
8497 // to the earlier row). select_block_at reads the AST, so every offset in
8498 // the paragraph selects the whole thing.
8499 let body = "one two three four five six seven eight\n";
8500 let mut d = doc_with("sel_block", body);
8501 d.view = View::Wysiwyg;
8502 d.build_visual(12); // force the paragraph to wrap into several rows
8503 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
8504 let para = (0, "one two three four five six seven eight".len());
8505 for off in [0usize, 8, 19, 28, 38] {
8506 d.caret = 0;
8507 d.anchor = None;
8508 d.select_block_at(off);
8509 assert_eq!(
8510 d.selection(),
8511 Some(para),
8512 "offset {off} should select the paragraph"
8513 );
8514 }
8515 }
8516
8517 #[test]
8518 fn select_block_uses_content_span_for_a_heading() {
8519 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
8520 d.select_block_at(4); // inside "Title"
8521 // content_span excludes the "# " marker.
8522 assert_eq!(d.selected_text(), Some("Title"));
8523 d.select_block_at(10); // inside "body"
8524 assert_eq!(d.selected_text(), Some("body"));
8525 }
8526
8527 #[test]
8528 fn select_all_spans_the_document() {
8529 let mut d = doc_with("sel_all", "abc\n\ndef\n");
8530 d.select_all();
8531 assert_eq!(d.selection(), Some((0, d.source.len())));
8532 }
8533
8534 #[test]
8535 fn select_word_at_picks_the_surrounding_word() {
8536 let mut d = doc_with("sel_word", "hello world\n");
8537 d.select_word_at(8); // inside "world"
8538 assert_eq!(d.selection(), Some((6, 11)));
8539 // Double-clicking at end-of-word still grabs the word to its left.
8540 d.select_word_at(5); // the space between the words
8541 assert_eq!(d.selection(), Some((5, 6)));
8542 }
8543
8544 #[test]
8545 fn word_helpers_respect_utf8_boundaries() {
8546 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
8547 assert_eq!(
8548 golden("utf8", "|café ok", |d| d.move_word_right(false)),
8549 "café| ok"
8550 );
8551 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
8552 }
8553
8554 #[test]
8555 fn typing_inserts_at_the_caret_and_advances_it() {
8556 let mut d = doc_with("type", "hello\n");
8557 d.insert("Hi ");
8558 assert_eq!(d.source, "Hi hello\n");
8559 assert_eq!(d.caret, 3);
8560 assert!(d.dirty);
8561 }
8562
8563 #[test]
8564 fn backspace_deletes_the_char_before_the_caret() {
8565 let mut d = doc_with("bs", "hello\n");
8566 d.caret = 3; // after "hel"
8567 d.backspace();
8568 assert_eq!(d.source, "helo\n");
8569 assert_eq!(d.caret, 2);
8570 }
8571
8572 #[test]
8573 fn typing_replaces_the_selection() {
8574 let mut d = doc_with("replace", "a word b\n");
8575 d.anchor = Some(2);
8576 d.caret = 6; // "word" selected
8577 d.insert("X");
8578 assert_eq!(d.source, "a X b\n");
8579 assert_eq!(d.caret, 3);
8580 assert_eq!(d.anchor, None);
8581 }
8582
8583 #[test]
8584 fn toggle_bold_wraps_then_unwraps_the_selection() {
8585 let mut d = doc_with("bold", "a word b\n");
8586 d.anchor = Some(2);
8587 d.caret = 6;
8588 d.toggle(InlineKind::Strong);
8589 assert_eq!(d.source, "a **word** b\n");
8590 // The toggled region stays selected, so a second toggle reverses it.
8591 d.toggle(InlineKind::Strong);
8592 assert_eq!(d.source, "a word b\n");
8593 d.toggle(InlineKind::Strong);
8594 assert_eq!(d.source, "a **word** b\n");
8595 }
8596
8597 #[test]
8598 fn toggle_code_wraps_then_unwraps_the_selection() {
8599 let mut d = doc_with("code_rt", "a word b\n");
8600 d.anchor = Some(2);
8601 d.caret = 6;
8602 d.toggle(InlineKind::Verbatim);
8603 assert_eq!(d.source, "a `word` b\n");
8604 d.toggle(InlineKind::Verbatim);
8605 assert_eq!(d.source, "a word b\n");
8606 }
8607
8608 #[test]
8609 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
8610 // ⌘b at a bare caret, then type: the text comes out bold with no
8611 // selection ever made — the word-processor "start bold here" gesture.
8612 let mut d = doc_with("sticky_wrap", "xy\n");
8613 d.caret = 1; // between x and y
8614 d.toggle(InlineKind::Strong);
8615 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
8616 d.insert("A");
8617 assert_eq!(d.source, "x**A**y\n");
8618 }
8619
8620 #[test]
8621 fn sticky_bold_lights_the_toolbar_before_any_typing() {
8622 // The button must light the instant ⌘b is pressed, or the mode is
8623 // invisible until the first character lands.
8624 let mut d = doc_with("sticky_light", "xy\n");
8625 d.caret = 1;
8626 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8627 d.toggle(InlineKind::Strong);
8628 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8629 }
8630
8631 #[test]
8632 fn sticky_bold_toggled_off_types_normally_again() {
8633 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8634 // in the flow of typing, the exact sequence the user described.
8635 let mut d = doc_with("sticky_off", "\n");
8636 d.caret = 0;
8637 d.toggle(InlineKind::Strong);
8638 d.insert("a");
8639 d.insert("b"); // continues inside the run, no re-arming
8640 assert_eq!(d.source, "**ab**\n");
8641 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8642 d.insert("c");
8643 assert_eq!(d.source, "**ab**c\n");
8644 }
8645
8646 #[test]
8647 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8648 // Once a mark is realised the caret sits inside the run, so plain typing
8649 // extends it rather than starting a second, adjacent bold span.
8650 let mut d = doc_with("sticky_cont", "\n");
8651 d.caret = 0;
8652 d.toggle(InlineKind::Emph);
8653 d.insert("h");
8654 d.insert("i");
8655 assert_eq!(d.source, "*hi*\n");
8656 }
8657
8658 #[test]
8659 fn moving_the_caret_disarms_a_sticky_mark() {
8660 // Arming a mark and then moving away must not style text elsewhere.
8661 let mut d = doc_with("sticky_disarm", "xy\n");
8662 d.caret = 0;
8663 d.toggle(InlineKind::Strong);
8664 d.move_right(false); // caret 0 → 1, disarms
8665 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8666 d.insert("A");
8667 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8668 }
8669
8670 #[test]
8671 fn stacked_sticky_marks_apply_together() {
8672 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8673 let mut d = doc_with("sticky_stack", "\n");
8674 d.caret = 0;
8675 d.toggle(InlineKind::Strong);
8676 d.toggle(InlineKind::Emph);
8677 d.insert("x");
8678 // Land the caret on the styled character and confirm both marks are live.
8679 d.anchor = Some(d.source.find('x').unwrap());
8680 d.caret = d.anchor.unwrap() + 1;
8681 let marks = d.active_inline_marks();
8682 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8683 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8684 }
8685
8686 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8687
8688 #[test]
8689 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8690 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8691 // The space inside the run made `**bold **`, which is *not* bold — four
8692 // literal asterisks — so the rich view drew them, correctly and
8693 // uselessly, until the next character happened to close the run again.
8694 let mut d = wysiwyg_doc("edge_typing", "a \n");
8695 d.caret = 2;
8696 d.toggle(InlineKind::Strong);
8697 for c in "bold".chars() {
8698 d.insert(&c.to_string());
8699 }
8700 assert_eq!(d.source, "a **bold**\n");
8701 d.insert(" ");
8702 assert_eq!(
8703 d.source, "a **bold** \n",
8704 "the space belongs outside the run"
8705 );
8706 assert!(
8707 d.active_inline_marks().contains(InlineKind::Strong),
8708 "bold is still what's being typed, so the button stays lit"
8709 );
8710 // What the writer is looking at while all this happens: their words.
8711 d.build_visual(80);
8712 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8713 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8714 for c in "hey".chars() {
8715 d.insert(&c.to_string());
8716 }
8717 assert_eq!(
8718 d.source, "a **bold hey**\n",
8719 "one bold phrase, not two runs"
8720 );
8721 }
8722
8723 #[test]
8724 fn typing_past_a_space_can_still_leave_the_bold_behind() {
8725 // The other half: the marks stay armed across the space, so ⌘b turns
8726 // them off again there and the next word is plain — the run isn't
8727 // rejoined by a caret that was told not to.
8728 let mut d = wysiwyg_doc("edge_shed", "\n");
8729 d.caret = 0;
8730 d.toggle(InlineKind::Strong);
8731 for c in "bold ".chars() {
8732 d.insert(&c.to_string());
8733 }
8734 assert_eq!(d.source, "**bold** \n");
8735 d.toggle(InlineKind::Strong);
8736 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8737 d.insert("x");
8738 assert_eq!(d.source, "**bold** x\n");
8739 }
8740
8741 #[test]
8742 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8743 // ⌘b and then a space before any word: the space is not marked (nothing
8744 // is), and the word after it is.
8745 let mut d = wysiwyg_doc("edge_space_first", "a\n");
8746 d.caret = 1;
8747 d.toggle(InlineKind::Strong);
8748 d.insert(" ");
8749 assert_eq!(d.source, "a \n");
8750 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8751 d.insert("b");
8752 assert_eq!(d.source, "a **b**\n");
8753 }
8754
8755 #[test]
8756 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8757 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8758 d.caret = 8; // the caret's home at the end of the run's text
8759 d.insert(" ");
8760 assert_eq!(
8761 d.source, "x **bold** \n",
8762 "the space lands past the delimiters"
8763 );
8764 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
8765
8766 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
8767 d.caret = 4; // in front of the "b"
8768 d.insert(" ");
8769 assert_eq!(d.source, "x **bold** y\n");
8770 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
8771 }
8772
8773 #[test]
8774 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
8775 // Backspace over the last letter of a bold phrase.
8776 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
8777 d.caret = 10; // past the "h"
8778 d.backspace();
8779 assert_eq!(d.source, "a **bold** \n");
8780 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
8781 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8782 d.insert("x");
8783 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
8784 }
8785
8786 #[test]
8787 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
8788 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
8789 // mark, which is only text. The marks live on in the caret instead.
8790 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
8791 d.caret = 5;
8792 d.backspace();
8793 assert_eq!(d.source, "a c\n");
8794 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8795 d.insert("x");
8796 assert_eq!(d.source, "a **x** c\n");
8797 }
8798
8799 #[test]
8800 fn typing_over_a_whole_bold_word_keeps_it_bold() {
8801 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
8802 d.anchor = Some(4);
8803 d.caret = 8; // the word, not its delimiters
8804 d.insert("x");
8805 assert_eq!(d.source, "a **x** c\n");
8806 }
8807
8808 #[test]
8809 fn a_code_span_keeps_the_space_it_is_given() {
8810 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
8811 // is still verbatim, so nothing is re-spelt. The repair asks the parser
8812 // rather than a table of kinds, and this is the answer it gets.
8813 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
8814 d.caret = 7;
8815 d.insert(" ");
8816 assert_eq!(d.source, "a `code ` c\n");
8817 }
8818
8819 #[test]
8820 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
8821 // A run's closing delimiter has a caret home on each side of it, one
8822 // column apart on screen — and a plain ← off the space after a bold word
8823 // lands on the outer one. The character drawn behind the caret there is
8824 // still the last letter of the phrase, so that is what Backspace takes;
8825 // the byte behind it is a `*` nobody can see.
8826 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
8827 d.caret = 9;
8828 d.move_left(false);
8829 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
8830 d.backspace();
8831 assert_eq!(
8832 d.source, "**bol** x\n",
8833 "a letter of the phrase, not its `*`"
8834 );
8835 assert_eq!(d.caret, 5);
8836
8837 // And the mirror in front of the opening delimiter, where Delete's
8838 // character is the first letter of the run.
8839 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
8840 d.caret = 1;
8841 d.delete_forward();
8842 assert_eq!(d.source, "x**old**\n");
8843 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
8844 }
8845
8846 #[test]
8847 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
8848 // The byte beside the caret at either edge of a bold word is a `*` the
8849 // rich view draws nothing for. Taking it is not the character delete the
8850 // key was pressed for — it unspells the run and puts a literal asterisk
8851 // on screen (`a *bold** c`). The visible character is the one that goes.
8852 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
8853 d.caret = 4; // in front of the "b"
8854 d.backspace();
8855 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
8856
8857 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
8858 d.caret = 8; // past the "d"
8859 d.delete_forward();
8860 assert_eq!(d.source, "a **bold**c\n");
8861 assert_eq!(d.caret, 8, "and the caret stays inside the run");
8862 d.insert("x");
8863 assert_eq!(d.source, "a **boldx**c\n");
8864
8865 // A code span's backticks are hidden the same way, so they are covered
8866 // by the same rule and not by a list of kinds.
8867 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
8868 d.caret = 3;
8869 d.backspace();
8870 assert_eq!(d.source, "a`code` c\n");
8871 }
8872
8873 #[test]
8874 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
8875 // The asterisks are on the screen there and the caret can stand between
8876 // them, so a delete takes exactly the byte it is aimed at.
8877 let mut d = doc_with("edge_open_src", "a **bold** c\n");
8878 d.caret = 4;
8879 d.backspace();
8880 assert_eq!(d.source, "a *bold** c\n");
8881
8882 let mut d = doc_with("edge_close_src", "a **bold** c\n");
8883 d.caret = 8;
8884 d.delete_forward();
8885 assert_eq!(d.source, "a **bold* c\n");
8886 }
8887
8888 #[test]
8889 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
8890 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
8891 // The space had stepped outside the run (the mark-edge rule), taking the
8892 // caret with it, so the delete put it back down on the far side of the
8893 // closing `**` — one place on screen, and the wrong side of it. Typing
8894 // came out plain and the toolbar went dark, with nothing to see.
8895 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
8896 d.caret = 0;
8897 d.toggle(InlineKind::Strong);
8898 for c in "bold".chars() {
8899 d.insert(&c.to_string());
8900 }
8901 d.insert(" ");
8902 assert_eq!(d.source, "**bold** \n");
8903 d.backspace();
8904 assert_eq!(
8905 d.source, "**bold**\n",
8906 "the space goes, the delimiters stay"
8907 );
8908 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
8909 assert!(
8910 d.active_inline_marks().contains(InlineKind::Strong),
8911 "so the button is still lit"
8912 );
8913 d.insert("x");
8914 assert_eq!(
8915 d.source, "**boldx**\n",
8916 "and the next character is still bold"
8917 );
8918 }
8919
8920 #[test]
8921 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
8922 // What the stranded caret did next: the byte behind it was the closing
8923 // `*`, so a second press took that instead of a letter — `**bold*`, the
8924 // styling gone and an asterisk on the screen where the word had been.
8925 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
8926 d.caret = 0;
8927 d.toggle(InlineKind::Strong);
8928 for c in "bold ".chars() {
8929 d.insert(&c.to_string());
8930 }
8931 assert_eq!(d.source, "**bold** \n");
8932 d.backspace();
8933 d.backspace();
8934 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
8935 assert_eq!(d.caret, 5);
8936 }
8937
8938 #[test]
8939 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
8940 // `***both***` closes two runs with one stack of asterisks: the caret has
8941 // to walk in through all of them, or it lands between the emph and the
8942 // strong and types half-marked.
8943 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
8944 d.caret = 11;
8945 d.backspace();
8946 assert_eq!(d.source, "***both***\n");
8947 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
8948 d.insert("x");
8949 assert_eq!(d.source, "***bothx***\n");
8950 }
8951
8952 #[test]
8953 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
8954 // The settle only moves a caret a run actually closed over. Ordinary
8955 // deletes — inside a run, or in plain prose — are untouched.
8956 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
8957 d.caret = 8;
8958 d.backspace();
8959 assert_eq!(d.source, "a **bol** c\n");
8960 assert_eq!(d.caret, 7);
8961
8962 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
8963 d.caret = 5;
8964 d.backspace();
8965 assert_eq!(d.source, "plai\n");
8966 assert_eq!(d.caret, 4);
8967 }
8968
8969 #[test]
8970 fn the_source_view_leaves_a_delete_where_it_landed() {
8971 // The delimiters are on the screen there, so the offset past them is a
8972 // place the caret can be seen to be — nothing to settle.
8973 let mut d = doc_with("edge_bksp_src", "**bold** \n");
8974 d.caret = 9;
8975 d.backspace();
8976 assert_eq!(d.source, "**bold**\n");
8977 assert_eq!(d.caret, 8);
8978 }
8979
8980 #[test]
8981 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
8982 // `***both***` closes two runs with one stack of asterisks; a space that
8983 // clears only the inner one lands against the outer's and breaks that
8984 // instead.
8985 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
8986 d.caret = 9;
8987 d.insert(" ");
8988 assert_eq!(d.source, "a ***both*** \n");
8989 assert_eq!(d.caret, 13);
8990 d.insert("x");
8991 assert_eq!(d.source, "a ***both x***\n");
8992 }
8993
8994 #[test]
8995 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
8996 // The delimiter shuffle is not an edit the writer made, so it is not a
8997 // step they have to undo past.
8998 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
8999 d.caret = 8;
9000 d.insert(" ");
9001 assert_eq!(d.source, "a **bold** \n");
9002 d.undo();
9003 assert_eq!(d.source, "a **bold**\n");
9004 }
9005
9006 #[test]
9007 fn the_source_view_types_the_space_where_it_was_asked_to() {
9008 // The rule is a rich-view courtesy. In the source view the delimiters are
9009 // on the screen and the user is editing the bytes they can see.
9010 let mut d = doc_with("edge_src", "a **bold** c\n");
9011 d.caret = 8;
9012 d.insert(" ");
9013 assert_eq!(d.source, "a **bold ** c\n");
9014 }
9015
9016 #[test]
9017 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
9018 // Double-clicking a word takes the space after it; bolding that must not
9019 // spell `**word **`, which is not bold at all.
9020 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
9021 d.anchor = Some(2);
9022 d.caret = 7; // "word "
9023 d.toggle(InlineKind::Strong);
9024 assert_eq!(d.source, "a **word** b\n");
9025 d.toggle(InlineKind::Strong);
9026 assert_eq!(d.source, "a word b\n");
9027 d.toggle(InlineKind::Strong);
9028 assert_eq!(
9029 d.source, "a **word** b\n",
9030 "reapplying the mark must not wrap stale delimiter offsets"
9031 );
9032 // And a selection of nothing but whitespace has no word to mark.
9033 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
9034 d.anchor = Some(6);
9035 d.caret = 7;
9036 d.toggle(InlineKind::Strong);
9037 assert_eq!(d.source, "a word b\n");
9038 assert!(d.status.is_some());
9039 }
9040
9041 #[test]
9042 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
9043 let mut d = doc_with("head_set", "hello\n");
9044 d.caret = 2; // caret inside the paragraph, no selection
9045 d.set_block(BlockKind::Heading(1));
9046 assert_eq!(d.source, "# hello\n");
9047 }
9048
9049 #[test]
9050 fn set_block_heading_works_in_wysiwyg_view() {
9051 // The app defaults to WYSIWYG; the caret is a source offset either way.
9052 let mut d = wysiwyg_doc("head_wys", "hello\n");
9053 d.caret = 2;
9054 d.set_block(BlockKind::Heading(1));
9055 assert_eq!(d.source, "# hello\n");
9056 }
9057
9058 #[test]
9059 fn toggle_heading_applies_switches_and_reverts() {
9060 let mut d = doc_with("head_toggle", "hello\n");
9061 d.caret = 2;
9062 d.toggle_heading(1);
9063 assert_eq!(d.source, "# hello\n"); // paragraph → H1
9064 d.toggle_heading(2);
9065 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
9066 d.toggle_heading(2);
9067 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
9068 }
9069
9070 #[test]
9071 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
9072 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
9073 // the blank line but the caret rendered on the *next* line, because the
9074 // separator was a non-navigable decoration row. In Preserve flow that
9075 // blank line is a real caret home — the caret must resolve onto it, and
9076 // typing there makes the soft break that continues the paragraph.
9077 let src = "line one:\nsecond line\n";
9078 let mut d = wysiwyg_doc("pre_enter_lineend", src);
9079 d.set_line_flow(LineFlow::Preserve);
9080 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
9081 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
9082 d.newline();
9083 d.build_visual_unwrapped();
9084 assert_eq!(d.source, "line one:\n\nsecond line\n");
9085 assert_eq!(
9086 d.caret, 10,
9087 "caret sits on the new blank line, not the next line"
9088 );
9089 // The blank line is row 1, and the caret resolves onto it — not row 2.
9090 assert_eq!(
9091 d.vmap.pos_of_offset(10),
9092 (1, 0),
9093 "caret renders on the blank row"
9094 );
9095 assert!(
9096 !d.vmap.rows[1].decoration,
9097 "the blank line is navigable in Preserve"
9098 );
9099 // Typing there makes a soft break: one paragraph, three lines.
9100 d.insert("new clause,");
9101 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
9102 }
9103
9104 #[test]
9105 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
9106 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
9107 // that keeps it one paragraph — where Fold would open a second paragraph.
9108 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
9109 d.set_line_flow(LineFlow::Preserve);
9110 d.caret = 3;
9111 d.newline();
9112 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
9113
9114 // End-of-paragraph: Enter then typing continues the same paragraph on a
9115 // new line (a soft break), not a fresh paragraph.
9116 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
9117 d.set_line_flow(LineFlow::Preserve);
9118 d.caret = 3;
9119 d.newline();
9120 d.insert("def");
9121 assert_eq!(
9122 d.source, "abc\ndef\n",
9123 "end-of-line Enter + typing is a soft break"
9124 );
9125 }
9126
9127 #[test]
9128 fn preserve_double_enter_still_makes_a_paragraph() {
9129 // Two Enters in a row promote to a real paragraph break: the second lands
9130 // on the blank line the first opened and takes the empty-line branch.
9131 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
9132 d.set_line_flow(LineFlow::Preserve);
9133 d.caret = 3;
9134 d.newline();
9135 d.newline();
9136 d.insert("def");
9137 assert_eq!(
9138 d.source, "abc\n\ndef\n",
9139 "double Enter is a paragraph break"
9140 );
9141 }
9142
9143 #[test]
9144 fn preserve_backspace_joins_across_a_soft_break() {
9145 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
9146 // soft break it deletes the single newline and joins the two lines.
9147 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
9148 d.set_line_flow(LineFlow::Preserve);
9149 d.build_visual(80);
9150 d.caret = 4; // start of "def", just past the soft break
9151 d.backspace();
9152 assert_eq!(
9153 d.source, "abcdef\n",
9154 "Backspace joins across the soft break"
9155 );
9156 assert_eq!(d.caret, 3, "caret lands where the lines meet");
9157 }
9158
9159 #[test]
9160 fn fold_enter_still_starts_a_new_paragraph() {
9161 // The default flow is unchanged: a lone `\n` would render as an invisible
9162 // space, so Enter keeps opening the paragraph break that actually shows.
9163 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9164 d.caret = 3;
9165 d.newline();
9166 assert_eq!(
9167 d.source, "abc\n\ndef\n",
9168 "Fold mid-line Enter is a paragraph break"
9169 );
9170 }
9171
9172 #[test]
9173 fn wysiwyg_one_enter_starts_a_new_paragraph() {
9174 // Regression: one Enter left the caret between the two newlines, so typing
9175 // made a soft break (one paragraph) and you needed a second Enter.
9176 let mut d = wysiwyg_doc("wys_enter", "abc\n");
9177 d.caret = 3;
9178 d.newline();
9179 d.insert("def");
9180 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9181 }
9182
9183 #[test]
9184 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9185 // Regression: Enter at the caret's natural End-of-line resting place
9186 // after a bold run with nothing following it (on screen: right after
9187 // "bold", before the hidden closing "**") spliced the paragraph break
9188 // at that very byte offset — which sits *before* the closing "**" in
9189 // the source, since the delimiter is hidden and emits no glyph of its
9190 // own for `push_row`'s "end of row" fallback to count. That severed the
9191 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9192 // "**" alone on the new line instead of leaving "**bold**" intact with
9193 // a fresh empty paragraph after it.
9194 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9195 d.move_end(false); // the WYSIWYG End key, from caret 0
9196 assert_eq!(
9197 d.caret, 6,
9198 "caret rests right after \"bold\", before the hidden \"**\""
9199 );
9200 d.newline();
9201 assert!(
9202 d.source.starts_with("**bold**"),
9203 "the closing ** must stay attached to \"bold\": got {:?}",
9204 d.source
9205 );
9206 assert_eq!(
9207 d.source, "**bold**\n\n\n",
9208 "a fresh empty paragraph follows the still-intact bold run"
9209 );
9210 }
9211
9212 #[test]
9213 fn source_view_enter_is_a_single_newline() {
9214 let mut d = doc_with("src_enter", "abc\n");
9215 d.caret = 3;
9216 d.newline();
9217 assert_eq!(d.source, "abc\n\n");
9218 }
9219
9220 #[test]
9221 fn heading_applies_at_the_end_of_a_paragraph() {
9222 // The caret at a line end sits at the doc level; set_block must still find
9223 // the block on that line.
9224 let mut d = doc_with("head_end", "abc\n");
9225 d.caret = 3; // end of "abc"
9226 d.toggle_heading(1);
9227 assert_eq!(d.source, "# abc\n");
9228 }
9229
9230 #[test]
9231 fn heading_on_an_empty_new_paragraph_creates_one() {
9232 let mut d = wysiwyg_doc("head_empty", "abc\n");
9233 d.caret = 3;
9234 d.newline(); // caret now on a fresh, empty paragraph
9235 d.toggle_heading(1);
9236 d.insert("Title");
9237 assert!(d.source.contains("# Title"), "got {:?}", d.source);
9238 }
9239
9240 #[test]
9241 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9242 // The reported bug, end to end: click a blank line with another one under
9243 // it, press H1, type. The text landed in the heading and the caret's
9244 // offset was right (the source view drew it there), but the rich view
9245 // drew it two rows lower, on the trailing blank line — the empty `# `
9246 // heading had left every row below it short by the marker's two bytes,
9247 // and the blank line ended up claiming the heading's own end offset.
9248 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9249 d.build_visual_unwrapped();
9250 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9251 d.toggle_heading(1);
9252 for c in "title".chars() {
9253 d.insert(&c.to_string());
9254 d.build_visual_unwrapped(); // as a frontend does, one frame per key
9255 }
9256 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9257 assert_eq!(
9258 d.caret_pos(),
9259 (4, 5),
9260 "the caret draws at the end of the heading"
9261 );
9262 }
9263
9264 #[test]
9265 fn clicking_an_empty_heading_types_after_its_marker() {
9266 // The same anchor from the other side: the empty heading's row is its own
9267 // caret home, so a click on it must land past the hidden `# `. Landing in
9268 // front of the hashes made the first keystroke un-heading the line.
9269 let mut d = wysiwyg_doc("head_click", "# \n");
9270 d.build_visual_unwrapped();
9271 d.caret = d.vmap.offset_of_pos(0, 0);
9272 d.insert("x");
9273 assert_eq!(d.source, "# x\n");
9274 }
9275
9276 #[test]
9277 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9278 let mut d = wysiwyg_doc("head_enter", "# Title\n");
9279 d.caret = 7; // end of the heading
9280 d.newline();
9281 d.insert("body");
9282 assert_eq!(d.source, "# Title\n\nbody\n");
9283 }
9284
9285 #[test]
9286 fn wysiwyg_enter_continues_a_bullet_list() {
9287 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9288 d.caret = 6; // end of "item"
9289 d.newline();
9290 d.insert("two");
9291 assert_eq!(d.source, "- item\n- two\n");
9292 }
9293
9294 #[test]
9295 fn wysiwyg_enter_increments_an_ordered_list() {
9296 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9297 d.caret = 6; // end of "one"
9298 d.newline();
9299 d.insert("two");
9300 assert_eq!(d.source, "1. one\n2. two\n");
9301 }
9302
9303 #[test]
9304 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9305 // Regression for the "extra newline" left between a list and the paragraph
9306 // below it. Enter, Enter leaves the list on a fresh empty paragraph
9307 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9308 // Backspace should then take the caret cleanly back to the end of the list
9309 // item, `- item\n\nnext`, not delete a single newline and strand it on the
9310 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9311 // no caret can land on. The map is rebuilt between keystrokes exactly as a
9312 // frontend does, since Backspace reads the stop table to place the delete.
9313 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9314 d.caret = 6; // end of "item"
9315 d.newline();
9316 d.build_visual(80);
9317 d.newline(); // leave the list onto a fresh empty paragraph
9318 d.build_visual(80);
9319 assert_eq!(
9320 d.source, "- item\n\n\n\nnext\n",
9321 "double-Enter opens the empty paragraph"
9322 );
9323 d.backspace();
9324 assert_eq!(
9325 d.source, "- item\n\nnext\n",
9326 "one Backspace collapses the whole gap"
9327 );
9328 assert_eq!(
9329 d.caret, 6,
9330 "and lands the caret back at the end of the list item"
9331 );
9332 }
9333
9334 #[test]
9335 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9336 // The stop-wise delete must not over-reach when there is no block boundary
9337 // to cross: two blank lines in a row are one caret stop apart, so pressing
9338 // Enter on an empty line and then Backspace removes exactly the one newline
9339 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9340 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9341 d.caret = 5; // the empty paragraph the first Enter already opened
9342 d.build_visual(80);
9343 d.newline();
9344 d.build_visual(80);
9345 assert_eq!(
9346 d.source, "abc\n\n\n\n",
9347 "Enter on the blank line adds one newline"
9348 );
9349 d.backspace();
9350 assert_eq!(
9351 d.source, "abc\n\n\n",
9352 "Backspace takes back exactly that one newline"
9353 );
9354 }
9355
9356 #[test]
9357 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9358 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9359 d.caret = 6; // end of the empty "- " item
9360 d.newline();
9361 d.insert("p");
9362 assert_eq!(d.source, "- a\n\np\n");
9363 }
9364
9365 #[test]
9366 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
9367 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
9368 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
9369 // not take the list-exit path (which would splice the `- ` away as if
9370 // leaving an empty item); the AST guard sends it to a normal break and
9371 // leaves the underline intact.
9372 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
9373 assert!(
9374 d.nodes().iter().any(|n| n.kind == Kind::Heading),
9375 "precondition: twig parses this as a heading, not a list",
9376 );
9377 d.caret = 7; // on the `- ` underline line
9378 d.newline();
9379 assert!(
9380 d.source.contains("- "),
9381 "the setext underline survives, not spliced away as a list item: {:?}",
9382 d.source,
9383 );
9384 }
9385
9386 #[test]
9387 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
9388 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
9389 d.caret = 7; // end of "abc" inside the fence
9390 d.newline();
9391 d.insert("def");
9392 assert_eq!(d.source, "```\nabc\ndef\n```\n");
9393 }
9394
9395 #[test]
9396 fn wysiwyg_enter_continues_a_block_quote() {
9397 // Enter opens a new *paragraph* inside the quote, not a second line of
9398 // the same one. `> quote\n> more` is a soft break, which under
9399 // `LineFlow::Fold` renders as a space — the keystroke would look like it
9400 // did nothing. The quoted blank line is what makes the break visible, and
9401 // it's the same thing Enter does in running prose.
9402 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
9403 d.caret = 7; // end of "quote"
9404 d.newline();
9405 d.insert("more");
9406 assert_eq!(d.source, "> quote\n>\n> more\n");
9407 // Still one quote, now holding two paragraphs — not a quote and a stray
9408 // line that fell out of it.
9409 let quotes = d
9410 .nodes()
9411 .iter()
9412 .filter(|n| n.kind == Kind::BlockQuote)
9413 .count();
9414 assert_eq!(quotes, 1);
9415 }
9416
9417 #[test]
9418 fn set_block_makes_a_heading_at_the_caret() {
9419 let mut d = doc_with("head", "Title\n\nbody\n");
9420 d.caret = 0;
9421 d.set_block(BlockKind::Heading(2));
9422 assert_eq!(d.source, "## Title\n\nbody\n");
9423 d.set_block(BlockKind::Paragraph);
9424 assert_eq!(d.source, "Title\n\nbody\n");
9425 }
9426
9427 // ── block containers (quote / list) ──────────────────────────────────────
9428
9429 #[test]
9430 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
9431 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
9432 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
9433 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
9434 // A caret at a line end sits at the doc level; the block is still found.
9435 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
9436 }
9437
9438 #[test]
9439 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
9440 // Every source line of the paragraph gets its own `> `, so a caret left
9441 // on its old byte offset falls one prefix per line above it too far
9442 // back — inside the markup it just asked for rather than in its word.
9443 assert_eq!(
9444 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
9445 "> aaa\n> b|bb\n> ccc\n"
9446 );
9447 }
9448
9449 #[test]
9450 fn toggle_blockquote_works_in_wysiwyg_view() {
9451 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9452 assert_eq!(
9453 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
9454 "> hel|lo\n"
9455 );
9456 assert_eq!(
9457 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
9458 "hel|lo\n"
9459 );
9460 }
9461
9462 #[test]
9463 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
9464 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
9465 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9466 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9467 // The *other* kind converts in place instead of nesting, which is what
9468 // makes the two buttons one three-state control.
9469 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9470 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9471 // Its own kind, over the only item the list holds, takes it off.
9472 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
9473 }
9474
9475 #[test]
9476 fn toggle_list_works_in_wysiwyg_view() {
9477 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9478 assert_eq!(
9479 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
9480 "1. hel|lo\n"
9481 );
9482 assert_eq!(
9483 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
9484 "- hel|lo\n"
9485 );
9486 assert_eq!(
9487 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
9488 "hel|lo\n"
9489 );
9490 }
9491
9492 #[test]
9493 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
9494 // The selection has to grow with the markup: twig takes a container off
9495 // only a range covering every block it holds, so the second press can
9496 // reverse the first only if the result is what's selected.
9497 let mut d = doc_with("list_sel", "abc\n\ndef\n");
9498 d.select_all();
9499 d.toggle_list(true);
9500 assert_eq!(d.source, "1. abc\n\n2. def\n");
9501 assert_eq!(d.selection(), Some((0, d.source.len())));
9502 d.toggle_list(true);
9503 assert_eq!(d.source, "abc\n\ndef\n");
9504 }
9505
9506 #[test]
9507 fn toggle_blockquote_nests_a_partly_covered_quote() {
9508 // twig's rule: covering only some of a container's blocks nests, because
9509 // taking the quote off would drag its uncovered siblings out with it.
9510 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
9511 d.caret = 2; // in the first quoted paragraph only
9512 d.toggle_blockquote();
9513 assert_eq!(d.source, "> > a\n>\n> b\n");
9514 }
9515
9516 #[test]
9517 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
9518 // A blank line used to be no block for twig to wrap —
9519 // `toggle_block_container` answered `NotFound` — so Quote and the list
9520 // buttons did nothing on the very line the H1 button works on, and leaf
9521 // lent twig a scratch paragraph to wrap and took it back out again.
9522 // twig 3.2.0 opens an empty container there itself, so what is left here
9523 // is where the caret lands: inside the marker that was just written.
9524 let mut d = doc_with("quote_blank", "\nabc\n");
9525 d.caret = 0;
9526 d.toggle_blockquote();
9527 assert_eq!(d.source, "> \nabc\n");
9528 assert_eq!(
9529 d.caret, 2,
9530 "the caret belongs inside the quote it just opened"
9531 );
9532 assert!(d.status.is_none(), "{:?}", d.status);
9533 assert!(d.dirty);
9534
9535 // And the paragraph below is still its own block: an empty container one
9536 // soft break from `abc` would take that paragraph into the quote with it.
9537 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
9538 d.caret = 0;
9539 d.toggle_blockquote();
9540 d.build_visual(80);
9541 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
9542
9543 // The same from the other side: a blank line directly under a paragraph
9544 // earns the blank line an empty block needs, rather than being read as a
9545 // soft break inside that paragraph.
9546 let mut d = doc_with("list_blank_below", "abc\n");
9547 d.caret = 4;
9548 d.toggle_list(false);
9549 assert_eq!(d.source, "abc\n\n- ");
9550 assert_eq!(d.caret, 7);
9551 }
9552
9553 #[test]
9554 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
9555 // The gesture the rendering fix is for. `newline` inside a quote already
9556 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
9557 // spelling — but the two marker lines it adds belonged to no node until
9558 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
9559 // just made drew as plain prose under the quote.
9560 let mut d = wysiwyg_doc("quote_enter", "> a\n");
9561 d.caret = 3; // past `a`, at the end of the quoted line
9562 d.newline();
9563 assert_eq!(d.source, "> a\n>\n> \n");
9564 d.build_visual(80);
9565 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
9566 // And the caret is on the new line, not stranded on the old one.
9567 assert_eq!(d.caret, 8);
9568 }
9569
9570 #[test]
9571 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
9572 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
9573 // now it is twig's single edit. Either way one ⌘z has to put the blank
9574 // line back rather than undoing into a half-built document.
9575 for open in [
9576 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
9577 &|d: &mut Doc| d.toggle_list(false),
9578 &|d: &mut Doc| d.toggle_list(true),
9579 ] {
9580 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
9581 d.caret = 3;
9582 open(&mut d);
9583 assert_ne!(d.source, "a\n\n\n\nb\n");
9584 d.undo();
9585 assert_eq!(d.source, "a\n\n\n\nb\n");
9586 }
9587 }
9588
9589 #[test]
9590 fn a_container_toggle_is_one_undo_step() {
9591 let mut d = doc_with("quote_undo", "hello\n");
9592 d.caret = 3;
9593 d.insert("X"); // a typing run the structural edit must not fold into
9594 d.toggle_blockquote();
9595 assert_eq!(d.source, "> helXlo\n");
9596 d.undo();
9597 assert_eq!(d.source, "helXlo\n");
9598 }
9599
9600 // ── links ────────────────────────────────────────────────────────────────
9601
9602 #[test]
9603 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
9604 let mut d = doc_with("link_sel", "word here\n");
9605 d.anchor = Some(0);
9606 d.caret = 4;
9607 d.insert_link("http://x.dev");
9608 assert_eq!(d.source, "[word](http://x.dev) here\n");
9609 // The text, not the destination — so a second press re-points the link
9610 // the first one made rather than nesting one inside it.
9611 assert_eq!(d.selected_text(), Some("word"));
9612 d.insert_link("http://y.dev");
9613 assert_eq!(d.source, "[word](http://y.dev) here\n");
9614 assert_eq!(d.selected_text(), Some("word"));
9615 }
9616
9617 #[test]
9618 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
9619 let mut d = doc_with("img_caret", "before after\n");
9620 d.caret = 7; // between "before " and "after"
9621 d.insert_image("cat.png", "a cat");
9622 assert_eq!(d.source, "before after\n");
9623 // The caret sits just past the inserted image, nothing selected.
9624 assert_eq!(d.selection(), None);
9625 assert_eq!(d.caret, 7 + "".len());
9626 }
9627
9628 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
9629 /// destination at the first space, so the `format!` this used to be wrote
9630 /// something that was not an image at all — and the reader saw the markup as
9631 /// text. twig owns the spelling now, and moves it into the angle form.
9632 #[test]
9633 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9634 let mut d = doc_with("img_space", "x\n");
9635 d.caret = 0;
9636 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9637 assert_eq!(
9638 d.source,
9639 "x\n"
9640 );
9641 // And it reads back as an image pointing at the unescaped path — the angle
9642 // brackets are spelling, not part of the destination.
9643 d.caret = 2;
9644 assert_eq!(
9645 d.image_destination_at_caret(),
9646 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9647 );
9648 }
9649
9650 /// A `)` in a caption or a filename must not close the image early.
9651 #[test]
9652 fn insert_image_escapes_a_paren_in_either_half() {
9653 let mut d = doc_with("img_paren", "x\n");
9654 d.caret = 0;
9655 d.insert_image("a)b.png", "");
9656 assert_eq!(d.source, "b.png)x\n");
9657 d.caret = 2;
9658 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9659 }
9660
9661 #[test]
9662 fn insert_image_uses_the_selection_as_alt_text() {
9663 let mut d = doc_with("img_sel", "caption here\n");
9664 d.anchor = Some(0);
9665 d.caret = 7; // "caption"
9666 d.insert_image("p.png", "ignored fallback");
9667 assert_eq!(d.source, " here\n");
9668 }
9669
9670 #[test]
9671 fn insert_image_with_no_alt_leaves_empty_brackets() {
9672 let mut d = doc_with("img_noalt", "\n");
9673 d.caret = 0;
9674 d.insert_image("logo.svg", "");
9675 assert_eq!(d.source, "\n");
9676 }
9677
9678 #[test]
9679 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9680 // The round trip is the point: it's no use writing markup the reader
9681 // can't pick up again. This is the pair that only holds from twig 2.5.1
9682 // on — before it, the one-line form went in fine and came back as a
9683 // paragraph of raw tags, publishing no media at all.
9684 let mut d = doc_with("vid_rt", "\n");
9685 d.caret = 0;
9686 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9687 assert_eq!(
9688 d.source,
9689 "<video src=\"clip.mp4\" controls>a clip</video>\n"
9690 );
9691
9692 d.build_visual(80);
9693 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9694 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9695 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9696 assert_eq!(d.vmap.media[0].alt, "a clip");
9697 }
9698
9699 #[test]
9700 fn insert_media_spells_audio_with_its_own_tag() {
9701 let mut d = doc_with("aud_rt", "\n");
9702 d.caret = 0;
9703 d.insert_media(MediaKind::Audio, "take.mp3", "");
9704 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9705 d.build_visual(80);
9706 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9707 }
9708
9709 #[test]
9710 fn insert_media_uses_the_selection_as_fallback_text() {
9711 // The same courtesy `insert_image` does with alt: select a caption,
9712 // insert, and the caption labels the thing rather than being replaced.
9713 let mut d = doc_with("vid_sel", "the talk here\n");
9714 d.anchor = Some(0);
9715 d.caret = 8; // "the talk"
9716 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9717 assert_eq!(
9718 d.source,
9719 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9720 );
9721 }
9722
9723 #[test]
9724 fn insert_media_with_an_image_kind_is_just_insert_image() {
9725 let mut d = doc_with("img_via_media", "\n");
9726 d.caret = 0;
9727 d.insert_media(MediaKind::Image, "logo.svg", "x");
9728 assert_eq!(d.source, "\n");
9729 }
9730
9731 // ── thematic breaks ─────────────────────────────────────────────────────
9732
9733 /// The node the source parses as at `caret` — what confirms an inserted
9734 /// `---` actually reads back as a rule, not stray text or a setext heading.
9735 ///
9736 /// The *narrowest* node covering the offset. Every ancestor covers it too,
9737 /// and since twig 2.8 that includes the `doc` root, which now carries a real
9738 /// span (it reported none before, so taking the first match used to land on
9739 /// the block by luck and now always answers `"doc"`).
9740 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9741 d.nodes()
9742 .into_iter()
9743 .filter(|n| n.span.start <= caret && caret < n.span.end)
9744 .min_by_key(|n| n.span.end - n.span.start)
9745 .map(|n| n.kind)
9746 }
9747
9748 #[test]
9749 fn a_task_box_toggles_at_the_caret_and_reads_back() {
9750 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9751 d.caret = 8; // inside "todo"
9752 assert_eq!(d.task_checked_at_caret(), Some(false));
9753 d.toggle_task_checked();
9754 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9755 assert_eq!(d.task_checked_at_caret(), Some(true));
9756 d.toggle_task_checked();
9757 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9758 }
9759
9760 #[test]
9761 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9762 // The whole reason `toggle_task_at` exists apart from the caret form:
9763 // ticking a box elsewhere must not move the cursor out of what's being
9764 // typed.
9765 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
9766 d.caret = 8; // inside "first"
9767 let second = d.source.find("second").unwrap();
9768 d.toggle_task_at(second);
9769 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
9770 assert_eq!(d.caret, 8, "the caret stayed in the first item");
9771 }
9772
9773 #[test]
9774 fn a_plain_item_gains_and_loses_a_box() {
9775 let mut d = doc_with("task_mint", "- plain\n");
9776 d.caret = 4;
9777 assert_eq!(d.task_checked_at_caret(), None);
9778 d.toggle_task_item();
9779 assert_eq!(d.source, "- [ ] plain\n");
9780 assert_eq!(
9781 d.task_checked_at_caret(),
9782 Some(false),
9783 "a new box arrives unticked"
9784 );
9785 d.toggle_task_item();
9786 assert_eq!(d.source, "- plain\n");
9787 }
9788
9789 #[test]
9790 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
9791 // `set checked` must not silently convert a bullet into a task — that is
9792 // `toggle_task_item`'s job, and twig refuses it here.
9793 let mut d = doc_with("task_none", "- plain\n");
9794 d.caret = 4;
9795 d.toggle_task_checked();
9796 assert_eq!(d.source, "- plain\n", "nothing written");
9797 assert!(
9798 d.status.is_some(),
9799 "the refusal should reach the status line"
9800 );
9801 }
9802
9803 #[test]
9804 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
9805 let mut d = doc_with("task_quote", "> - [ ] nested\n");
9806 d.caret = d.source.find("nested").unwrap();
9807 assert_eq!(d.task_checked_at_caret(), Some(false));
9808 d.toggle_task_checked();
9809 assert_eq!(d.source, "> - [x] nested\n");
9810 }
9811
9812 #[test]
9813 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
9814 // A rule is a block, so twig's `insert_thematic_break` alone lands it
9815 // after the whole paragraph. `split_block` parts the paragraph first and
9816 // the rule is aimed at the *first* half, which is what a rule button is
9817 // understood to do — and what leaf spelled by hand until twig grew both
9818 // halves of the gesture.
9819 let mut d = doc_with("hr_mid", "before after\n");
9820 d.caret = 7; // between "before " and "after"
9821 d.insert_thematic_break();
9822 assert_eq!(d.source, "before \n\n---\n\nafter\n");
9823 assert_eq!(d.selection(), None);
9824 assert_eq!(
9825 kind_at(&mut d, "before \n\n".len()),
9826 Some(Kind::ThematicBreak)
9827 );
9828 }
9829
9830 #[test]
9831 fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
9832 // At the end there is nothing to part, and a split there writes the
9833 // separator anyway — a blank line and the empty slot the next paragraph
9834 // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
9835 // two blank lines nothing fills. Now the rule lands after the paragraph,
9836 // where the split-and-aim was sending it regardless. Both formats, and
9837 // both shapes of a last line — terminated, and still being typed —
9838 // because the two reach the split through different doors: Markdown's
9839 // paragraph span stops before its newline, so `para\n` at 4 never split
9840 // there, but `para` at 4 did.
9841 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9842 for src in ["para\n", "para"] {
9843 let mut d = Doc::from_source(src.into(), fmt).unwrap();
9844 d.caret = 4;
9845 d.insert_thematic_break();
9846 assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
9847 assert_eq!(d.caret, d.source.len());
9848 }
9849 // Mid-document the slot sat between the rule and the next block.
9850 let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
9851 d.caret = 4;
9852 d.insert_thematic_break();
9853 assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
9854 // Trailing whitespace is nothing to part either.
9855 let mut d = Doc::from_source("para \n".into(), fmt).unwrap();
9856 d.caret = 4;
9857 d.insert_thematic_break();
9858 assert_eq!(d.source, format!("para \n\n{rule}\n"), "{fmt:?}");
9859 }
9860 }
9861
9862 #[test]
9863 fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
9864 // The split at the start parts nothing, but it is kept on purpose:
9865 // `|para` becomes `\npara` with the caret on a blank line, and twig
9866 // (3.5.2) writes a rule aimed at a blank line ON that line — the only
9867 // way "before the paragraph" is reachable through a gesture that only
9868 // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
9869 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9870 let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
9871 d.caret = 0;
9872 d.insert_thematic_break();
9873 assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
9874 let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
9875 d.caret = 6;
9876 d.insert_thematic_break();
9877 assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
9878 }
9879 }
9880
9881 #[test]
9882 fn insert_thematic_break_on_a_blank_line_takes_that_line() {
9883 // The gap between two blocks is where a click lands the caret; the
9884 // rule goes on the blank, one blank each side.
9885 let mut d = doc_with("hr_gap", "a\n\nb\n");
9886 d.caret = 2;
9887 d.insert_thematic_break();
9888 assert_eq!(d.source, "a\n\n---\n\nb\n");
9889 }
9890
9891 #[test]
9892 fn insert_table_at_a_paragraph_s_end_splits_nothing() {
9893 // The same door as the rule's, through the placement they share.
9894 let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9895 d.caret = 4;
9896 d.insert_table(1, 1);
9897 assert_eq!(d.source, "para\n\n| |\n|---|\n| |\n");
9898 let mut d = doc_with("table_end_typed", "para");
9899 d.caret = 4;
9900 d.insert_table(1, 1);
9901 assert_eq!(d.source, "para\n\n| |\n| --- |\n| |\n");
9902 assert!(d.caret_in_table());
9903 }
9904
9905 #[test]
9906 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
9907 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
9908 // and leaf wrote the first into both until twig started spelling it.
9909 let mut md = doc_with("hr_md", "para\n");
9910 md.caret = 2;
9911 md.insert_thematic_break();
9912 assert_eq!(md.source, "pa\n\n---\n\nra\n");
9913
9914 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9915 dj.caret = 2;
9916 dj.insert_thematic_break();
9917 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
9918 }
9919
9920 #[test]
9921 fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
9922 // The table goes *at* the caret the way the rule does: the paragraph is
9923 // parted first, and twig writes the grid after its first half. The
9924 // caret then sits in the first header cell — selected, as Tab would
9925 // leave it — so the next keystroke is the heading.
9926 let mut d = doc_with("table_mid", "before after\n");
9927 d.caret = 7;
9928 d.insert_table(2, 3);
9929 assert_eq!(
9930 d.source,
9931 "before \n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nafter\n"
9932 );
9933 assert!(d.caret_in_table());
9934 let first_bar = d.source.find('|').unwrap();
9935 assert!(
9936 d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
9937 "caret {} is not in the header row",
9938 d.caret
9939 );
9940 d.insert("Name");
9941 assert!(d.source.starts_with("before \n\n| Name | | |\n"));
9942 // And the grid the table was written into is one the table keys walk
9943 // (over the map a frontend rebuilds after every edit).
9944 d.build_visual(80);
9945 assert!(d.cell_tab(true));
9946 d.insert("Qty");
9947 assert!(d.source.starts_with("before \n\n| Name | Qty | |\n"));
9948 }
9949
9950 #[test]
9951 fn insert_table_spells_the_grid_the_format_s_own_way() {
9952 // Djot's delimiter row is unpadded, and leaf never has to know that.
9953 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9954 dj.caret = 2;
9955 dj.insert_table(1, 2);
9956 assert_eq!(dj.source, "pa\n\n| | |\n|---|---|\n| | |\n\nra\n");
9957 assert!(dj.caret_in_table());
9958 }
9959
9960 #[test]
9961 fn insert_table_refuses_where_the_format_spells_no_table() {
9962 let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
9963 d.caret = 4;
9964 d.insert_table(1, 1);
9965 assert_eq!(d.source, "<p>ab</p>\n");
9966 assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
9967 assert!(!d.capabilities().table);
9968 }
9969
9970 #[test]
9971 fn insert_table_reports_a_zero_shape_and_writes_nothing() {
9972 let mut d = doc_with("table_zero", "para\n");
9973 d.caret = 2;
9974 d.insert_table(0, 2);
9975 assert_eq!(d.source, "para\n");
9976 assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
9977 }
9978
9979 #[test]
9980 fn clicking_below_a_final_thematic_break_can_type_after_it() {
9981 let mut d = wysiwyg_doc("hr_final_click", "---\n");
9982 d.build_visual(80);
9983 d.click(d.vmap.num_rows() + 2, 0, false);
9984 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
9985 d.insert("after");
9986 assert_eq!(d.source, "---\nafter");
9987 }
9988
9989 #[test]
9990 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
9991 // The same bytes are two documents. In Markdown ` - b` is a nested item
9992 // and the next one belongs beside it, at its indent. In Djot a list
9993 // marker can't interrupt a paragraph, so those bytes are literal text in
9994 // item `a` and there is only one item — writing ` - ` under it would add
9995 // no item at all, just more text, and the new sibling has to go to
9996 // column zero. Both spellings come out of the *enclosing item's* line.
9997 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
9998 md.caret = "- a\n - b".len();
9999 md.newline();
10000 assert_eq!(md.source, "- a\n - b\n - \n");
10001 assert_eq!(list_items(&mut md), 3);
10002
10003 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
10004 dj.view = View::Wysiwyg;
10005 dj.build_visual(80);
10006 dj.caret = "- a\n - b".len();
10007 dj.newline();
10008 assert_eq!(dj.source, "- a\n - b\n- \n");
10009 assert_eq!(list_items(&mut dj), 2);
10010
10011 // Where Djot's nesting is real — opened by a blank line — the indent is
10012 // reproduced there too, and the two formats agree again.
10013 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
10014 dj.view = View::Wysiwyg;
10015 dj.build_visual(80);
10016 dj.caret = "- a\n\n - b".len();
10017 dj.newline();
10018 assert_eq!(dj.source, "- a\n\n - b\n - \n");
10019 assert_eq!(list_items(&mut dj), 3);
10020 }
10021
10022 #[test]
10023 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
10024 // Tab replaces the line's whole prefix with the one twig spells, so the
10025 // quote markers, the parent's indent and an ordered marker's extra
10026 // column are all its answer rather than leaf's arithmetic.
10027 for (name, body, caret, want) in [
10028 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
10029 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
10030 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
10031 // A checkbox is markup the item's own text wraps past, but a nested
10032 // list may only open at the *list* marker's column — four in from
10033 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
10034 // parses as one item, not two.
10035 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
10036 (
10037 "quoted task",
10038 "> - [ ] a\n> - [ ] b\n",
10039 18,
10040 "> - [ ] a\n> - [ ] b\n",
10041 ),
10042 ] {
10043 let mut doc = wysiwyg_doc(name, body);
10044 doc.caret = caret;
10045 doc.indent();
10046 assert_eq!(doc.source, want, "{name}");
10047 // The nesting is real, not just indented text.
10048 assert_eq!(list_items(&mut doc), 2, "{name}");
10049 }
10050 }
10051
10052 #[test]
10053 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
10054 // The same bytes, the two formats disagreeing, and a gesture that used
10055 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
10056 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
10057 // so those bytes are literal text inside item `a` — there is nothing to
10058 // outdent, and treating them as a marker turned one item into two, a
10059 // structural edit from a keystroke that should delete one character.
10060 //
10061 // twig's `line_prefix` is what tells them apart: it reports the marker
10062 // on the Markdown line and nothing on the Djot one, which is a
10063 // continuation. No byte scan can reach that answer.
10064 let src = "- a\n - b\n";
10065 let at = "- a\n - ".len();
10066
10067 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
10068 md.view = View::Wysiwyg;
10069 md.build_visual(80);
10070 md.caret = at;
10071 md.backspace();
10072 assert_eq!(md.source, "- a\n- b\n");
10073 assert_eq!(list_items(&mut md), 2);
10074
10075 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
10076 dj.view = View::Wysiwyg;
10077 dj.build_visual(80);
10078 dj.caret = at;
10079 dj.backspace();
10080 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
10081 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
10082 }
10083
10084 #[test]
10085 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
10086 // Leaf used to spell the next item from the marker bytes it scanned, and
10087 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
10088 // and dropped out of the checklist. twig reproduces the whole
10089 // continuation, and a fresh item is always unticked however the one above
10090 // it stands.
10091 for (name, body, want) in [
10092 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
10093 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
10094 ] {
10095 let mut doc = wysiwyg_doc(name, body);
10096 doc.caret = body.trim_end_matches('\n').len();
10097 doc.newline();
10098 assert_eq!(doc.source, want, "{name}");
10099 // Both items are checklist items — the new one is a box, not the
10100 // plain bullet the old marker scan left behind — and it is unticked
10101 // whichever way the one above it faces.
10102 let boxes: Vec<Option<bool>> = doc
10103 .nodes()
10104 .iter()
10105 .filter(|n| n.kind == Kind::TaskListItem)
10106 .map(|n| n.checked)
10107 .collect();
10108 assert_eq!(boxes.len(), 2, "{name}");
10109 assert_eq!(boxes[1], Some(false), "{name}");
10110 }
10111 }
10112
10113 #[test]
10114 fn a_split_takes_the_space_the_caret_was_in_front_of() {
10115 // Splicing a break at the caret strands the space the words were parted
10116 // at on the head of the second block, where it reads as an indent nobody
10117 // typed. twig's split consumes it.
10118 for (name, body, caret, want) in [
10119 ("para", "one two\n", 3, "one\n\ntwo\n"),
10120 ("item", "- one two\n", 5, "- one\n- two\n"),
10121 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
10122 // A heading takes leaf's own path, which has to match.
10123 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
10124 ] {
10125 let mut doc = wysiwyg_doc(name, body);
10126 doc.caret = caret;
10127 doc.newline();
10128 assert_eq!(doc.source, want, "{name}");
10129 }
10130 }
10131
10132 #[test]
10133 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
10134 // The one place leaf keeps its own break: `split_block` repeats the `#`,
10135 // and Enter after a title is how the body under it is asked for.
10136 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
10137 doc.caret = "# Title".len();
10138 doc.newline();
10139 doc.insert("body");
10140 assert_eq!(doc.source, "# Title\n\nbody\n");
10141 assert_eq!(
10142 doc.nodes()
10143 .iter()
10144 .filter(|n| n.kind == Kind::Heading)
10145 .count(),
10146 1
10147 );
10148 }
10149
10150 #[test]
10151 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
10152 // A quoted item's marker doesn't open its line, so a scan that starts at
10153 // column zero finds a `>` where it wanted a bullet, calls the line "not a
10154 // list" and hands Enter to the plain-quote branch — which writes `> ` and
10155 // drops the list. The next item has to carry the whole prefix.
10156 for (name, body, want) in [
10157 ("flat", "> - a\n", "> - a\n> - \n"),
10158 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10159 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10160 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
10161 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
10162 ] {
10163 let mut doc = wysiwyg_doc(name, body);
10164 doc.caret = body.trim_end_matches('\n').len();
10165 doc.newline();
10166 assert_eq!(doc.source, want, "{name}");
10167 // The marker isn't just spelled right, it parses as an item.
10168 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
10169 }
10170 }
10171
10172 #[test]
10173 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
10174 // Double-Enter exits the list. Unquoted that means a blank line, but a
10175 // *bare* blank line would end the quote too and drop the caret out of it,
10176 // so the separator keeps its `>` and the caret's line keeps its `> `.
10177 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
10178 doc.caret = "> - a\n> - ".len();
10179 doc.newline();
10180 assert_eq!(doc.source, "> - a\n>\n> \n");
10181 assert_eq!(list_items(&mut doc), 1);
10182 // What "still in the quote" means for the next keystroke: the caret sits
10183 // behind the prefix, and what's typed there lands inside the quote as a
10184 // paragraph of its own — not as more of item `a`.
10185 doc.insert("x");
10186 assert_eq!(doc.source, "> - a\n>\n> x\n");
10187 assert!(
10188 doc.editor
10189 .ancestors_at(doc.caret - 1)
10190 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
10191 );
10192 }
10193
10194 #[test]
10195 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
10196 // The marker is hidden block markup, so Backspace over it is structural —
10197 // but only the marker is the list's. Splicing from the line start would
10198 // take the `>` with it and silently unquote the line.
10199 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
10200 doc.caret = "> - ".len();
10201 doc.backspace();
10202 assert_eq!(doc.source, "> a\n");
10203 assert_eq!(list_items(&mut doc), 0);
10204
10205 // A nested one outdents instead, moving the bullet within the quote
10206 // rather than moving the quote.
10207 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
10208 doc.caret = "> - a\n> - ".len();
10209 doc.backspace();
10210 assert_eq!(doc.source, "> - a\n> - b\n");
10211 assert_eq!(list_items(&mut doc), 2);
10212 }
10213
10214 #[test]
10215 fn only_a_bare_paragraph_is_parted_around_the_caret() {
10216 // The split is deliberately narrow. Parting a fenced block would leave
10217 // two fences with a rule between them, and parting a list item would
10218 // mint an item nobody asked for on the way to a rule that lands after
10219 // the list either way — so both keep the whole block intact and take the
10220 // rule after it. A caret in a quote is likewise left alone.
10221 for (name, body, caret, want) in [
10222 (
10223 "code",
10224 "```\nfn x() {}\n```\n",
10225 8,
10226 "```\nfn x() {}\n```\n\n---\n",
10227 ),
10228 ("list", "- one two\n", 6, "- one two\n\n---\n"),
10229 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
10230 ] {
10231 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
10232 d.caret = caret;
10233 d.insert_thematic_break();
10234 assert_eq!(d.source, want, "{name}: the block should stay whole");
10235 }
10236 }
10237
10238 #[test]
10239 fn insert_thematic_break_replaces_the_selection() {
10240 // Now that the rule lands *at* the caret again, replacing the selection
10241 // is coherent once more: the text goes, and the rule takes its place.
10242 // The space the deletion left leading the second half is consumed by the
10243 // split rather than opening the new paragraph with it.
10244 let mut d = doc_with("hr_sel", "one two three\n");
10245 d.anchor = Some(4);
10246 d.caret = 7; // "two"
10247 d.insert_thematic_break();
10248 assert_eq!(d.source, "one \n\n---\n\nthree\n");
10249 assert_eq!(d.selection(), None);
10250 }
10251
10252 #[test]
10253 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
10254 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
10255 // because writing `---` into one is code, not a rule — twig now walks out
10256 // to the block that owns the caret's line, so there is nothing to refuse.
10257 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
10258 code.caret = 5; // inside the fenced code
10259 code.insert_thematic_break();
10260 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
10261 assert_eq!(code.status, None, "no refusal to report any more");
10262
10263 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
10264 table.caret = 3; // in the header row
10265 table.insert_thematic_break();
10266 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
10267 }
10268
10269 #[test]
10270 fn insert_thematic_break_in_a_list_item_ends_the_list() {
10271 // The un-indented rule cannot continue the list, so it closes the list
10272 // and lands at the top level rather than nested inside it.
10273 let mut d = doc_with("hr_list", "- one\n- two\n");
10274 d.caret = "- one\n- tw".len(); // mid "two"
10275 d.insert_thematic_break();
10276 d.build_visual(80);
10277 let rule_at = d.source.find("---").unwrap();
10278 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10279 assert!(
10280 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
10281 && n.span.start <= rule_at
10282 && rule_at < n.span.end),
10283 "the rule must not be nested inside the list"
10284 );
10285 }
10286
10287 #[test]
10288 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
10289 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
10290 // which is the document the gesture was actually asked for.
10291 let mut d = doc_with("hr_quote", "> hello\n");
10292 d.caret = 4; // inside the quoted text
10293 d.insert_thematic_break();
10294 assert_eq!(d.source, "> hello\n>\n> ---\n");
10295 d.build_visual(80);
10296 let rule_at = d.source.find("---").unwrap();
10297 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10298 assert!(
10299 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
10300 && n.span.start <= rule_at
10301 && rule_at < n.span.end),
10302 "the rule belongs to the quote it was asked for"
10303 );
10304 }
10305
10306 // ── typing against a block picture ────────────────────────────────────────
10307
10308 /// A rendered-view document with the caret parked on one of the picture's two
10309 /// stops, and the map already built — the state a frontend is in between
10310 /// drawing a frame and the next keystroke.
10311 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
10312 let mut d = doc_in(View::Wysiwyg, name, src);
10313 d.build_visual_unwrapped();
10314 let start = src.find("".len(),
10318 };
10319 d
10320 }
10321
10322 /// The block media the map publishes, after rebuilding it — "is this still a
10323 /// picture, or has it become a line of text with an image in it?"
10324 fn media_count(d: &mut Doc) -> usize {
10325 d.build_visual_unwrapped();
10326 d.vmap.media.len()
10327 }
10328
10329 #[test]
10330 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
10331 // The accident this prevents: tap the blank page under a photo (which
10332 // lands on the picture's trailing stop), type, and `xy` is a
10333 // paragraph with an *inline* image — the photo stops being drawn.
10334 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
10335 d.insert("xy");
10336 assert_eq!(d.source, "hi\n\n\n\nxy\n");
10337 assert_eq!(media_count(&mut d), 1, "still a picture");
10338 }
10339
10340 #[test]
10341 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
10342 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
10343 d.insert("xy");
10344 assert_eq!(d.source, "hi\n\nxy\n\n\n");
10345 assert_eq!(media_count(&mut d), 1);
10346 }
10347
10348 #[test]
10349 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
10350 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
10351 d.insert("x");
10352 assert_eq!(d.source, "x\n\n\n");
10353 assert_eq!(media_count(&mut d), 1);
10354 }
10355
10356 #[test]
10357 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
10358 // The opened paragraph is part of the keystroke, not an edit the writer
10359 // made — so it undoes with the character, not a step later.
10360 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
10361 d.insert("x");
10362 assert_eq!(d.source, "hi\n\n\n\nx\n");
10363 d.undo();
10364 assert_eq!(d.source, "hi\n\n\n");
10365 }
10366
10367 #[test]
10368 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
10369 // ⌘V dissolves the picture exactly as a keystroke does.
10370 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
10371 d.paste("pasted");
10372 assert_eq!(d.source, "hi\n\n\n\npasted\n");
10373 assert_eq!(media_count(&mut d), 1);
10374 }
10375
10376 #[test]
10377 fn typing_beside_an_inline_image_is_ordinary_editing() {
10378 // An inline image has no placeholder row and no stops of its own. Opening
10379 // a paragraph mid-sentence would be the bug, not the fix.
10380 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
10381 d.build_visual_unwrapped();
10382 d.caret = "see ".len();
10383 d.insert("!");
10384 assert_eq!(d.source, "see ! here\n");
10385 }
10386
10387 #[test]
10388 fn source_view_types_raw_markup_against_an_image_untouched() {
10389 // Source view is for writing the markup itself; a break inserted behind
10390 // the writer's back there would be the editor arguing with them.
10391 let mut d = doc_in(View::Source, "pic_src", "\n");
10392 d.caret = "".len();
10393 d.insert("x");
10394 assert_eq!(d.source, "x\n");
10395 }
10396
10397 #[test]
10398 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
10399 // A selection is replaced, not joined into, so there is nothing to
10400 // protect: the range takes the picture with it.
10401 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
10402 d.anchor = Some(d.caret);
10403 d.caret = d.source.find("".len();
10404 d.insert("x");
10405 assert_eq!(d.source, "hi\n\nx\n");
10406 }
10407
10408 #[test]
10409 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
10410 // What this actually cost: a real vault's photo, to one stray Backspace.
10411 // The caret past `` was deleting the closing paren — invisible
10412 // in the rendered view — and the photo became the text `\n", MediaStop::After);
10414 d.backspace();
10415 assert_eq!(d.source, "hi\n");
10416 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
10417 d.undo();
10418 assert_eq!(
10419 d.source, "hi\n\n\n",
10420 "and comes back in one piece"
10421 );
10422 }
10423
10424 #[test]
10425 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
10426 // Deleting the break here would join the picture to the paragraph above,
10427 // where it is an *inline* image and stops being drawn. Step over the
10428 // boundary; the next press deletes in the paragraph the caret reached.
10429 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
10430 d.backspace();
10431 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
10432 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
10433 d.backspace();
10434 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
10435 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
10436 }
10437
10438 #[test]
10439 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
10440 // The mirror. A byte-step here eats the `!` and leaves a link.
10441 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
10442 d.delete_forward();
10443 assert_eq!(d.source, "hi\n\nbye\n");
10444 assert_eq!(media_count(&mut d), 0);
10445 }
10446
10447 #[test]
10448 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
10449 let mut d = doc_at_picture(
10450 "pic_del_after",
10451 "hi\n\n\n\nbye\n",
10452 MediaStop::After,
10453 );
10454 d.delete_forward();
10455 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
10456 assert_eq!(
10457 d.caret,
10458 d.source.find("bye").unwrap(),
10459 "the caret stepped down to `bye`"
10460 );
10461 }
10462
10463 #[test]
10464 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
10465 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
10466 d.backspace();
10467 assert_eq!(d.source, "\n");
10468 assert_eq!(media_count(&mut d), 0);
10469 }
10470
10471 #[test]
10472 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
10473 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
10474 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
10475 d.delete_word_back();
10476 assert_eq!(d.source, "hi there\n");
10477
10478 // And in front of one it runs *through* the paragraph break into the
10479 // prose above, which merges the picture inline — so it steps out first,
10480 // and the second press deletes the word it was aimed at.
10481 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
10482 d.delete_word_back();
10483 assert_eq!(d.source, "hi there\n\n\n");
10484 d.delete_word_back();
10485 assert_eq!(
10486 d.source, "hi \n\n\n",
10487 "the word above went, the picture stayed"
10488 );
10489 assert_eq!(media_count(&mut d), 1);
10490 }
10491
10492 #[test]
10493 fn source_view_deletes_raw_markup_against_an_image_untouched() {
10494 let mut d = doc_in(View::Source, "pic_src_del", "\n");
10495 d.caret = "".len();
10496 d.backspace();
10497 assert_eq!(d.source, ";
10498 }
10499
10500 #[test]
10501 fn image_destination_at_caret_reads_the_image_under_the_caret() {
10502 let mut d = doc_with("img_read", "\n");
10503 d.caret = 3; // inside the image markup
10504 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
10505 // Past the image, the caret is in no image.
10506 d.caret = "".len();
10507 assert_eq!(d.image_destination_at_caret(), None);
10508 }
10509
10510 #[test]
10511 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
10512 // The image is one placeholder row by default, and `set_media_rows` grows
10513 // it to the height the frontend measured: the label row plus blank
10514 // `decoration` fillers that hold the vertical space a raster is drawn into.
10515 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
10516 assert_eq!(d.vmap.media.len(), 1);
10517 let img_row = d.vmap.media[0].rows_span.start;
10518 assert_eq!(
10519 d.vmap.media[0].rows_span,
10520 img_row..img_row + 1,
10521 "default is one row"
10522 );
10523
10524 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
10525 d.build_visual(80);
10526 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
10527 let span = d.vmap.media[0].rows_span.clone();
10528 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
10529 // The label row carries the mark and its glyphs; the three below are blank
10530 // decoration — drawn, but no caret and no text.
10531 assert!(
10532 d.vmap.rows[span.start].media.is_some(),
10533 "mark rides the first row"
10534 );
10535 for r in (span.start + 1)..span.end {
10536 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
10537 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
10538 assert!(
10539 d.vmap.rows[r].media.is_none(),
10540 "only the first row is marked"
10541 );
10542 }
10543 }
10544
10545 #[test]
10546 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
10547 // The extra rows are pure spacers: the caret's only homes stay the stop in
10548 // front of the image and the one just past it, so walking the document top
10549 // to bottom visits the same offsets whether the image is 1 row or 5.
10550 let body = "ab\n\n\n\ncd\n";
10551 let stops_at = |rows: usize| -> Vec<usize> {
10552 let mut d = wysiwyg_doc("img_stops", body);
10553 if rows > 1 {
10554 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
10555 d.build_visual(80);
10556 }
10557 d.caret = 0;
10558 let mut seen = vec![d.caret];
10559 loop {
10560 d.move_right(false);
10561 if *seen.last().unwrap() == d.caret {
10562 break;
10563 }
10564 seen.push(d.caret);
10565 }
10566 seen
10567 };
10568 assert_eq!(
10569 stops_at(1),
10570 stops_at(5),
10571 "reserving rows must not add stops"
10572 );
10573 }
10574
10575 #[test]
10576 fn insert_link_repoints_the_link_at_a_bare_caret() {
10577 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
10578 d.caret = 3; // in the link's text, nothing selected
10579 d.insert_link("http://y.dev");
10580 assert_eq!(d.source, "[word](http://y.dev)\n");
10581 assert_eq!(d.selected_text(), Some("word"));
10582 }
10583
10584 #[test]
10585 fn insert_link_on_an_empty_range_autolinks_a_url() {
10586 // A link with no text of its own is an autolink, and twig spells it —
10587 // `<…>` is the canonical form and needs no text typed into it, so the
10588 // caret lands after it rather than selecting a finished link.
10589 let mut d = doc_with("link_empty", "\n");
10590 d.caret = 0;
10591 d.insert_link("http://x.dev");
10592 assert_eq!(d.source, "<http://x.dev>\n");
10593 assert_eq!(d.selection(), None);
10594 assert_eq!(d.caret, 14);
10595 }
10596
10597 #[test]
10598 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
10599 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
10600 // in Markdown, so a destination that can't autolink doubles as the text
10601 // instead — which is then selected, ready to be typed over.
10602 let mut d = doc_with("link_rel", "\n");
10603 d.caret = 0;
10604 d.insert_link("./notes.md");
10605 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
10606 assert_eq!(d.selection(), Some((1, 11)));
10607 d.insert("Notes");
10608 assert_eq!(d.source, "[Notes](./notes.md)\n");
10609 }
10610
10611 #[test]
10612 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
10613 // The autolink's text is its URL, so re-pointing replaces the whole
10614 // node — the caret must not splice a second link inside the first.
10615 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
10616 d.caret = 10;
10617 d.insert_link("https://y.dev");
10618 assert_eq!(d.source, "see <https://y.dev> ok\n");
10619 }
10620
10621 #[test]
10622 fn code_language_reads_and_edits_through_the_fence() {
10623 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
10624 d.caret = 10; // inside the code body
10625 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10626 assert!(d.caret_in_fenced_code());
10627
10628 d.set_code_language("python");
10629 assert!(
10630 d.source.starts_with("```python\n"),
10631 "source: {:?}",
10632 d.source
10633 );
10634 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10635
10636 // Clearing it leaves a bare fence and no label.
10637 d.set_code_language("");
10638 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10639 assert_eq!(d.code_language_at_caret(), None);
10640
10641 // A caret outside any code block edits nothing.
10642 let mut p = doc_with("code_lang_none", "just prose\n");
10643 assert!(!p.caret_in_fenced_code());
10644 p.set_code_language("rust");
10645 assert_eq!(p.source, "just prose\n");
10646 }
10647
10648 #[test]
10649 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10650 // Markdown's info string ends at whitespace, so `two words` would write
10651 // a fence that reads back with a different language than the one asked
10652 // for. twig refuses it; leaf reports that and leaves the source alone.
10653 // The old splice trimmed the ends and wrote whatever was left.
10654 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10655 d.caret = 10;
10656 d.set_code_language("two words");
10657 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10658 assert!(d.status.is_some(), "the refusal should be reported");
10659 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10660 }
10661
10662 #[test]
10663 fn link_destination_at_caret_reads_both_spellings() {
10664 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10665 d.caret = 5;
10666 assert_eq!(
10667 d.link_destination_at_caret().as_deref(),
10668 Some("https://x.dev")
10669 );
10670 d.caret = 0;
10671 assert_eq!(d.link_destination_at_caret(), None);
10672
10673 // An autolink has no `destination`; its text is the URL.
10674 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10675 a.caret = 10;
10676 assert_eq!(
10677 a.link_destination_at_caret().as_deref(),
10678 Some("https://x.dev")
10679 );
10680 a.caret = 21;
10681 assert_eq!(a.link_destination_at_caret(), None);
10682 }
10683
10684 #[test]
10685 fn locate_finds_the_block_a_declared_id_names() {
10686 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10687 // verse. The locator has to land on the *verse*, which is the whole
10688 // reason a link carries one.
10689 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10690 {#v2}\nYea, I make a record in the language of my father.\n";
10691 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10692 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10693 assert_eq!(
10694 d.source[v2.start..v2.end].trim_end(),
10695 "Yea, I make a record in the language of my father."
10696 );
10697 // The attribute line is not part of it: `start` is a place to put a
10698 // caret, and `{#v2}` is markup the caret has no business landing in.
10699 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10700 assert_eq!(d.locate("v99"), None);
10701 }
10702
10703 #[test]
10704 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10705 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10706 // Markdown heading is literal text. So `#the-second-part` can only be
10707 // the heading's own words, which is the rule every Markdown renderer
10708 // already follows and therefore the one a link was authored against.
10709 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10710 let mut d = doc_with("locate_md", src);
10711 let hit = d.locate("the-second-part").expect("the heading's slug");
10712 assert!(d.source[hit.start..].starts_with("## The Second Part"));
10713 // Bounded by the next heading that isn't under it, so a peek shows the
10714 // section rather than only its title.
10715 assert_eq!(
10716 &d.source[hit.start..hit.end],
10717 "## The Second Part\n\nbody\n\n"
10718 );
10719
10720 // A subsection does not end its parent: `# Title` runs to `## Third`'s
10721 // sibling only because there is no other `#`, so it covers the lot.
10722 let title = d.locate("title").expect("the top heading");
10723 assert_eq!(title.end, d.source.len());
10724 }
10725
10726 #[test]
10727 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10728 // djot mints `Some-Heading-Here`; a link to it is written
10729 // `#some-heading-here` by nearly everything that writes links. Both
10730 // spellings are one question.
10731 let src = "## Some Heading Here\n\nbody\n";
10732 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10733 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10734 let slugged = d.locate("some-heading-here").expect("the link's spelling");
10735 assert_eq!(exact, slugged);
10736 // The section, not the heading line — there is more to show than a title.
10737 assert_eq!(&d.source[exact.start..exact.end], src);
10738 }
10739
10740 #[test]
10741 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10742 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10743 assert_eq!(d.locate(""), None);
10744 assert_eq!(d.locate(" "), None);
10745 // All punctuation: it names nothing, and must not be read as "match the
10746 // first heading whose slug is also empty".
10747 assert_eq!(d.locate("!!!"), None);
10748 }
10749
10750 #[test]
10751 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10752 // The document's mistake, and the answer every other anchor
10753 // implementation gives — the alternative is for a link to mean whichever
10754 // of the two a walk happened to reach first.
10755 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10756 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10757 let hit = d.locate("dup").expect("the first `{#dup}`");
10758 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10759 }
10760
10761 #[test]
10762 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10763 // The button's whole job: a reference where the caret was, a definition
10764 // to give it meaning, and the caret waiting in the empty note so the
10765 // next keystroke is the note's first word.
10766 let mut d = doc_with("fn_insert", "A claim and more.\n");
10767 d.caret = 7; // just past "A claim"
10768 d.insert_footnote();
10769 assert!(
10770 d.source.starts_with("A claim[^1] and more."),
10771 "{:?}",
10772 d.source
10773 );
10774 assert!(
10775 d.source.contains("[^1]:"),
10776 "the definition too: {:?}",
10777 d.source
10778 );
10779 assert_eq!(d.status, None);
10780
10781 let reference = d.source.find("[^1]").unwrap();
10782 let note = d
10783 .footnote_at(reference + 2)
10784 .expect("the reference just written");
10785 assert_eq!(note.label, "1");
10786 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
10787 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
10788 // …and typing there is typing into the note, not near it.
10789 d.insert("the note");
10790 assert_eq!(
10791 d.footnote_at(reference + 2).and_then(|f| f.text),
10792 Some("the note".to_string())
10793 );
10794 }
10795
10796 #[test]
10797 fn insert_footnote_numbers_past_the_notes_already_written() {
10798 // A second press must not hand back a label somebody else is using: twig
10799 // reuses a defined label rather than appending a rival definition, so a
10800 // repeat of `1` would quietly point the new reference at the old note.
10801 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
10802 d.caret = 7; // past `[^1]`, before " two."
10803 d.insert_footnote();
10804 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
10805 assert_eq!(d.source.matches("[^2]:").count(), 1);
10806 }
10807
10808 #[test]
10809 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
10810 // `[^2]` with no definition is still a 2 that means something to whoever
10811 // wrote it — stepping over it would mint a note for their reference. A
10812 // word label takes no number, so it blocks none.
10813 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
10814 d.caret = d.source.find(" c").unwrap();
10815 d.insert_footnote();
10816 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
10817 assert!(
10818 d.source.starts_with("a[^2] b[^why][^1] c"),
10819 "{:?}",
10820 d.source
10821 );
10822 }
10823
10824 #[test]
10825 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
10826 // A reference annotates the words before it. Consuming the selection —
10827 // which is what an insert normally does — would delete the very claim
10828 // the author selected in order to footnote.
10829 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
10830 d.anchor = Some(2);
10831 d.caret = 7; // "claim" selected
10832 d.insert_footnote();
10833 assert!(
10834 d.source.starts_with("A claim[^1] and more."),
10835 "{:?}",
10836 d.source
10837 );
10838 }
10839
10840 #[test]
10841 fn a_note_just_written_still_knows_where_its_reference_is() {
10842 // The authoring loop in one test: press the button, type the note, ask to
10843 // go back. The caret ends at the note's last byte — which is the *end* of
10844 // the definition's span, the one offset the query used to exclude — so
10845 // this is where the round trip either works or doesn't.
10846 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
10847 d.caret = 7;
10848 d.insert_footnote();
10849 d.insert("the note");
10850 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
10851 let back = d
10852 .footnote_definition_at_caret()
10853 .expect("still in the note we just typed");
10854 assert_eq!(back.label, "1");
10855 // …and following it lands on the reference's label, where a reader's
10856 // return leg lands.
10857 assert_eq!(back.offset, Some(9));
10858 assert_eq!(&d.source[9..10], "1");
10859 }
10860
10861 #[test]
10862 fn insert_footnote_takes_one_undo_for_both_halves() {
10863 // twig writes the pair as a single edit; the point of that is here.
10864 let before = "A claim and more.\n";
10865 let mut d = doc_with("fn_insert_undo", before);
10866 d.caret = 7;
10867 d.insert_footnote();
10868 assert_ne!(d.source, before);
10869 d.undo();
10870 assert_eq!(d.source, before, "one undo takes back both halves");
10871 }
10872
10873 #[test]
10874 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
10875 // HTML is authorable — it spells the inline marks — and has no footnote.
10876 // The refusal says so rather than writing brackets that would render as
10877 // brackets.
10878 let src = "<p>A claim.</p>\n";
10879 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
10880 assert!(!Capabilities::of(Format::Html).footnote);
10881 d.caret = 5;
10882 d.insert_footnote();
10883 assert_eq!(d.source, src, "nothing written");
10884 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
10885 }
10886
10887 #[test]
10888 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
10889 // The empty body is the one place this could go wrong: the definition
10890 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
10891 // byte early would draw up in the paragraph above the note it belongs to.
10892 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
10893 d.place_caret(7, false);
10894 d.insert_footnote();
10895 d.build_visual(80); // the frame a frontend draws after the edit
10896 assert_eq!(
10897 d.vmap.snap_to_stop(d.caret),
10898 d.caret,
10899 "the caret sits on a stop"
10900 );
10901 let (row, _) = d.caret_pos();
10902 assert!(
10903 drawn_rows(&d)[row].contains("[1]"),
10904 "the caret is on the note's row, not above it: {:?}",
10905 drawn_rows(&d)
10906 );
10907 }
10908
10909 #[test]
10910 fn footnote_at_caret_resolves_a_reference_to_its_note() {
10911 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
10912 // blank line, as one has to.
10913 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
10914 d.caret = 9;
10915 let f = d
10916 .footnote_at_caret()
10917 .expect("the caret stands in a reference");
10918 assert_eq!(f.label, "1");
10919 assert_eq!(f.text.as_deref(), Some("the note"));
10920 // The offset points at the note's first word, not at the definition's
10921 // `[` — the marker is decoration with no caret stop on it.
10922 assert_eq!(f.offset, Some(29));
10923 assert_eq!(&d.source[29..37], "the note");
10924 // …and `end` closes the range, so a frontend can ask which rendered rows
10925 // the note occupies rather than re-deriving them from the text.
10926 assert_eq!(f.end, Some(37));
10927 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
10928 }
10929
10930 /// Two definitions in a row: each is its own note, and neither reaches into
10931 /// the other.
10932 ///
10933 /// A djot definition's span used to run past the blank line into the first
10934 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
10935 /// offsets named the *next* note's rows too, showing a reader two footnotes
10936 /// when they had asked about one. twig 3.1 ends the span after the block's
10937 /// own last line; the test outlives the workaround leaf carried for it.
10938 #[test]
10939 fn footnote_at_stops_a_note_at_the_definition_after_it() {
10940 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
10941 for format in [Format::Markdown, Format::Djot] {
10942 let mut d = Doc::from_source(src.to_string(), format).unwrap();
10943 d.caret = 7;
10944 let f = d.footnote_at_caret().expect("a reference");
10945 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
10946 assert_eq!(
10947 &src[f.offset.unwrap()..f.end.unwrap()],
10948 "first note.",
10949 "in {format:?}"
10950 );
10951 }
10952 }
10953
10954 /// The other side of that boundary: a blank line *inside* a definition is
10955 /// interior to it, and the note keeps its second paragraph.
10956 ///
10957 /// This is what the old body scan cost. It stopped at the first line not
10958 /// indented under the note — a blank line is not — so a two-paragraph note
10959 /// came back as its first paragraph, and "go to note" framed half of it.
10960 /// Reading the span twig gives is both simpler and right.
10961 #[test]
10962 fn footnote_at_keeps_a_notes_second_paragraph() {
10963 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
10964 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10965 d.caret = 7;
10966 let f = d.footnote_at_caret().expect("a reference");
10967 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
10968 // And it stops there — `After.` is the next block, not more note.
10969 assert_eq!(
10970 &src[f.offset.unwrap()..f.end.unwrap()],
10971 f.text.as_deref().unwrap()
10972 );
10973 assert!(!f.text.as_deref().unwrap().contains("After"));
10974 }
10975
10976 #[test]
10977 fn footnote_at_bounds_a_note_whose_body_is_empty() {
10978 // `[^1]:` with nothing after it. The range is empty rather than
10979 // inverted, and still points inside the definition — which is what keeps
10980 // a frontend's row lookup from walking off into the block above.
10981 let src = "A claim[^1].\n\n[^1]:\n";
10982 let mut d = doc_with("fn_empty_body", src);
10983 d.caret = 9;
10984 let f = d.footnote_at_caret().expect("a reference");
10985 assert_eq!(f.text.as_deref(), Some(""));
10986 assert_eq!(f.offset, f.end, "an empty note is an empty range");
10987 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
10988 }
10989
10990 #[test]
10991 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
10992 let mut d = doc_with(
10993 "fn_at_caret_none",
10994 "A claim[^1] and more.\n\n[^1]: the note\n",
10995 );
10996 d.caret = 2; // in the prose
10997 assert_eq!(d.footnote_at_caret(), None);
10998 }
10999
11000 #[test]
11001 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
11002 // The two are deliberately separate: a reference names a note in this
11003 // document, a link names somewhere to leave for, and answering one with
11004 // the other is what made a reference click do nothing at all.
11005 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
11006 d.caret = 3; // the `1` of `[^1]`
11007 assert!(d.footnote_at_caret().is_some());
11008 assert_eq!(
11009 d.link_destination_at_caret(),
11010 None,
11011 "a reference is not a link"
11012 );
11013
11014 d.caret = 10; // inside the link's label
11015 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
11016 assert_eq!(
11017 d.link_destination_at_caret().as_deref(),
11018 Some("https://x.dev")
11019 );
11020 }
11021
11022 #[test]
11023 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
11024 // A `[^99]` the document never defines is a real state — a note deleted
11025 // out from under its reference — and the label is what lets a frontend
11026 // say so. `None` here would be indistinguishable from "not on a
11027 // reference", which is the wrong thing to tell a reader.
11028 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
11029 d.caret = 9;
11030 let f = d
11031 .footnote_at_caret()
11032 .expect("the reference is still a reference");
11033 assert_eq!(f.label, "99");
11034 assert_eq!(f.text, None);
11035 assert_eq!(f.offset, None);
11036 }
11037
11038 #[test]
11039 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
11040 // Labels are not always numbers, and a note's body runs past its first
11041 // line — the indented continuation belongs to the note, so it comes back
11042 // with it (source bytes, verbatim, as documented).
11043 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
11044 let mut d = doc_with("fn_word_label", src);
11045 d.caret = 6;
11046 let f = d
11047 .footnote_at_caret()
11048 .expect("the caret stands in a reference");
11049 assert_eq!(f.label, "note");
11050 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
11051 }
11052
11053 #[test]
11054 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
11055 // The point of the offset form: a pointer hovering a reference asks what
11056 // note it names, and must not drag the caret along to ask.
11057 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
11058 d.caret = 0;
11059 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
11060 assert_eq!(f.label, "1");
11061 assert_eq!(f.text.as_deref(), Some("the note"));
11062 assert_eq!(d.caret, 0, "asking must not move the caret");
11063 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
11064 }
11065
11066 #[test]
11067 fn footnote_definition_at_caret_points_back_at_the_reference() {
11068 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
11069 // the caret can rest on — is at 9.
11070 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
11071 d.caret = 30; // inside the note's body
11072 let f = d
11073 .footnote_definition_at_caret()
11074 .expect("the caret stands in a definition");
11075 assert_eq!(f.label, "1");
11076 assert_eq!(f.offset, Some(9));
11077 assert_eq!(&d.source[7..11], "[^1]");
11078 }
11079
11080 #[test]
11081 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
11082 // The two legs have to meet: wherever `footnote_at` sends the caret, the
11083 // definition query must answer for — otherwise arriving at a note leaves
11084 // the reader somewhere the way back isn't offered.
11085 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
11086 let mut d = doc_with("fn_def_marker", src);
11087 let landed = d.footnote_at(9).unwrap().offset.unwrap();
11088 assert_eq!(
11089 d.footnote_definition_at(landed).and_then(|f| f.offset),
11090 Some(9),
11091 "the note a reference sends you to offers the way back"
11092 );
11093 }
11094
11095 #[test]
11096 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
11097 // The two queries answer for disjoint places, which is what lets one
11098 // gesture mean "down to the note" in one and "back up" in the other
11099 // without either having to remember which way the reader is going.
11100 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
11101 d.caret = 2; // prose
11102 assert_eq!(d.footnote_definition_at_caret(), None);
11103 d.caret = 9; // the reference
11104 assert_eq!(d.footnote_definition_at_caret(), None);
11105 assert!(
11106 d.footnote_at_caret().is_some(),
11107 "which is the reference's own query"
11108 );
11109 }
11110
11111 #[test]
11112 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
11113 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
11114 // note", which is false and leaves a frontend unable to explain why the
11115 // way back is missing.
11116 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
11117 let mut d = doc_with("fn_def_orphan", src);
11118 d.caret = src.find("orphan").unwrap();
11119 let f = d
11120 .footnote_definition_at_caret()
11121 .expect("an orphan is still a definition");
11122 assert_eq!(f.label, "2");
11123 assert_eq!(f.offset, None);
11124 }
11125
11126 #[test]
11127 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
11128 // One label, cited twice. The first is where the reader most likely came
11129 // from, and the only answer that doesn't depend on how they got here.
11130 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
11131 let mut d = doc_with("fn_def_repeat", src);
11132 d.caret = src.find("the note").unwrap();
11133 let f = d.footnote_definition_at_caret().expect("a definition");
11134 assert_eq!(
11135 f.offset,
11136 Some(5),
11137 "the first `[^a]`'s label, not the second's"
11138 );
11139 assert_eq!(&src[3..7], "[^a]");
11140 }
11141
11142 #[test]
11143 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
11144 // Down and back up, each leg found from the document rather than from a
11145 // memory of the other — so it still works for a reader who scrolled to
11146 // the notes instead of jumping there.
11147 //
11148 // `place_caret` rather than assigning `caret`, because that is what a
11149 // frontend calls: it snaps to a real caret stop, and a jump that lands
11150 // on a byte the caret can't rest on would arrive somewhere the return
11151 // leg no longer answers for. `build_map` first, since snapping is a
11152 // no-op until the map exists — which is exactly how this went unnoticed
11153 // when the offsets pointed at the `[^` markers.
11154 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
11155 d.build_map(None);
11156 d.place_caret(9, false);
11157 let down = d
11158 .footnote_at_caret()
11159 .expect("a reference")
11160 .offset
11161 .expect("a note");
11162 d.place_caret(down, false);
11163 let up = d
11164 .footnote_definition_at_caret()
11165 .expect("a definition")
11166 .offset
11167 .expect("a reference");
11168 d.place_caret(up, false);
11169 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
11170 assert_eq!(
11171 d.footnote_at_caret().expect("back on the reference").label,
11172 "1"
11173 );
11174 }
11175
11176 #[test]
11177 fn insert_link_hands_the_destination_to_twig_raw() {
11178 // Escaping is twig's, and format-specific: Markdown ends a destination
11179 // at the first space and needs the `<…>` form, where djot would read
11180 // those angle brackets as part of the URL.
11181 let mut d = doc_with("link_space", "word\n");
11182 d.anchor = Some(0);
11183 d.caret = 4;
11184 d.insert_link("a b");
11185 assert_eq!(d.source, "[word](<a b>)\n");
11186 }
11187
11188 #[test]
11189 fn insert_link_reports_a_destination_no_format_can_carry() {
11190 let mut d = doc_with("link_bad", "word\n");
11191 d.anchor = Some(0);
11192 d.caret = 4;
11193 d.insert_link("a\nb");
11194 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
11195 assert!(
11196 d.status.is_some(),
11197 "InvalidArgument should reach the status line"
11198 );
11199 assert!(!d.dirty);
11200 }
11201
11202 #[test]
11203 fn insert_link_works_in_wysiwyg_view() {
11204 let mut d = wysiwyg_doc("link_wys", "word here\n");
11205 d.anchor = Some(0);
11206 d.caret = 4;
11207 d.insert_link("http://x.dev");
11208 assert_eq!(d.source, "[word](http://x.dev) here\n");
11209 assert_eq!(d.selected_text(), Some("word"));
11210 // The map the caret has to keep riding is rebuilt each frame; motion
11211 // over the fresh one must still land on a real stop (the debug_assert).
11212 d.build_visual(80);
11213 d.move_right(false);
11214 d.move_left(false);
11215 }
11216
11217 #[test]
11218 fn click_maps_a_row_col_to_a_byte_offset() {
11219 let mut d = doc_with("click", "ab\ncd\n");
11220 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
11221 assert_eq!(d.caret, 4);
11222 }
11223
11224 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
11225 // stop just as the `(row, col)` click path does, so the caret can never come
11226 // to rest in the blank gap between two paragraphs — where it would draw in one
11227 // place and type in another.
11228 #[test]
11229 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
11230 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
11231 // caret stop (stops are 0,1,3,4).
11232 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
11233 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
11234 d.place_caret(2, false);
11235 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11236 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
11237 }
11238
11239 #[test]
11240 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
11241 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
11242 d.place_caret(0, false); // anchor at the start of "A"
11243 d.place_caret(2, true); // drag into the gap
11244 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11245 let (s, e) = d.selection().expect("a selection");
11246 assert!(
11247 d.vmap.is_stop(s) && d.vmap.is_stop(e),
11248 "selection {s}..{e} off a stop"
11249 );
11250 }
11251
11252 #[test]
11253 fn place_caret_on_a_real_stop_is_left_untouched() {
11254 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
11255 d.place_caret(3, false); // the start of "B" — a genuine stop
11256 assert_eq!(d.caret, 3);
11257 }
11258
11259 // An *empty paragraph* (two blank lines, an intentional blank line the user
11260 // opened) is a real caret stop, unlike the gap — a click into it must stay.
11261 #[test]
11262 fn place_caret_rests_in_an_empty_paragraph() {
11263 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
11264 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
11265 assert!(d.vmap.is_stop(empty));
11266 d.place_caret(empty, false);
11267 assert_eq!(d.caret, empty);
11268 }
11269
11270 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
11271 // a drag over the word `bold` ends there, and a caret placed there stays.
11272 #[test]
11273 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
11274 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
11275 let mut d = wysiwyg_doc("place_mark_end", src);
11276 let start = src.find("bold").unwrap();
11277 d.place_caret(start, false);
11278 d.place_caret(start + 4, true);
11279 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
11280 d.toggle(InlineKind::Strong);
11281 assert_eq!(d.source, src.replace("**bold**", "bold"));
11282 }
11283
11284 #[test]
11285 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
11286 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
11287 d.caret = 7; // before the `d`
11288 d.move_right(false);
11289 assert_eq!(d.caret, 8, "onto the end of the bold");
11290 assert!(d.active_inline_marks().contains(InlineKind::Strong));
11291 d.move_right(false);
11292 assert_eq!(d.caret, 10, "past the closing `**`");
11293 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
11294 d.move_left(false);
11295 assert_eq!(d.caret, 8);
11296 d.move_left(false);
11297 assert_eq!(d.caret, 7);
11298 // Typing at the inner home extends the bold.
11299 d.caret = 8;
11300 d.insert("!");
11301 assert_eq!(d.source, "a **bold!** b");
11302 }
11303
11304 #[test]
11305 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
11306 // The splice path shifts the home with the block it is in, and the
11307 // re-rendered block finds its own again.
11308 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
11309 d.build_visual_unwrapped();
11310 d.edit(0, 0, "zz");
11311 d.build_visual_unwrapped();
11312 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
11313 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11314 let at = d.source.find("bold").unwrap();
11315 d.edit(at, at, "very ");
11316 d.build_visual_unwrapped();
11317 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
11318 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11319 }
11320
11321 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
11322 doc_in(View::Wysiwyg, name, body)
11323 }
11324
11325 /// How many list items the source actually parses into — the check that a
11326 /// marker Leaf wrote is a marker the format agrees is one.
11327 fn list_items(doc: &mut Doc) -> usize {
11328 doc.editor
11329 .nodes()
11330 .unwrap()
11331 .iter()
11332 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
11333 .count()
11334 }
11335
11336 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
11337 /// incremental (`build_spliced` / `build_cached`) path must always match.
11338 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
11339 reference_map_revealing(source, None)
11340 }
11341
11342 /// [`reference_map`] with a reveal line — the ground truth for the
11343 /// `MarkupMode::Full` builds, where the map is a function of the caret's
11344 /// line as well as the text.
11345 fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
11346 // The same parse `Doc` uses. With twig's plain defaults instead, the two
11347 // sides disagree on what the *document* is before the renderer is even
11348 // reached — a bare `:word` is a text directive to one and prose to the
11349 // other — and the mismatch reads as a splice bug that isn't one.
11350 let mut ed =
11351 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
11352 let nodes = ed.nodes().unwrap();
11353 crate::wysiwyg::build(
11354 &nodes,
11355 source,
11356 None,
11357 false,
11358 &wysiwyg::Surface::default(),
11359 reveal,
11360 )
11361 }
11362
11363 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
11364 if a.rows.len() != b.rows.len() {
11365 return true;
11366 }
11367 for (ra, rb) in a.rows.iter().zip(&b.rows) {
11368 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
11369 return true;
11370 }
11371 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
11372 if ga.ch != gb.ch || ga.src != gb.src {
11373 return true;
11374 }
11375 }
11376 }
11377 false
11378 }
11379
11380 #[test]
11381 fn incremental_build_matches_a_fresh_build_across_edits() {
11382 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
11383 // fast path, gated on twig's `dirty_range`) or falls back to
11384 // `build_cached`. After each edit the map must be byte-identical to a
11385 // from-scratch build — this is the correctness net under the splice.
11386 let docs = [
11387 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
11388 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
11389 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
11390 // A footnote definition is a root beside `doc`, merged back into the
11391 // top-level list by `wysiwyg::top_blocks`. The random edits below
11392 // make and unmake definitions as they go (a deleted `:` turns one
11393 // back into a paragraph, and vice versa), which is exactly the
11394 // structural churn the splice path has to notice and bail out of.
11395 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
11396 // A comment is a top-level block that draws no rows — a layout entry
11397 // at zero rows either side of blocks that do. The edits below type
11398 // into the blocks around it (a splice past a hidden block), and
11399 // break the comment open into prose and back (a structural change).
11400 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
11401 // Link reference definitions: a hidden block that an edit can turn
11402 // into a paragraph (a deleted `:`) and back, and whose own bytes an
11403 // edit can land in.
11404 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
11405 ];
11406 // A deterministic mix: mostly single characters (which stay inside one
11407 // block → splice), plus edits that reshape structure (a paragraph break,
11408 // a heading marker, a code fence → fallback), so both paths are exercised.
11409 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
11410 for src in docs {
11411 let mut d = wysiwyg_doc("diff", src);
11412 d.build_visual_unwrapped();
11413 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
11414
11415 for step in 0..60usize {
11416 let len = d.source.len();
11417 let raw = (step * 13 + 5) % (len + 1);
11418 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11419 let pre = d.source.clone();
11420 let action;
11421 if step % 3 == 0 && pos < len {
11422 let end = (pos + 1..=len)
11423 .find(|&i| d.source.is_char_boundary(i))
11424 .unwrap();
11425 action = format!("delete [{pos},{end})");
11426 d.edit(pos, end, "");
11427 } else {
11428 let ins = inserts[step % inserts.len()];
11429 action = format!("insert {ins:?} @ {pos}");
11430 d.edit(pos, pos, ins);
11431 }
11432 d.build_visual_unwrapped();
11433 if maps_differ(&d.vmap, &reference_map(&d.source)) {
11434 panic!(
11435 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
11436 d.source
11437 );
11438 }
11439 }
11440 }
11441 }
11442
11443 /// A frontend is handed [`Doc::vmap`] and may present it differently:
11444 /// leaf-ratatui splices blank filler rows under an oversized heading so the
11445 /// raster it paints there has somewhere to stand, and leaves them in the map
11446 /// because the caret and the mouse both read it between frames. The splice
11447 /// path addresses that map by *row index*, against the block layout the last
11448 /// build recorded — so handed a map with rows in it that no block owns, it
11449 /// laid the re-rendered block over one of the fillers and carried the rows
11450 /// the block really occupied into the suffix. One stranded copy of the
11451 /// edited line, and everything below it a row further down, per keystroke.
11452 ///
11453 /// A map that isn't the one the layout describes is a map this path can't
11454 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
11455 #[test]
11456 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
11457 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
11458 d.build_visual_unwrapped();
11459
11460 // Stand in for the heading filler rows: two blank rows past the heading
11461 // that no block accounts for. Cloning a real row keeps every field
11462 // plausible — it is the row *count* the splice can't survive.
11463 let filler = d.vmap.rows[0].clone();
11464 d.vmap.rows.insert(1, filler.clone());
11465 d.vmap.rows.insert(1, filler);
11466
11467 // An edit inside the last block: the single-block case the splice path
11468 // is for, and the one the frontend hits on every keystroke.
11469 let at = d.source.len() - 1;
11470 d.edit(at, at, "!");
11471 d.build_visual_unwrapped();
11472
11473 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
11474 }
11475
11476 /// A glyph's [`FaceId`] has to mean the same thing however its row was
11477 /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
11478 /// cloned at a shifted offset, and a previous map's rows a splice kept
11479 /// untouched — and only the first of those walks a `data-font` at all. An
11480 /// index into a per-build table would have had the same glyph naming two
11481 /// families the moment a second one appeared; the id is the name's own
11482 /// hash, so nothing is remapped and the table is merged rather than rebuilt.
11483 ///
11484 /// Two families, because one cannot tell a wrong id from a right one.
11485 ///
11486 /// [`FaceId`]: crate::style::FaceId
11487 /// [`BlockCache`]: crate::wysiwyg::BlockCache
11488 #[test]
11489 fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
11490 use crate::style::{FaceId, FaceRef};
11491 let garamond = FaceId::of("Garamond");
11492 let futura = FaceId::of("Futura");
11493 let mut d = wysiwyg_doc(
11494 "two_faces",
11495 "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
11496 );
11497 d.build_visual_unwrapped();
11498
11499 // What the map has to keep saying, whichever path built it.
11500 let check = |d: &Doc, ctx: &str| {
11501 let face_of = |ch: char| {
11502 d.vmap
11503 .rows
11504 .iter()
11505 .flat_map(|r| r.glyphs.iter())
11506 .find(|g| g.ch == ch)
11507 .map(|g| g.style.font)
11508 };
11509 assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
11510 assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
11511 assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
11512 assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
11513 assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
11514 };
11515 check(&d, "fresh");
11516
11517 // An edit inside the second block: the single-block case the splice
11518 // path is for. The first block's rows are carried over untouched, so
11519 // its glyphs' ids are the previous build's and the table has to be too.
11520 let at = d.source.find("beta").unwrap();
11521 d.edit(at, at, "z");
11522 d.build_visual_unwrapped();
11523 check(&d, "after an edit in the second block");
11524 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
11525
11526 // And the other way round, so the block that was kept is the one that
11527 // is now re-rendered.
11528 let at = d.source.find("alpha").unwrap();
11529 d.edit(at, at, "z");
11530 d.build_visual_unwrapped();
11531 check(&d, "after an edit in the first block");
11532 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
11533
11534 // A structural edit is one the splice bails out of, so the map is
11535 // reassembled by `build_cached` — where an untouched block is a *cache
11536 // hit* and its rows are cloned without a `data-font` being walked
11537 // again. The names the entry stored are what keeps the table honest
11538 // there.
11539 let at = d.source.find("\n\ny ").unwrap();
11540 d.edit(at, at, "\n\nmiddle");
11541 d.build_visual_unwrapped();
11542 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
11543 // Twice, because that first `build_cached` is what stores the entries:
11544 // this one is the build where the Garamond block is a *hit*, its rows
11545 // cloned with their ids and no attribute walked to explain them.
11546 let at = d.source.len() - 1;
11547 d.edit(at, at, "\n\ntail");
11548 d.build_visual_unwrapped();
11549 check(&d, "after a structural edit, through the block cache");
11550 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
11551
11552 // A family the edit took the last glyph of leaves the glyphs with no
11553 // face and the table with a name nothing asks for — harmless, and the
11554 // price of not walking the rows the splice exists to avoid walking.
11555 let span = d.source.find("<span data-font=\"Futura\">").unwrap();
11556 let end = d.source.rfind("</span>").unwrap() + "</span>".len();
11557 d.edit(span, end, "beta");
11558 d.build_visual_unwrapped();
11559 assert!(!d.source.contains("Futura"), "{:?}", d.source);
11560 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
11561 }
11562
11563 #[test]
11564 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
11565 // The same correctness net as `incremental_build_matches_a_fresh_build_
11566 // across_edits`, under `MarkupMode::Full` — where the map depends on
11567 // the caret's *line* as well as the text, so the two caches have a new
11568 // way to be wrong. Both are exercised: the block cache can hand back
11569 // rows built for a line that is no longer the revealed one, and the
11570 // splice path can reuse a suffix that still has yesterday's line raw.
11571 //
11572 // Caret motion is interleaved with the edits deliberately, because a
11573 // caret that only ever moved with the edit would never cross a line
11574 // without also dirtying it — the case where a stale reveal survives.
11575 let docs = [
11576 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
11577 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
11578 ];
11579 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
11580 for src in docs {
11581 let mut d = wysiwyg_doc("reveal_diff", src);
11582 d.set_markup_mode(MarkupMode::Full);
11583
11584 for step in 0..60usize {
11585 let len = d.source.len();
11586 let raw = (step * 13 + 5) % (len + 1);
11587 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11588 let pre = d.source.clone();
11589 let action;
11590 if step % 3 == 0 && pos < len {
11591 let end = (pos + 1..=len)
11592 .find(|&i| d.source.is_char_boundary(i))
11593 .unwrap();
11594 action = format!("delete [{pos},{end})");
11595 d.edit(pos, end, "");
11596 } else {
11597 let ins = inserts[step % inserts.len()];
11598 action = format!("insert {ins:?} @ {pos}");
11599 d.edit(pos, pos, ins);
11600 }
11601 // Walk the caret somewhere else in the document, independently
11602 // of where the edit landed.
11603 let want = (step * 29 + 11) % (d.source.len() + 1);
11604 d.caret = (want..=d.source.len())
11605 .find(|&i| d.source.is_char_boundary(i))
11606 .unwrap();
11607 d.build_visual_unwrapped();
11608
11609 let want = reference_map_revealing(&d.source, d.reveal_line());
11610 if maps_differ(&d.vmap, &want) {
11611 panic!(
11612 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
11613 d.caret, d.source
11614 );
11615 }
11616 }
11617 }
11618 }
11619
11620 #[test]
11621 fn caret_motion_across_lines_rebuilds_only_under_full() {
11622 // The cache-key change has to earn its keep in both directions: `Full`
11623 // must rebuild when the caret changes line (or the reveal would never
11624 // move), and the hidden modes must *not* (or every arrow key would pay
11625 // for a feature they don't use). The existing `cache_motion` test pins
11626 // the second for the default mode; this pins the pair against a mode
11627 // change alone.
11628 let body = "*one* here\n\n*two* there\n";
11629
11630 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
11631 full.set_markup_mode(MarkupMode::Full);
11632 caret_at(&mut full, "one");
11633 let before = full.revision();
11634 caret_at(&mut full, "two");
11635 assert_eq!(full.revision(), before, "motion is not an edit");
11636 assert!(
11637 drawn_rows(&full).iter().any(|r| r == "*two* there"),
11638 "the map followed the caret: {:?}",
11639 drawn_rows(&full)
11640 );
11641
11642 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
11643 caret_at(&mut hidden, "one");
11644 let key = hidden.vmap_key.clone();
11645 caret_at(&mut hidden, "two");
11646 assert_eq!(
11647 hidden.vmap_key, key,
11648 "a hidden mode rebuilds nothing on motion"
11649 );
11650 }
11651
11652 #[test]
11653 fn wysiwyg_down_crosses_a_paragraph_boundary() {
11654 // Regression: the blank separator row used to share the previous
11655 // paragraph's end offset, so Down got pinned at the boundary (while Up
11656 // still crossed). Both directions must step through it symmetrically.
11657 //
11658 // It's now stepped *over* rather than onto: the blank line between two
11659 // paragraphs is the boundary being drawn, not a line of the document, so
11660 // one press of Down crosses it. The goal column survives the crossing —
11661 // col 3 at the end of "abc" is col 3 at the end of "def".
11662 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
11663 d.caret = 3; // end of "abc" (row 0)
11664 d.move_down(false);
11665 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
11666 assert_eq!(d.caret, 8); // end of "def", col 3 kept
11667 d.move_up(false);
11668 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
11669 assert_eq!(d.caret, 3);
11670 }
11671
11672 #[test]
11673 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
11674 // The second Up and the second Down here run off the ends of the
11675 // document, which is no longer a place a press is swallowed: they carry
11676 // the caret to the start and the end of the text. The claim in the
11677 // middle — that a Down retraces the Up that crossed the paragraph gap —
11678 // is the one this test is for, and it is asserted where it is made.
11679 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
11680 d.caret = 5; // start of "def"
11681 let start = d.caret_pos();
11682 d.move_up(false);
11683 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
11684 d.move_up(false);
11685 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
11686 d.move_down(false);
11687 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
11688 d.move_down(false);
11689 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
11690 }
11691
11692 #[test]
11693 fn wysiwyg_new_paragraph_shows_before_typing() {
11694 // Regression: two Enters at the end of a paragraph produced trailing
11695 // newlines with no AST node, so the caret appeared stuck on the old line
11696 // until a character was typed. It must ride down onto the new line now.
11697 let mut d = doc_with("wys_newpara", "abc\n");
11698 d.view = View::Wysiwyg;
11699 d.caret = 3;
11700 d.insert("\n");
11701 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
11702 assert_eq!(d.source, "abc\n\n\n");
11703 d.build_visual(80);
11704 let (row, _) = d.caret_pos();
11705 assert!(
11706 row >= 2,
11707 "caret should have moved down to the new line, got row {row}"
11708 );
11709 assert!(
11710 d.vmap.num_rows() >= 3,
11711 "the blank lines should render as rows"
11712 );
11713 }
11714
11715 #[test]
11716 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11717 // The reported bug: Enter at the end of a paragraph that has another
11718 // paragraph below put the caret at the *start of the next paragraph* —
11719 // the empty paragraph it opened had no row, so the caret snapped onto
11720 // "World". It must now sit on its own empty line, with a blank spacer
11721 // above it (the paragraph gap).
11722 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11723 d.caret = 5; // end of "Hello"
11724 d.newline();
11725 d.build_visual(80);
11726 let (row, col) = d.caret_pos();
11727 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11728 assert_eq!(
11729 d.vmap.row_width(row),
11730 0,
11731 "caret's row must be empty, not 'World'"
11732 );
11733 assert!(
11734 row >= 2,
11735 "a blank spacer row should sit above the caret, got row {row}"
11736 );
11737 // The row above the caret is a real (empty) gap, and "Hello" stays put.
11738 assert_eq!(
11739 d.vmap.row_width(row - 1),
11740 0,
11741 "the row above the caret is a gap"
11742 );
11743 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11744 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11745 }
11746
11747 #[test]
11748 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11749 // At the document end a single Enter must also show the paragraph gap —
11750 // a blank spacer row above the caret — so the layout already matches how
11751 // it will look once the new paragraph has text.
11752 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11753 d.caret = 5; // end of "Hello", no trailing newline
11754 d.newline(); // source becomes "Hello\n\n"
11755 d.build_visual(80);
11756 let (row, col) = d.caret_pos();
11757 assert_eq!(col, 0);
11758 assert!(
11759 row >= 2,
11760 "caret should sit below a blank spacer, got row {row}"
11761 );
11762 assert_eq!(
11763 d.vmap.row_width(row - 1),
11764 0,
11765 "the row above the caret is a gap"
11766 );
11767 }
11768
11769 #[test]
11770 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
11771 // The spacer is view-only: typing the new paragraph must not reflow the
11772 // caret onto a different row — the transient view already matched the
11773 // settled one.
11774 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
11775 d.caret = 5;
11776 d.newline();
11777 d.build_visual(80);
11778 let before = d.caret_pos();
11779 d.insert("New");
11780 d.build_visual(80);
11781 let after = d.caret_pos();
11782 assert_eq!(
11783 after.0, before.0,
11784 "typing must not move the caret to another row ({before:?} -> {after:?})"
11785 );
11786 }
11787
11788 #[test]
11789 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
11790 // Return at the end of the block's last line writes an empty line the
11791 // map used to drop, so the caret landed on `after` and the next
11792 // keystroke went into the paragraph below instead of into the code.
11793 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
11794 d.caret = d.source.find("beta").unwrap() + "beta".len();
11795 d.build_visual(80);
11796 let before = d.caret_pos().0;
11797
11798 d.newline();
11799 d.build_visual(80);
11800 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
11801
11802 let (row, col) = d.caret_pos();
11803 assert_eq!(row, before + 1, "the caret moves down one row");
11804 assert_eq!(col, 0, "onto the head of the empty line");
11805 let span = d.vmap.code_blocks[0].rows_span.clone();
11806 assert!(
11807 span.contains(&row),
11808 "caret row {row} is outside the block's rows {span:?}"
11809 );
11810
11811 // The whole point: what is typed next is code.
11812 d.insert("gamma");
11813 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
11814 }
11815
11816 #[test]
11817 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
11818 let fm = "---\ntitle: hi\n---\n";
11819 let body = format!("{fm}# leaf\n\nbody\n");
11820 let mut d = wysiwyg_doc("wys_fm", &body);
11821 // Opening lifts the caret out of the now-hidden frontmatter.
11822 assert_eq!(
11823 d.caret,
11824 fm.len(),
11825 "caret should start at the first real block"
11826 );
11827 // Left at the content start can't step back into frontmatter.
11828 d.move_left(false);
11829 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
11830 // Doc-start lands on the content floor, not offset 0.
11831 d.move_doc_start(false);
11832 assert_eq!(d.caret, fm.len());
11833 // Select-all + copy never include the frontmatter bytes.
11834 d.select_all();
11835 let sel = d.selected_text().unwrap().to_string();
11836 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
11837 assert!(
11838 sel.starts_with("# leaf"),
11839 "selection should begin at content: {sel:?}"
11840 );
11841 }
11842
11843 #[test]
11844 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
11845 // A fresh note is frontmatter and nothing else. With no rendered block
11846 // to floor the caret it opened at offset 0 — before the opening `---` —
11847 // so the first keystroke wrote itself in front of the metadata and the
11848 // file came out as `This---\ntitle: …`.
11849 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
11850 let mut d = wysiwyg_doc("wys_fm_only", fm);
11851 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
11852 // Nothing is rendered, so the caret draws at the origin of an empty view
11853 // — the same place an empty document puts it.
11854 assert_eq!(d.caret_pos(), (0, 0));
11855 d.insert("This");
11856 assert_eq!(d.source, format!("{fm}This"));
11857 }
11858
11859 /// `select_range` is the verb for a range a host already knows the bytes of,
11860 /// so it must not snap — and must still hold every invariant `place_caret`
11861 /// holds, the frontmatter floor above all.
11862 #[test]
11863 fn select_range_takes_the_range_as_given_but_still_floors_it() {
11864 let fm = "---\ntitle: foo\n---\n\n";
11865 let body = format!("{fm}body foo here\n");
11866 let mut d = wysiwyg_doc("wys_select_range", &body);
11867
11868 // The `foo` in the body: taken exactly, not snapped to a caret stop.
11869 let at = body.rfind("foo").unwrap();
11870 d.select_range(at, at + 3);
11871 assert_eq!(d.selection(), Some((at, at + 3)));
11872 assert_eq!(d.selected_text(), Some("foo"));
11873
11874 // The `foo` in the hidden frontmatter: below the floor, so both ends
11875 // come up to it rather than parking the caret in the metadata, where a
11876 // later keystroke would rewrite `title:`.
11877 let hidden = body.find("foo").unwrap();
11878 assert!(hidden < d.vmap.content_start);
11879 d.select_range(hidden, hidden + 3);
11880 assert!(
11881 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
11882 "a range under the floor must not leave the caret in the frontmatter"
11883 );
11884
11885 // Past the end, and mid-character, are both brought back to something
11886 // sliceable rather than panicking the next reader of the range.
11887 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
11888 let mut d = multi;
11889 d.select_range(2, 9_999);
11890 assert_eq!(d.caret, d.source.len());
11891 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
11892 assert!(d.source.is_char_boundary(d.caret));
11893 }
11894
11895 /// The bug `select_range` exists for: a match butting up against a hidden
11896 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
11897 /// the one before the `**`.
11898 #[test]
11899 fn select_range_does_not_snap_off_a_hidden_delimiter() {
11900 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
11901 let at = d.source.find("needle").unwrap();
11902 d.select_range(at, at + 6);
11903 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
11904 }
11905
11906 #[test]
11907 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
11908 // Backspace deletes `prev_boundary..caret` directly; at the first real
11909 // block that boundary is inside the hidden frontmatter, so it must be a
11910 // no-op rather than eating the closing `---`.
11911 let fm = "---\ntitle: hi\n---\n";
11912 let body = format!("{fm}leaf\n");
11913 let mut d = wysiwyg_doc("wys_fm_bs", &body);
11914 assert_eq!(d.caret, fm.len());
11915 d.backspace();
11916 assert_eq!(d.source, body, "backspace must not touch frontmatter");
11917 d.delete_word_back();
11918 assert_eq!(
11919 d.source, body,
11920 "word-delete must not touch frontmatter either"
11921 );
11922 }
11923
11924 #[test]
11925 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
11926 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
11927 // fenced div, really, since core parses these on for every document
11928 // now (`parse_extensions`). The container is a `directive` node, an
11929 // `is_block_container` kind like `block_quote`, so the caret works
11930 // inside its child paragraph exactly as it would inside a quote: typing
11931 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
11932 // untouched.
11933 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
11934 let mut d = wysiwyg_doc("wys_vis", body);
11935 d.caret = body.find("hello").unwrap() + "hello".len();
11936 d.insert("!");
11937 assert_eq!(
11938 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
11939 "typing inside the block edits its content in place"
11940 );
11941 assert!(
11942 d.source.contains(":::vis{.public .family}"),
11943 "opening fence survives"
11944 );
11945 assert!(d.source.contains(":::\nafter"), "closing fence survives");
11946 }
11947
11948 #[test]
11949 fn source_view_still_reaches_frontmatter() {
11950 // The metadata is only *hidden*, never lost: the source view edits and
11951 // selects it in full, and it's always preserved on save.
11952 let fm = "---\ntitle: hi\n---\n";
11953 let body = format!("{fm}# leaf\n");
11954 let mut d = doc_with("src_fm", &body);
11955 d.select_all();
11956 let sel = d.selected_text().unwrap();
11957 assert!(
11958 sel.contains("title"),
11959 "source view should select everything"
11960 );
11961 d.move_doc_start(false);
11962 assert_eq!(d.caret, 0, "source view can reach offset 0");
11963 }
11964
11965 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
11966
11967 #[test]
11968 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
11969 // The border and padding between two cells all share one source offset,
11970 // so a column-stepping caret would sit on `│` and then stall there
11971 // forever. Right must step: end of "Name" -> start of "Qty".
11972 let mut d = wysiwyg_doc("tbl_right", TABLE);
11973 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
11974 d.move_right(false);
11975 assert_eq!(
11976 d.caret,
11977 TABLE.find("Qty").unwrap(),
11978 "should land in the next cell"
11979 );
11980 let (r, c) = d.caret_pos();
11981 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
11982 }
11983
11984 #[test]
11985 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
11986 let mut d = wysiwyg_doc("tbl_left", TABLE);
11987 d.caret = TABLE.find("Qty").unwrap();
11988 d.move_left(false);
11989 assert_eq!(
11990 d.caret,
11991 TABLE.find("Name").unwrap() + 4,
11992 "end of the previous cell"
11993 );
11994 }
11995
11996 #[test]
11997 fn wysiwyg_down_steps_over_a_table_rule() {
11998 // Between the header and the first body row sits a `├───┼───┤` rule.
11999 // It's drawn but holds no caret, so one Down must reach "Pear".
12000 let mut d = wysiwyg_doc("tbl_down", TABLE);
12001 d.caret = TABLE.find("Name").unwrap();
12002 d.move_down(false);
12003 assert_eq!(
12004 d.caret,
12005 TABLE.find("Pear").unwrap(),
12006 "one Down reaches the body row"
12007 );
12008 d.move_down(false);
12009 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
12010 }
12011
12012 #[test]
12013 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
12014 let mut d = wysiwyg_doc("tbl_tab", TABLE);
12015 d.caret = TABLE.find("Name").unwrap();
12016 // A hop lands with the destination cell's whole content selected, the
12017 // caret at its end — so typing replaces the cell like a form field.
12018 assert!(d.cell_hop(true));
12019 assert_eq!(
12020 d.selected_text(),
12021 Some("Qty"),
12022 "the target cell comes up selected"
12023 );
12024 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
12025 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
12026 assert_eq!(d.selected_text(), Some("Pear"));
12027 assert!(d.cell_hop(false));
12028 assert_eq!(d.selected_text(), Some("Qty"));
12029 }
12030
12031 #[test]
12032 fn tab_outside_a_table_is_not_a_cell_hop() {
12033 // `cell_hop` reports false so the frontend can indent as usual.
12034 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
12035 d.caret = 4;
12036 assert!(!d.cell_hop(true));
12037 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
12038 }
12039
12040 #[test]
12041 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
12042 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12043 d.caret = TABLE.rfind("12").unwrap(); // the final cell
12044 assert!(!d.cell_hop(true), "no cell after the last one");
12045 d.caret = TABLE.find("Name").unwrap();
12046 assert!(!d.cell_hop(false), "no cell before the first one");
12047 }
12048
12049 #[test]
12050 fn wysiwyg_vertical_cell_motion_holds_the_column() {
12051 // Down/Up step to the cell above/below in the *same column*, not back to
12052 // the top-left the way a naive row/col motion over the picture would.
12053 let mut d = wysiwyg_doc("tbl_vert", TABLE);
12054 d.caret = TABLE.find("Qty").unwrap();
12055 // Each vertical hop selects the destination cell, holding the column.
12056 assert!(d.cell_move_vertical(true));
12057 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
12058 assert!(d.cell_move_vertical(true));
12059 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
12060 assert!(!d.cell_move_vertical(true), "no row below the last");
12061 assert!(d.cell_move_vertical(false));
12062 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
12063 assert!(d.cell_move_vertical(false));
12064 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
12065 assert!(!d.cell_move_vertical(false), "no row above the header");
12066 }
12067
12068 #[test]
12069 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
12070 let mut d = wysiwyg_doc("tbl_grow", TABLE);
12071 d.caret = TABLE.rfind("12").unwrap();
12072 let rows_before = d.source.matches('\n').count();
12073 assert!(d.cell_tab(true), "acts as a table key");
12074 assert_eq!(
12075 d.source.matches('\n').count(),
12076 rows_before + 1,
12077 "a fresh row was appended"
12078 );
12079 assert!(d.caret_in_table(), "the caret entered the new row");
12080 // The caret sits in the new row's first cell — past the old last cell.
12081 assert!(d.caret > TABLE.rfind("12").unwrap());
12082 }
12083
12084 #[test]
12085 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
12086 let mut d = wysiwyg_doc("tbl_ret", TABLE);
12087 d.caret = TABLE.find("Name").unwrap();
12088 assert!(d.cell_return(), "acts as a table key");
12089 assert_eq!(
12090 d.selected_text(),
12091 Some("Pear"),
12092 "Return drops one cell, selecting it"
12093 );
12094 // From the last row, Return appends a row and enters it.
12095 d.caret = TABLE.rfind("Fig").unwrap();
12096 let rows_before = d.source.matches('\n').count();
12097 assert!(d.cell_return());
12098 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
12099 assert!(d.caret_in_table());
12100 }
12101
12102 #[test]
12103 fn return_and_tab_outside_a_table_decline() {
12104 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
12105 d.caret = 4;
12106 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
12107 assert!(!d.cell_tab(true), "no table: the frontend indents");
12108 assert!(
12109 !d.cell_line_break(),
12110 "no table: the frontend breaks the line"
12111 );
12112 }
12113
12114 #[test]
12115 fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
12116 // A document that ends in a table used to end *inside* it: nothing
12117 // past the last cell was a caret stop, so a click in the blank space
12118 // under the grid snapped back into the table and there was no way to
12119 // write a line after it. The bottom border's end is that stop now.
12120 let mut d = wysiwyg_doc("tbl_trail", TABLE);
12121 let rows = d.vmap.num_rows();
12122 d.click(rows + 3, 0, false);
12123 let end = TABLE.trim_end_matches('\n').len();
12124 assert_eq!(d.caret, end, "the caret stands just past the table");
12125 assert!(!d.caret_in_table(), "past the table is outside it");
12126 assert!(!d.cell_return(), "Return there is the frontend's newline");
12127 d.newline();
12128 d.insert("after");
12129 assert_eq!(
12130 d.source,
12131 format!("{TABLE}\nafter\n"),
12132 "Enter opens a paragraph under the table"
12133 );
12134 }
12135
12136 #[test]
12137 fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
12138 // The stop sits at the end of the table's last source line, and a
12139 // line glued under a table is a row of it — `| Fig | 12 |x` would be a
12140 // three-cell row. So the text gets a paragraph of its own, as it does
12141 // beside a block picture.
12142 let mut d = wysiwyg_doc("tbl_type", TABLE);
12143 d.caret = TABLE.trim_end_matches('\n').len();
12144 d.insert("x");
12145 assert_eq!(d.source, format!("{TABLE}\nx\n"));
12146 assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
12147 // And a paste, which joins the block exactly as typing would.
12148 let mut d = wysiwyg_doc("tbl_paste", TABLE);
12149 d.caret = TABLE.trim_end_matches('\n').len();
12150 d.paste("pasted");
12151 assert_eq!(d.source, format!("{TABLE}\npasted\n"));
12152 }
12153
12154 #[test]
12155 fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
12156 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12157 let last_cell_end = TABLE.rfind("12").unwrap() + 2;
12158 let end = TABLE.trim_end_matches('\n').len();
12159 d.caret = last_cell_end;
12160 d.move_right(false);
12161 assert_eq!(d.caret, end, "Right from the last cell leaves the table");
12162 // Backspace there takes no byte: the one behind the caret is the row's
12163 // closing `|`, which the rich view never drew. It steps back instead.
12164 d.backspace();
12165 assert_eq!(d.source, TABLE, "nothing deleted");
12166 assert_eq!(d.caret, last_cell_end, "back into the last cell");
12167 // Down from the last row lands on the same stop, and Up returns.
12168 d.move_down(false);
12169 assert_eq!(d.caret, end, "Down from the last row leaves the table");
12170 d.move_up(false);
12171 assert_eq!(d.caret, last_cell_end);
12172 }
12173
12174 #[test]
12175 fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
12176 // With prose under the table, the stop is one hop between the last
12177 // cell and the paragraph — the shape a block picture's second stop has.
12178 let src = format!("{TABLE}\nafter\n");
12179 let mut d = wysiwyg_doc("tbl_mid", &src);
12180 d.caret = TABLE.rfind("12").unwrap() + 2;
12181 d.move_right(false);
12182 assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
12183 d.move_right(false);
12184 assert_eq!(d.caret, src.find("after").unwrap());
12185 // Typing at the stop still opens a paragraph, and the text below keeps
12186 // its own.
12187 d.move_left(false);
12188 d.insert("x");
12189 assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
12190 }
12191
12192 #[test]
12193 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
12194 let mut d = wysiwyg_doc("tbl_break", TABLE);
12195 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
12196 assert!(d.cell_line_break(), "acts as a table key");
12197 assert!(
12198 d.source.contains("Pear<br>"),
12199 "spelled as an inline <br>: {}",
12200 d.source
12201 );
12202 assert!(d.caret_in_table(), "still in the cell, past the break");
12203 // The break renders as a real line: the "Pear" cell now draws two lines,
12204 // so the table's picture is one row taller than a single-line table.
12205 d.build_visual(80);
12206 let table = &d.vmap.tables[0];
12207 let cell = &table.grid[1].cells[0]; // first body row, first column
12208 assert!(
12209 cell.glyphs.iter().any(|g| g.ch == '\n'),
12210 "the cell carries the break as a newline glyph for the frontend to split"
12211 );
12212 }
12213
12214 #[test]
12215 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
12216 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
12217 // back as structure — the whole point of routing through insert_line_break
12218 // instead of splicing raw `<br>` bytes.
12219 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
12220 d.caret = TABLE.find("Pear").unwrap() + 4;
12221 assert!(d.cell_line_break());
12222 let kinds: Vec<Kind> = d
12223 .editor
12224 .nodes()
12225 .unwrap()
12226 .iter()
12227 .map(|n| n.kind.clone())
12228 .collect();
12229 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
12230 assert!(
12231 !kinds.contains(&Kind::RawInline),
12232 "still raw HTML: {kinds:?}"
12233 );
12234 }
12235
12236 #[test]
12237 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
12238 // The `<br>` draws as one newline glyph, so Backspace over it must take
12239 // all four bytes — a one-byte delete would strand a visible `<br` in the
12240 // cell (the reported bug).
12241 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
12242 d.caret = TABLE.find("Pear").unwrap() + 4;
12243 assert!(d.cell_line_break());
12244 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
12245 d.backspace(); // caret sits just past the break
12246 assert!(
12247 !d.source.contains("<br"),
12248 "no half-deleted <br left: {}",
12249 d.source
12250 );
12251 assert!(
12252 d.source.contains("| Pear |"),
12253 "the cell is back to one line: {}",
12254 d.source
12255 );
12256 }
12257
12258 #[test]
12259 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
12260 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
12261 d.caret = TABLE.find("Pear").unwrap() + 4;
12262 assert!(d.cell_line_break());
12263 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
12264 d.delete_forward();
12265 assert!(
12266 !d.source.contains("<br"),
12267 "no half-deleted <br: {}",
12268 d.source
12269 );
12270 assert!(
12271 d.source.contains("| Pear |"),
12272 "cell back to one line: {}",
12273 d.source
12274 );
12275 }
12276
12277 #[test]
12278 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
12279 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
12280 // still consumed (a real newline would split the one-line row), but the
12281 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
12282 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
12283 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12284 d.caret = src.find("Pear").unwrap() + 4;
12285 assert!(d.caret_in_table(), "caret should be inside the djot table");
12286 assert!(
12287 d.cell_line_break(),
12288 "the key is consumed, not passed to the frontend"
12289 );
12290 assert_eq!(d.source, src, "the djot cell is left untouched");
12291 assert!(
12292 !d.source.contains("<br>"),
12293 "no non-idiomatic <br> spliced into djot"
12294 );
12295 assert!(
12296 d.status.is_some(),
12297 "the refusal is surfaced on the status line"
12298 );
12299 }
12300
12301 #[test]
12302 fn typing_in_a_cell_edits_that_cell() {
12303 // Editing comes free once offsets map correctly: the caret is a source
12304 // offset, so a normal splice lands inside the pipe table.
12305 let mut d = wysiwyg_doc("tbl_type", TABLE);
12306 d.caret = TABLE.find("Pear").unwrap() + 4;
12307 d.insert("s");
12308 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
12309 }
12310
12311 #[test]
12312 fn motion_and_delete_treat_an_emoji_as_one_character() {
12313 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
12314 // bytes, several codepoints. Right-arrow must clear it in one step, and
12315 // backspace must remove the whole cluster, not a stray joiner.
12316 let family = "👨👩👧";
12317 let mut d = doc_with("emoji", &format!("a{family}b\n"));
12318 d.caret = 1; // just after 'a', before the emoji
12319 d.move_right(false);
12320 assert_eq!(
12321 d.caret,
12322 1 + family.len(),
12323 "one step clears the whole cluster"
12324 );
12325 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
12326
12327 d.backspace(); // delete the emoji as a unit
12328 assert_eq!(d.source, "ab\n");
12329 assert_eq!(d.caret, 1);
12330 }
12331
12332 #[test]
12333 fn motion_handles_a_combining_accent_as_one_character() {
12334 // "e" + U+0301 (combining acute) renders as one é.
12335 let mut d = doc_with("combining", "e\u{0301}x\n");
12336 d.caret = 0;
12337 d.move_right(false);
12338 assert_eq!(
12339 d.caret,
12340 "e\u{0301}".len(),
12341 "steps past base + combining mark"
12342 );
12343 }
12344
12345 #[test]
12346 fn undo_then_redo_round_trips_an_edit() {
12347 let mut d = doc_with("undo", "hello\n");
12348 d.caret = 5;
12349 d.insert("!");
12350 assert_eq!(d.source, "hello!\n");
12351 d.undo();
12352 assert_eq!(d.source, "hello\n");
12353 assert_eq!(d.caret, 5, "undo restores the caret");
12354 d.redo();
12355 assert_eq!(d.source, "hello!\n");
12356 }
12357
12358 #[test]
12359 fn a_run_of_typing_undoes_as_one_step() {
12360 let mut d = doc_with("coalesce", "\n");
12361 d.caret = 0;
12362 d.insert("a");
12363 d.insert("b");
12364 d.insert("c");
12365 assert_eq!(d.source, "abc\n");
12366 d.undo(); // the whole typed run, not just "c"
12367 assert_eq!(d.source, "\n");
12368 d.undo(); // nothing left — the run was one step
12369 assert_eq!(d.source, "\n");
12370 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12371 }
12372
12373 // ── IME composition ──────────────────────────────────────────────────────
12374
12375 #[test]
12376 fn a_composition_run_undoes_as_one_step() {
12377 let mut d = doc_with("compose", "\n");
12378 d.caret = 0;
12379 // What an IME does: each step replaces the last one's provisional bytes.
12380 d.edit_composing(0, 0, "k");
12381 d.edit_composing(0, 1, "か");
12382 d.edit_composing(0, 3, "かん");
12383 d.edit_composing(0, 6, "感"); // the commit
12384 d.end_composition();
12385 assert_eq!(d.source, "感\n");
12386 d.undo(); // the whole composition, not its last keystroke
12387 assert_eq!(d.source, "\n");
12388 assert_eq!(d.status.as_deref(), None, "the run was a single step");
12389 }
12390
12391 #[test]
12392 fn two_compositions_are_two_undo_steps() {
12393 let mut d = doc_with("compose_two", "\n");
12394 d.caret = 0;
12395 d.edit_composing(0, 0, "か");
12396 d.edit_composing(0, 3, "蚊");
12397 d.end_composition();
12398 d.edit_composing(3, 3, "き");
12399 d.edit_composing(3, 6, "木");
12400 d.end_composition();
12401 assert_eq!(d.source, "蚊木\n");
12402 d.undo();
12403 assert_eq!(d.source, "蚊\n", "only the second composition");
12404 d.undo();
12405 assert_eq!(d.source, "\n");
12406 }
12407
12408 #[test]
12409 fn a_composition_does_not_fold_into_the_typing_around_it() {
12410 let mut d = doc_with("compose_typing", "\n");
12411 d.caret = 0;
12412 d.insert("a");
12413 d.insert("b");
12414 d.edit_composing(2, 2, "か");
12415 d.edit_composing(2, 5, "蚊");
12416 d.end_composition();
12417 d.insert("c");
12418 assert_eq!(d.source, "ab蚊c\n");
12419 d.undo();
12420 assert_eq!(d.source, "ab蚊\n");
12421 d.undo();
12422 assert_eq!(d.source, "ab\n");
12423 d.undo();
12424 assert_eq!(d.source, "\n");
12425 }
12426
12427 #[test]
12428 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
12429 let mut d = doc_with("compose_spurious", "\n");
12430 d.caret = 0;
12431 d.insert("a");
12432 d.end_composition(); // an IME unmarking unprompted
12433 d.insert("b");
12434 assert_eq!(d.source, "ab\n");
12435 d.undo();
12436 assert_eq!(d.source, "\n", "still one typed run");
12437 }
12438
12439 // ── the clipboard's rich flavor ──────────────────────────────────────────
12440
12441 #[test]
12442 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
12443 let mut d = doc_with("sel_inline", "a **bold** c\n");
12444 d.anchor = Some(2);
12445 d.caret = 10; // `**bold**`, inside the paragraph
12446 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
12447 }
12448
12449 #[test]
12450 fn a_whole_block_selection_keeps_its_paragraph() {
12451 let mut d = doc_with("sel_block", "a **bold** c\n");
12452 d.anchor = Some(0);
12453 d.caret = 12; // the entire paragraph
12454 assert_eq!(
12455 d.selection_html().as_deref(),
12456 Some("<p>a <strong>bold</strong> c</p>")
12457 );
12458 }
12459
12460 #[test]
12461 fn a_multi_block_selection_keeps_its_structure() {
12462 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
12463 d.select_all();
12464 let html = d.selection_html().expect("renders");
12465 assert!(html.contains("<p>para</p>"), "{html:?}");
12466 assert!(html.contains("<li>one</li>"), "{html:?}");
12467 }
12468
12469 #[test]
12470 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
12471 // The fragment `Head` is a paragraph standalone; the *document* says it
12472 // sits inside one block, so the wrapper is an artifact either way.
12473 let mut d = doc_with("sel_heading", "# Head line\n");
12474 d.anchor = Some(2);
12475 d.caret = 6;
12476 assert_eq!(d.selection_html().as_deref(), Some("Head"));
12477 }
12478
12479 #[test]
12480 fn no_selection_publishes_no_html() {
12481 let mut d = doc_with("sel_none", "a b\n");
12482 d.caret = 1;
12483 assert_eq!(d.selection_html(), None);
12484 }
12485
12486 #[test]
12487 fn pasting_html_converts_it_and_is_one_undo_step() {
12488 let mut d = doc_with("paste_html", "x\n");
12489 d.caret = 1;
12490 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
12491 assert_eq!(d.source, "xa **b** c\n");
12492 d.undo();
12493 assert_eq!(d.source, "x\n", "the whole paste, in one step");
12494 }
12495
12496 #[test]
12497 fn pasting_html_replaces_the_selection() {
12498 let mut d = doc_with("paste_html_sel", "keep drop\n");
12499 d.anchor = Some(5);
12500 d.caret = 9;
12501 assert!(d.paste_html("<em>new</em>"));
12502 assert_eq!(d.source, "keep *new*\n");
12503 }
12504
12505 #[test]
12506 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
12507 let mut d = doc_with("paste_html_bad", "x\n");
12508 d.caret = 1;
12509 // twig builds no table from HTML; raw `<table>` in prose is worse than
12510 // the plain flavor the caller still holds.
12511 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
12512 assert_eq!(d.source, "x\n", "declined edits nothing");
12513 }
12514
12515 #[test]
12516 fn copy_then_paste_round_trips_through_the_html_flavor() {
12517 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
12518 d.select_all();
12519 let html = d.selection_html().expect("renders");
12520 let mut into = doc_with("clip_round_dst", "\n");
12521 into.caret = 0;
12522 assert!(into.paste_html(&html));
12523 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
12524 }
12525
12526 #[test]
12527 fn moving_the_caret_starts_a_new_undo_group() {
12528 let mut d = doc_with("break", "\n");
12529 d.caret = 0;
12530 d.insert("a");
12531 d.insert("b"); // "ab\n", caret at 2
12532 d.move_left(false); // breaks the run
12533 d.insert("X"); // "aXb\n"
12534 assert_eq!(d.source, "aXb\n");
12535 d.undo();
12536 assert_eq!(
12537 d.source, "ab\n",
12538 "first undo removes only the post-move insert"
12539 );
12540 d.undo();
12541 assert_eq!(d.source, "\n", "second undo removes the earlier run");
12542 }
12543
12544 #[test]
12545 fn undo_reverses_a_format_toggle() {
12546 let mut d = doc_with("fmt_undo", "a word b\n");
12547 d.anchor = Some(2);
12548 d.caret = 6;
12549 d.toggle(InlineKind::Strong);
12550 assert_eq!(d.source, "a **word** b\n");
12551 d.undo();
12552 assert_eq!(d.source, "a word b\n");
12553 }
12554
12555 #[test]
12556 fn undo_back_to_the_saved_state_clears_dirty() {
12557 let mut d = doc_with("dirty_undo", "hello\n");
12558 assert!(!d.dirty);
12559 d.caret = 5;
12560 d.insert("!");
12561 assert!(d.dirty);
12562 d.undo();
12563 assert!(
12564 !d.dirty,
12565 "undoing to the saved source is not a modification"
12566 );
12567 }
12568
12569 #[test]
12570 fn a_new_edit_invalidates_redo() {
12571 let mut d = doc_with("redo_inv", "\n");
12572 d.caret = 0;
12573 d.insert("a");
12574 d.undo();
12575 d.insert("b"); // diverges — the redo of "a" is now gone
12576 d.redo();
12577 assert_eq!(d.source, "b\n");
12578 }
12579
12580 #[test]
12581 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
12582 let mut d = doc_with("can_undo", "hello\n");
12583 assert!(
12584 !d.can_undo() && !d.can_redo(),
12585 "a fresh document has no history"
12586 );
12587 d.caret = 5;
12588 d.insert("!");
12589 assert!(
12590 d.can_undo() && !d.can_redo(),
12591 "an edit is a step to take back"
12592 );
12593 d.undo();
12594 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
12595 d.redo();
12596 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
12597 d.undo();
12598 d.insert("?");
12599 assert!(
12600 d.can_undo() && !d.can_redo(),
12601 "a fresh edit ends the redo chain"
12602 );
12603 // A coalesced run over-counts steps — the bound is what a menu needs,
12604 // and it reconciles the moment twig reports the history empty.
12605 d.insert("a");
12606 d.insert("b");
12607 while d.can_undo() {
12608 d.undo();
12609 }
12610 assert_eq!(d.source, "hello\n");
12611 assert!(!d.can_undo());
12612 // A reading surface has nothing to undo, whatever the history holds.
12613 d.redo();
12614 d.set_read_only(true);
12615 assert!(!d.can_undo() && !d.can_redo());
12616 }
12617
12618 #[test]
12619 fn undo_on_empty_history_is_a_no_op() {
12620 let mut d = doc_with("undo_empty", "hi\n");
12621 d.undo();
12622 assert_eq!(d.source, "hi\n");
12623 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12624 }
12625
12626 #[test]
12627 fn a_one_character_paste_is_its_own_undo_step() {
12628 for view in [View::Source, View::Wysiwyg] {
12629 let mut d = doc_in(view, "paste_step", "ab\n");
12630 d.caret = 0;
12631 d.insert("x");
12632 d.insert("y"); // a run of typing
12633 d.paste("z"); // one character, but pasted — not part of that run
12634 assert_eq!(d.source, "xyzab\n");
12635 d.undo();
12636 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
12637 assert_eq!(d.caret, 2, "and hands back the caret it found");
12638 d.undo();
12639 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
12640 }
12641 }
12642
12643 #[test]
12644 fn the_same_character_typed_still_joins_the_run() {
12645 // The other half of the pair: `z` is a keystroke here and a paste above,
12646 // and the two undo differently. Nothing about the *string* says which —
12647 // which is why provenance has to come from the door the caller uses.
12648 for view in [View::Source, View::Wysiwyg] {
12649 let mut d = doc_in(view, "typed_run", "ab\n");
12650 d.caret = 0;
12651 d.insert("x");
12652 d.insert("y");
12653 d.insert("z");
12654 d.undo();
12655 assert_eq!(d.source, "ab\n", "one run, one step");
12656 }
12657 }
12658
12659 #[test]
12660 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
12661 for view in [View::Source, View::Wysiwyg] {
12662 let mut d = doc_in(view, "undo_caret", "hello world\n");
12663 d.caret = 11; // standing at the end of "world", away from the edit
12664 d.edit(0, 5, "goodbye");
12665 assert_eq!(d.source, "goodbye world\n");
12666 d.undo();
12667 assert_eq!(d.source, "hello world\n");
12668 // The undone edit ends at offset 5; the user was at 11.
12669 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
12670 }
12671 }
12672
12673 #[test]
12674 fn undo_restores_the_selection_the_edit_replaced() {
12675 for view in [View::Source, View::Wysiwyg] {
12676 let mut d = doc_in(view, "undo_sel", "a word b\n");
12677 d.anchor = Some(2);
12678 d.caret = 6; // "word" selected
12679 d.insert("X");
12680 assert_eq!(d.source, "a X b\n");
12681 d.undo();
12682 assert_eq!(d.source, "a word b\n");
12683 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
12684 }
12685 }
12686
12687 #[test]
12688 fn redo_restores_the_caret_the_edit_left_behind() {
12689 for view in [View::Source, View::Wysiwyg] {
12690 let mut d = doc_in(view, "redo_caret", "hello world\n");
12691 d.caret = 11;
12692 d.edit(0, 5, "goodbye");
12693 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
12694 d.undo();
12695 d.redo();
12696 assert_eq!(d.source, "goodbye world\n");
12697 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
12698 }
12699 }
12700
12701 #[test]
12702 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
12703 for view in [View::Source, View::Wysiwyg] {
12704 let mut d = doc_in(view, "run_caret", "hi\n");
12705 d.caret = 2;
12706 d.insert("a");
12707 d.insert("b");
12708 d.insert("c");
12709 assert_eq!(d.source, "hiabc\n");
12710 d.undo();
12711 assert_eq!(d.source, "hi\n");
12712 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
12713 d.redo();
12714 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
12715 }
12716 }
12717
12718 #[test]
12719 fn undo_restores_the_caret_across_a_format_toggle() {
12720 // A toggle reaches twig without going through `splice`, so it has to
12721 // record its own step — miss it and every stack depth below it is off by
12722 // one, and undo starts handing back another edit's caret.
12723 for view in [View::Source, View::Wysiwyg] {
12724 let mut d = doc_in(view, "fmt_caret", "a word b\n");
12725 d.caret = 8;
12726 d.anchor = Some(2);
12727 d.caret = 6;
12728 d.toggle(InlineKind::Strong);
12729 assert_eq!(d.source, "a **word** b\n");
12730 d.undo();
12731 assert_eq!(d.source, "a word b\n");
12732 assert_eq!(
12733 d.selection(),
12734 Some((2, 6)),
12735 "the toggled selection comes back"
12736 );
12737 }
12738 }
12739
12740 #[test]
12741 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12742 // The drift that would never announce itself: twig drops its redo stack
12743 // on any fresh edit, so a leaf redo entry that outlives it would restore
12744 // a caret from the timeline that edit abandoned.
12745 for view in [View::Source, View::Wysiwyg] {
12746 let mut d = doc_in(view, "redo_trunc", "hello world\n");
12747 d.caret = 11;
12748 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12749 d.undo();
12750 assert_eq!(d.caret, 11);
12751 d.caret = 0;
12752 d.insert("X"); // diverges: A's redo is gone from twig
12753 assert_eq!(d.source, "Xhello world\n");
12754
12755 d.redo();
12756 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12757 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12758 d.undo();
12759 assert_eq!(d.source, "hello world\n");
12760 assert_eq!(
12761 d.caret, 0,
12762 "the surviving step's caret, not the dropped one"
12763 );
12764 }
12765 }
12766
12767 #[test]
12768 fn indent_and_outdent_move_the_caret_line_with_its_text() {
12769 for view in [View::Source, View::Wysiwyg] {
12770 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
12771 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
12772 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12773 // Indentation the caret is standing *in* collapses to the line start
12774 // rather than dragging the caret into the text.
12775 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
12776 // A line with none to give back is left exactly as it was.
12777 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
12778 // Less than a full level gives back what it has.
12779 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12780 // A tab is one level however many spaces it isn't.
12781 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
12782 }
12783 }
12784
12785 #[test]
12786 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
12787 // Why the level is two spaces and not the four both frontends type
12788 // today. Four is markdown's indented-code-block marker, so a Tab on a
12789 // paragraph would silently restyle it as code — a width that changes
12790 // what the document *means* isn't an indent. Pinned because the number
12791 // is the kind of thing a later list-aware pass would reach for.
12792 let mut d = doc_with("indent_kind", "hello\n");
12793 d.caret = 2;
12794 d.indent();
12795 assert_eq!(d.source, " hello\n");
12796 assert!(
12797 d.nodes().iter().any(|n| n.kind == Kind::Para),
12798 "still prose after a Tab"
12799 );
12800 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
12801
12802 // The four-space level this replaces, for contrast: same text, and twig
12803 // reparses the paragraph into a code block.
12804 let mut wide = doc_with("indent_kind_4", " hello\n");
12805 wide.build_visual(80);
12806 assert!(
12807 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
12808 "four spaces is a code block, not an indented paragraph"
12809 );
12810 }
12811
12812 #[test]
12813 fn indent_nests_a_list_item_under_its_parent() {
12814 // Tab indents a list item by its own marker width, landing its marker at
12815 // the parent's content column so twig reparses it as a nested list.
12816 for view in [View::Source, View::Wysiwyg] {
12817 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
12818 d.caret = 6; // on the second item
12819 d.indent();
12820 assert_eq!(d.source, "- a\n - b\n");
12821 let lists = d
12822 .nodes()
12823 .iter()
12824 .filter(|n| n.kind == Kind::BulletList)
12825 .count();
12826 assert_eq!(lists, 2, "the indented item is a nested list");
12827 }
12828 }
12829
12830 #[test]
12831 fn indent_nests_an_ordered_item_at_its_marker_width() {
12832 // An ordered marker `1. ` is three columns wide, so a two-space step
12833 // (which nests a bullet) leaves it flat. Regression: Tab must use the
12834 // marker width, three, so the item actually nests — and the source
12835 // renumbers so the sub-list restarts at 1 and the outer list resumes.
12836 for view in [View::Source, View::Wysiwyg] {
12837 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
12838 d.caret = d.source.find('b').unwrap();
12839 d.indent();
12840 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
12841 let lists = d
12842 .nodes()
12843 .iter()
12844 .filter(|n| n.kind == Kind::OrderedList)
12845 .count();
12846 assert_eq!(lists, 2, "the indented item is a nested ordered list");
12847 }
12848 }
12849
12850 #[test]
12851 fn indent_leaves_a_lists_first_item_put() {
12852 // The first item of a list has no sibling above it to nest under, so Tab
12853 // is a no-op there — the marker stays at column zero rather than being
12854 // shoved into indentation twig can't read as a sub-list.
12855 for view in [View::Source, View::Wysiwyg] {
12856 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
12857 d.caret = 1; // on the FIRST item
12858 d.indent();
12859 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
12860 // The sibling below still nests, proving the guard is per-item.
12861 d.caret = d.source.find('b').unwrap();
12862 d.indent();
12863 assert_eq!(d.source, "- a\n - b\n");
12864 }
12865 }
12866
12867 #[test]
12868 fn hidden_mode_keeps_typed_markup_literal() {
12869 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
12870 // twig escapes what would open markup, so the source is `\*hi\*` and the
12871 // AST is a plain string. Formatting is the commands' job in this mode.
12872 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
12873 d.insert("*hi*");
12874 assert_eq!(d.source, "\\*hi\\*");
12875 assert!(
12876 d.nodes()
12877 .iter()
12878 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
12879 );
12880 }
12881
12882 #[test]
12883 fn hidden_mode_escapes_a_line_start_block_marker() {
12884 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
12885 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
12886 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
12887 d.insert("# hi");
12888 assert_eq!(d.source, "\\# hi");
12889 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
12890 }
12891
12892 #[test]
12893 fn authoring_modes_keep_typed_markup_live() {
12894 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
12895 // (no escape), the same as source view — escaping is `None`'s alone, and
12896 // it's the axis, not the reveal, that decides.
12897 for (view, mode) in [
12898 (View::Wysiwyg, MarkupMode::Shortcuts),
12899 (View::Wysiwyg, MarkupMode::Full),
12900 (View::Source, MarkupMode::None),
12901 ] {
12902 let mut d = doc_in(view, "live_markup", "");
12903 d.set_markup_mode(mode);
12904 d.insert("*hi*");
12905 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
12906 }
12907 }
12908
12909 #[test]
12910 fn hidden_mode_overwrite_undoes_in_one_step() {
12911 // Typing over a selection escapes the replacement *and* stays a single
12912 // undo — the selection-delete and the literal insert fold together, so
12913 // one undo brings the whole selection back, like a plain overwrite.
12914 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
12915 d.anchor = Some(2);
12916 d.caret = 6; // "word"
12917 d.insert("*");
12918 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
12919 d.undo();
12920 assert_eq!(d.source, "a word b\n");
12921 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
12922 }
12923
12924 #[test]
12925 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
12926 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
12927 // the whole visual character, never stranding the hidden `\`.
12928 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
12929 d.insert("*");
12930 assert_eq!(d.source, "\\*");
12931 d.backspace();
12932 assert_eq!(d.source, "", "the escape backslash went with the *");
12933 // A *literal* backslash (source view, no escape) is an ordinary char.
12934 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
12935 s.caret = 3; // after `b`
12936 s.backspace();
12937 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
12938 }
12939
12940 #[test]
12941 fn hidden_mode_leaves_structural_markup_alone() {
12942 // Enter continues a bullet list by writing a real `- ` marker (an
12943 // `insert_raw`, not the typing path), so Hidden mode's escaping never
12944 // touches it — the list keeps working.
12945 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
12946 d.caret = 6;
12947 d.newline();
12948 d.insert("two");
12949 assert_eq!(d.source, "- item\n- two\n");
12950 }
12951
12952 #[test]
12953 fn markup_mode_defaults_to_none_and_round_trips() {
12954 // Diaryx's default is the clean `None` surface; a markup-fluent
12955 // frontend can climb the ladder, and the choice sticks.
12956 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
12957 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
12958 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
12959 d.set_markup_mode(mode);
12960 assert_eq!(d.markup_mode(), mode);
12961 }
12962 }
12963
12964 #[test]
12965 fn full_mode_reveals_only_the_caret_line() {
12966 // The mode's whole claim: the caret's line shows its raw delimiters and
12967 // every other line stays resolved. Two paragraphs with identical markup
12968 // so the only difference between the rows is where the caret is.
12969 let mut d = doc_in(
12970 View::Wysiwyg,
12971 "reveal_caret_line",
12972 "*one* here\n\n*two* there\n",
12973 );
12974 d.set_markup_mode(MarkupMode::Full);
12975
12976 caret_at(&mut d, "one");
12977 let rows = drawn_rows(&d);
12978 assert!(
12979 rows.iter().any(|r| r == "*one* here"),
12980 "caret's line raw: {rows:?}"
12981 );
12982 assert!(
12983 rows.iter().any(|r| r == "two there"),
12984 "other line resolved: {rows:?}"
12985 );
12986
12987 // Move to the other paragraph: the reveal follows, and the line just
12988 // left goes back to being resolved.
12989 caret_at(&mut d, "two");
12990 let rows = drawn_rows(&d);
12991 assert!(
12992 rows.iter().any(|r| r == "*two* there"),
12993 "caret's line raw: {rows:?}"
12994 );
12995 assert!(
12996 rows.iter().any(|r| r == "one here"),
12997 "left line resolved: {rows:?}"
12998 );
12999 }
13000
13001 #[test]
13002 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
13003 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
13004 // the same treatment as an emphasis's `*`: hidden while the caret is
13005 // elsewhere, shown in full where the caret lands. That falls out of
13006 // `delims` reading the bytes between the mark's span and its content
13007 // span, which is exactly `==🔴 ` and `==`, rather than from a table
13008 // of spellings — so the no-space form `==🟢green==` reveals right too.
13009 let mut d = doc_in(
13010 View::Wysiwyg,
13011 "reveal_coloured_mark",
13012 "a ==🔴 red== one\n\nb ==plain== two\n",
13013 );
13014 d.set_markup_mode(MarkupMode::Full);
13015
13016 caret_at(&mut d, "red");
13017 let rows = drawn_rows(&d);
13018 assert!(
13019 rows.iter().any(|r| r == "a ==🔴 red== one"),
13020 "the caret's line shows the colour it was written with: {rows:?}"
13021 );
13022 assert!(
13023 rows.iter().any(|r| r == "b plain two"),
13024 "and every other line stays resolved: {rows:?}"
13025 );
13026
13027 // Away from it, the emoji goes back to being markup — the reader sees
13028 // the words and the wash.
13029 caret_at(&mut d, "two");
13030 let rows = drawn_rows(&d);
13031 assert!(
13032 rows.iter().any(|r| r == "a red one"),
13033 "resolved again: {rows:?}"
13034 );
13035 }
13036
13037 #[test]
13038 fn hidden_modes_never_reveal_wherever_the_caret_is() {
13039 // The two rungs below `Full` share a rendering: delimiters stay hidden
13040 // even under the caret. `Shortcuts` differing from `None` only in what
13041 // typing does is exactly the point of splitting the axes.
13042 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
13043 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
13044 d.set_markup_mode(mode);
13045 caret_at(&mut d, "one");
13046 let rows = drawn_rows(&d);
13047 assert!(
13048 rows.iter().any(|r| r == "one here"),
13049 "{mode:?} hides: {rows:?}"
13050 );
13051 assert!(
13052 !rows.iter().any(|r| r.contains('*')),
13053 "{mode:?} shows no `*`: {rows:?}"
13054 );
13055 }
13056 }
13057
13058 #[test]
13059 fn revealed_delimiters_are_the_authors_own_spelling() {
13060 // Delimiters are re-read from the source rather than synthesized per
13061 // kind, so a line comes back spelled the way it was written: `_em_` does
13062 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
13063 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
13064 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
13065 d.set_markup_mode(MarkupMode::Full);
13066 caret_at(&mut d, "em");
13067 let rows = drawn_rows(&d);
13068 assert!(
13069 rows.iter().any(|r| r == body.trim_end()),
13070 "the revealed line is its own source: {rows:?}"
13071 );
13072 }
13073
13074 #[test]
13075 fn revealed_heading_shows_its_hashes() {
13076 // The `# ` marker is a block-level prefix, not an inline delimiter, so
13077 // it takes its own path — but it reveals on the same rule.
13078 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
13079 d.set_markup_mode(MarkupMode::Full);
13080
13081 caret_at(&mut d, "Title");
13082 assert!(
13083 drawn_rows(&d).iter().any(|r| r == "# Title"),
13084 "{:?}",
13085 drawn_rows(&d)
13086 );
13087
13088 caret_at(&mut d, "body");
13089 let rows = drawn_rows(&d);
13090 assert!(
13091 rows.iter().any(|r| r == "Title"),
13092 "hashes hidden again: {rows:?}"
13093 );
13094 }
13095
13096 #[test]
13097 fn revealed_delimiters_are_caret_stops() {
13098 // A delimiter that is drawn but can't be reached is worse than one
13099 // that's hidden: the mode exists so the markup can be *edited*. Every
13100 // revealed byte must be somewhere the caret can stand.
13101 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
13102 d.set_markup_mode(MarkupMode::Full);
13103 caret_at(&mut d, "em");
13104 let opener = d.source.find('*').unwrap();
13105 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
13106 assert!(
13107 d.vmap.is_stop(opener + 3),
13108 "the closing `*` is a caret stop"
13109 );
13110 }
13111
13112 #[test]
13113 fn setext_heading_reveals_nothing_across_its_newline() {
13114 // A setext heading's underline is on another line, so it is not the
13115 // caret line's to reveal — and emitting it would inject a `\n` glyph
13116 // that splits the row where the author wrote no break.
13117 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
13118 d.set_markup_mode(MarkupMode::Full);
13119 caret_at(&mut d, "Title");
13120 let rows = drawn_rows(&d);
13121 assert!(
13122 rows.iter().any(|r| r == "Title"),
13123 "title renders alone: {rows:?}"
13124 );
13125 assert!(
13126 !rows.iter().any(|r| r.contains('=')),
13127 "no underline leaks in: {rows:?}"
13128 );
13129 }
13130
13131 #[test]
13132 fn markup_mode_axes_split_the_ladder() {
13133 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
13134 // that authors markup but still hides it, and it's the only rung where
13135 // the two axes disagree.
13136 assert!(!MarkupMode::None.authors());
13137 assert!(!MarkupMode::None.reveals_caret_line());
13138 assert!(MarkupMode::Shortcuts.authors());
13139 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
13140 assert!(MarkupMode::Full.authors());
13141 assert!(MarkupMode::Full.reveals_caret_line());
13142 }
13143
13144 #[test]
13145 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
13146 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
13147 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
13148 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
13149 // nested bullet and `hello` stays prose: the file round-trips instead of
13150 // hiding a heading the user never asked for.
13151 for view in [View::Source, View::Wysiwyg] {
13152 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
13153 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
13154 d.indent();
13155 assert_eq!(d.source, "- hello\n * \n");
13156 assert!(
13157 d.nodes().iter().all(|n| n.kind != Kind::Heading),
13158 "no heading"
13159 );
13160 // And it's genuinely a nested list, not a flat one.
13161 assert_eq!(
13162 d.nodes()
13163 .iter()
13164 .filter(|n| n.kind == Kind::BulletList)
13165 .count(),
13166 2
13167 );
13168 }
13169 }
13170
13171 #[test]
13172 fn indenting_a_dash_item_with_content_keeps_its_dash() {
13173 // With content, `- x` can't be a setext underline, so there's nothing to
13174 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
13175 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
13176 d.caret = d.source.find('x').unwrap();
13177 d.indent();
13178 assert_eq!(d.source, "- hello\n - x\n");
13179 }
13180
13181 #[test]
13182 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
13183 // The dash→`*` repair coalesces into the Tab, so a single undo restores
13184 // the whole pre-Tab state rather than stranding a half-collapsed doc.
13185 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
13186 d.caret = d.source.find("- \n").unwrap() + 2;
13187 d.indent();
13188 assert_eq!(d.source, "- hello\n * \n");
13189 d.undo();
13190 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
13191 }
13192
13193 #[test]
13194 fn indent_leaves_a_nested_lists_first_item_put_too() {
13195 // The guard is about siblings, not depth: the first item of an *inner*
13196 // list (already nested under `a`) still has nothing before it at its own
13197 // level, so Tab can't take it deeper.
13198 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
13199 d.caret = d.source.find('b').unwrap();
13200 d.indent();
13201 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
13202 // But `c` (a sibling of `b`) nests under `b`.
13203 d.caret = d.source.find('c').unwrap();
13204 d.indent();
13205 assert_eq!(d.source, "- a\n - b\n - c\n");
13206 }
13207
13208 #[test]
13209 fn backspace_at_a_nested_item_start_outdents_it() {
13210 // Backspace with the caret right after a nested item's marker gives back
13211 // one level of nesting, the mirror of Tab — and renumbers the flattened
13212 // ordered list back to a clean run.
13213 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
13214 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
13215 d.backspace();
13216 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13217 }
13218
13219 #[test]
13220 fn backspace_at_a_top_level_item_start_strips_the_marker() {
13221 // At the outermost level there's no nesting left to give back, so the same
13222 // keystroke drops the bullet and leaves a plain paragraph.
13223 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
13224 d.caret = d.source.find('b').unwrap(); // right after `- `
13225 d.backspace();
13226 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
13227 }
13228
13229 #[test]
13230 fn backspace_mid_item_still_deletes_a_character() {
13231 // The list behaviour is armed only at the item's content start; anywhere
13232 // else Backspace is the ordinary character delete.
13233 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
13234 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
13235 d.backspace();
13236 assert_eq!(d.source, "- b\n");
13237 }
13238
13239 #[test]
13240 fn backspace_at_a_heading_start_strips_the_marker() {
13241 // The `# ` is markup the rich view hides, so Backspace over it takes the
13242 // whole marker and leaves a paragraph. Deleting a byte of it instead left
13243 // `#Title` — no longer a heading, with the hash now literal text the user
13244 // never typed and has to delete again.
13245 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
13246 d.caret = d.source.find('T').unwrap(); // right after `## `
13247 d.backspace();
13248 assert_eq!(d.source, "Title\n");
13249 assert_eq!(
13250 d.caret, 0,
13251 "the caret stays with the text it was in front of"
13252 );
13253 }
13254
13255 #[test]
13256 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
13257 // Only the heading's own marker goes — the quote (or list) it sits in is
13258 // untouched, exactly as un-heading it should be.
13259 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
13260 d.caret = d.source.find('T').unwrap();
13261 d.backspace();
13262 assert_eq!(d.source, "> Title\n");
13263 }
13264
13265 #[test]
13266 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
13267 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
13268 // behind would surface the same stray hash the marker delete just avoided.
13269 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
13270 d.caret = d.source.find('T').unwrap();
13271 d.backspace();
13272 assert_eq!(d.source, "Title\n");
13273 // And it's one edit: a single undo puts the whole heading back.
13274 d.undo();
13275 assert_eq!(d.source, "# Title #\n");
13276 }
13277
13278 #[test]
13279 fn backspace_mid_heading_still_deletes_a_character() {
13280 // The heading behaviour is armed only at the content's start; anywhere
13281 // else Backspace is the ordinary character delete.
13282 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
13283 d.caret = d.source.find('b').unwrap();
13284 d.backspace();
13285 assert_eq!(d.source, "# b\n");
13286 }
13287
13288 #[test]
13289 fn source_view_backspace_still_edits_the_heading_marker_literally() {
13290 // In source view the `# ` is text on the screen the user is deleting a
13291 // byte of, so it keeps its literal meaning — the same split the list
13292 // ladder and Enter draw between the two views.
13293 let mut d = doc_with("bsp_head_src", "# Title\n");
13294 d.caret = d.source.find('T').unwrap();
13295 d.backspace();
13296 assert_eq!(d.source, "#Title\n");
13297 }
13298
13299 #[test]
13300 fn outdent_unnests_an_ordered_item_in_one_press() {
13301 // Shift+Tab gives back exactly the marker width the indent added, so a
13302 // nested ordered item unnests in a single press, and the flattened list
13303 // renumbers back to a clean 1, 2, 3.
13304 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
13305 d.caret = d.source.find('b').unwrap();
13306 d.outdent();
13307 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13308 let lists = d
13309 .nodes()
13310 .iter()
13311 .filter(|n| n.kind == Kind::OrderedList)
13312 .count();
13313 assert_eq!(lists, 1, "back to one flat list");
13314 }
13315
13316 #[test]
13317 fn table_insert_row_adds_a_row_below_the_caret() {
13318 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13319 d.caret = d.source.find('1').unwrap(); // in the body row
13320 d.table_insert_row(true);
13321 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
13322 }
13323
13324 #[test]
13325 fn table_insert_and_delete_column_at_the_caret() {
13326 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13327 d.caret = d.source.find('a').unwrap(); // column 0
13328 d.table_insert_column(true); // add a column to the right of `a`
13329 assert_eq!(
13330 d.source,
13331 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
13332 );
13333 d.caret = d.source.find('b').unwrap(); // now the third column
13334 d.table_delete_column();
13335 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
13336 }
13337
13338 // ── ragged formats ───────────────────────────────────────────────────────
13339 // No format spells every gesture. HTML writes the inline marks as a tag pair
13340 // and no heading, list, quote or link; Markdown spells five of the eight
13341 // marks — the highlight only because leaf parses with `highlight`, which is
13342 // why the question is asked with the extensions; djot spells all eight and
13343 // no in-cell break. leaf asks twig per
13344 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
13345 // op discover the fact on its own — one of them didn't.
13346
13347 /// An HTML document in the rich view, ready for a gesture.
13348 fn html_doc(body: &str) -> Doc {
13349 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
13350 d.view = View::Wysiwyg;
13351 d.build_visual(80);
13352 d
13353 }
13354
13355 #[test]
13356 fn a_table_gesture_leaves_an_html_table_alone() {
13357 // The regression this guard exists for. twig's table editor consults no
13358 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
13359 // HTML `<table>` as a *pipe table* and reported success: the whole
13360 // element replaced by `| a | b |`, silently, on one press of a toolbar
13361 // button. Every grid op went the same way.
13362 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
13363 // A table of named operations, which is what it looks like.
13364 #[allow(clippy::type_complexity)]
13365 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
13366 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
13367 ("delete row", &|d: &mut Doc| d.table_delete_row()),
13368 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
13369 ("delete column", &|d: &mut Doc| d.table_delete_column()),
13370 ("align", &|d: &mut Doc| {
13371 d.table_set_alignment(Alignment::Right)
13372 }),
13373 ("move row", &|d: &mut Doc| d.table_move_row(true)),
13374 ("move column", &|d: &mut Doc| d.table_move_column(true)),
13375 ];
13376 for (name, op) in ops {
13377 let mut d = html_doc(src);
13378 d.caret = d.source.find('a').unwrap();
13379 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
13380 op(&mut d);
13381 assert_eq!(d.source, src, "{name} rewrote an HTML table");
13382 assert!(
13383 !d.dirty,
13384 "{name} marked the document dirty without editing it"
13385 );
13386 assert!(d.status.is_some(), "{name} refused without saying why");
13387 }
13388 }
13389
13390 #[test]
13391 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
13392 // A task box is a form control in HTML and a footnote has no native
13393 // spelling at all — the two gestures twig 3.5 still spells nothing
13394 // for, now that a quote, a list, a link and an image print through
13395 // its renderer (see the test below).
13396 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13397 // A table of named operations, which is what it looks like.
13398 #[allow(clippy::type_complexity)]
13399 let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
13400 ("task item", &|d: &mut Doc| d.toggle_task_item()),
13401 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
13402 ("footnote", &|d: &mut Doc| d.insert_footnote()),
13403 ];
13404 for (name, op) in ops {
13405 let mut d = html_doc(src);
13406 let at = d.source.find("Hello").unwrap();
13407 d.caret = at;
13408 d.anchor = Some(at + 5); // a selection, for the ops that want one
13409 op(&mut d);
13410 assert_eq!(d.source, src, "{name} edited an HTML document");
13411 assert!(
13412 !d.dirty,
13413 "{name} marked the document dirty without editing it"
13414 );
13415 let status = d.status.as_deref().unwrap_or("");
13416 assert!(
13417 status.contains("html"),
13418 "{name}: the refusal should name the format, got {status:?}"
13419 );
13420 }
13421 }
13422
13423 #[test]
13424 fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
13425 // twig 3.5: where HTML has no marker alphabet it prints the fresh
13426 // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
13427 // the paragraph as its item, an `<a>` or `<img>` over the selection.
13428 // Until then every one of these was a refusal; now each is a real
13429 // edit, which is what the toolbar's capability flags say too.
13430 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13431 #[allow(clippy::type_complexity)]
13432 let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
13433 (
13434 "quote",
13435 &|d: &mut Doc| d.toggle_blockquote(),
13436 "<blockquote>",
13437 ),
13438 ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
13439 (
13440 "ordered list",
13441 &|d: &mut Doc| d.toggle_list(true),
13442 "<ol>\n<li>",
13443 ),
13444 (
13445 "link",
13446 &|d: &mut Doc| d.insert_link("https://example.dev"),
13447 "<a href=\"https://example.dev\">Hello</a>",
13448 ),
13449 (
13450 "image",
13451 &|d: &mut Doc| d.insert_image("pic.png", "alt"),
13452 "<img alt=\"Hello\" src=\"pic.png\">",
13453 ),
13454 ];
13455 for (name, op, expect) in ops {
13456 let mut d = html_doc(src);
13457 let at = d.source.find("Hello").unwrap();
13458 d.caret = at;
13459 d.anchor = Some(at + 5);
13460 op(&mut d);
13461 assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
13462 assert!(d.dirty, "{name}: a real edit");
13463 assert_eq!(
13464 d.status, None,
13465 "{name}: a supported gesture reports nothing"
13466 );
13467 }
13468 }
13469
13470 #[test]
13471 fn html_spells_a_heading_as_its_tag_pair() {
13472 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
13473 // along — the one block gesture whose HTML shape it can write. So ⌘2
13474 // in an HTML document is a real edit, and ⌘0 takes it back.
13475 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
13476 let mut d = html_doc(src);
13477 d.caret = d.source.find("Hello").unwrap();
13478 d.toggle_heading(2);
13479 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
13480 assert!(d.dirty);
13481 assert_eq!(d.status, None, "a supported gesture reports nothing");
13482 d.toggle_heading(2);
13483 assert_eq!(d.source, src, "the same level again is back to a paragraph");
13484 }
13485
13486 #[test]
13487 fn html_spells_the_inline_marks_and_the_rule() {
13488 // The other half, and why one per-document flag stopped being enough:
13489 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
13490 // already emits and the parser reads straight back as the same mark —
13491 // and the rule button writes an `<hr>`. Refusing these on the old
13492 // "HTML is parse-only" reading would now be leaf's own limitation.
13493 let mut d = html_doc("<p>Hello world</p>\n");
13494 let at = d.source.find("world").unwrap();
13495 d.caret = at;
13496 d.anchor = Some(at + 5);
13497 d.toggle(InlineKind::Strong);
13498 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
13499 assert!(d.dirty);
13500 assert_eq!(d.status, None, "a supported gesture reports nothing");
13501
13502 // And off again — the toggle reverses, which is the property that makes
13503 // authoring in HTML worth offering rather than a one-way trip.
13504 d.toggle(InlineKind::Strong);
13505 assert_eq!(d.source, "<p>Hello world</p>\n");
13506
13507 let mut d = html_doc("<p>Hello world</p>\n");
13508 d.caret = d.source.find("world").unwrap();
13509 d.insert_thematic_break();
13510 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
13511 }
13512
13513 #[test]
13514 fn a_mark_the_format_cannot_spell_arms_nothing() {
13515 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
13516 // sticky mark for the next text typed. Guarding only the twig call
13517 // leaves that path live, promising a mark the gesture will not write and
13518 // then swallowing the error inside `insert`.
13519 //
13520 // Markdown carries this, on the superscript now rather than on the
13521 // highlight: `^x^` is text there in any configuration, whereas twig
13522 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
13523 // which every leaf document does.
13524 let mut d = doc_with("mark", "Hello world\n");
13525 d.view = View::Wysiwyg;
13526 d.build_visual(80);
13527 d.caret = d.source.find("world").unwrap();
13528 d.toggle(InlineKind::Superscript);
13529 assert!(d.pending_marks.is_empty(), "no mark should be armed");
13530 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
13531 d.insert("X");
13532 assert_eq!(d.source, "Hello Xworld\n");
13533 }
13534
13535 #[test]
13536 fn markdown_authors_a_highlight_and_a_strikethrough() {
13537 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
13538 // write. `==x==` is authorable because leaf's own `parse_extensions`
13539 // turns `highlight` on — twig will only mint bytes this editor's reparse
13540 // reads back — and `~~x~~` because GFM strikethrough is parsed by
13541 // default, so the refusal there was never right for any leaf document.
13542 for (kind, marked) in [
13543 (InlineKind::Mark, "a ==word== b\n"),
13544 (InlineKind::Delete, "a ~~word~~ b\n"),
13545 ] {
13546 let mut d = doc_with("author_mark", "a word b\n");
13547 d.anchor = Some(2);
13548 d.caret = 6;
13549 d.toggle(kind);
13550 assert_eq!(d.source, marked, "{kind:?}");
13551 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
13552 assert!(d.dirty, "{kind:?}");
13553 // The region stays selected, so the second press reverses it — the
13554 // property that separates authoring from a one-way trip.
13555 d.toggle(kind);
13556 assert_eq!(d.source, "a word b\n", "{kind:?}");
13557 }
13558 }
13559
13560 #[test]
13561 fn an_authored_highlight_reads_back_as_a_mark() {
13562 // The round trip the extension gate exists to protect: what the toggle
13563 // writes, the reparse must read back as a `mark` rather than as two
13564 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
13565 // rebuilt map rather than from the source text.
13566 let mut d = doc_with("mark_roundtrip", "a word b\n");
13567 d.view = View::Wysiwyg;
13568 d.build_visual(80);
13569 d.anchor = Some(2);
13570 d.caret = 6;
13571 d.toggle(InlineKind::Mark);
13572 assert_eq!(d.source, "a ==word== b\n");
13573 d.build_visual(80);
13574 let w = d
13575 .vmap
13576 .rows
13577 .iter()
13578 .flat_map(|r| r.glyphs.iter())
13579 .find(|g| g.ch == 'w')
13580 .expect("the highlighted word");
13581 assert_eq!(w.style.role, crate::Role::Mark(None));
13582 }
13583
13584 #[test]
13585 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
13586 // The three states of one gesture, in the order a palette is pressed:
13587 // an uncoloured highlight takes the prefix, a coloured one has it
13588 // replaced, and `None` takes it away with the space that was part of the
13589 // spelling.
13590 let mut d = doc_with("mark_colour", "a ==word== b\n");
13591 d.caret = d.source.find("word").unwrap();
13592 d.set_mark_color(Some(MarkColor::Red));
13593 assert_eq!(d.source, "a ==🔴 word== b\n");
13594 assert_eq!(d.status, None);
13595 assert!(d.dirty);
13596
13597 d.set_mark_color(Some(MarkColor::Blue));
13598 assert_eq!(d.source, "a ==🔵 word== b\n");
13599
13600 d.set_mark_color(None);
13601 assert_eq!(d.source, "a ==word== b\n");
13602 }
13603
13604 #[test]
13605 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
13606 // The prefix is written *before* the word, so an offset in the word has
13607 // to ride its width — a caret that stayed put would be a caret that
13608 // walked backwards through the text it was standing in.
13609 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
13610 let word = d.source.find("word").unwrap();
13611 d.caret = word + 2; // between `wo` and `rd`
13612 d.set_mark_color(Some(MarkColor::Red));
13613 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
13614
13615 // And back the other way when the prefix goes.
13616 d.set_mark_color(None);
13617 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
13618 }
13619
13620 #[test]
13621 fn the_colour_at_the_caret_is_what_the_palette_lights() {
13622 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
13623 d.caret = d.source.find("red").unwrap();
13624 assert!(d.caret_in_mark());
13625 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
13626
13627 d.caret = d.source.find("plain").unwrap();
13628 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
13629 assert_eq!(d.mark_color_at_caret(), None);
13630
13631 d.caret = d.source.find(" and ").unwrap() + 2;
13632 assert!(!d.caret_in_mark());
13633 assert_eq!(d.mark_color_at_caret(), None);
13634 }
13635
13636 #[test]
13637 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
13638 // The gesture colours a highlight that exists; it does not make one.
13639 // Two presses is the price of a coloured highlight from bare text, and
13640 // the reason is undo — one press that spliced twice would take two
13641 // presses to take back.
13642 let mut d = doc_with("mark_colour_none", "a word b\n");
13643 d.caret = d.source.find("word").unwrap();
13644 d.set_mark_color(Some(MarkColor::Red));
13645 assert_eq!(d.source, "a word b\n");
13646 assert!(d.status.is_some(), "it should say why");
13647 assert!(!d.dirty);
13648
13649 // Clearing where there is nothing to clear is the same refusal, not a
13650 // quiet success — the caret is in no highlight either way.
13651 d.status = None;
13652 d.set_mark_color(None);
13653 assert_eq!(d.source, "a word b\n");
13654 assert!(d.status.is_some());
13655 }
13656
13657 #[test]
13658 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
13659 // twig answers this one *successfully* with a `Change` describing some
13660 // earlier edit, so a caller that trusted the change would jump the caret
13661 // to wherever that was. Core answers it before asking.
13662 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
13663 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
13664 d.caret = d.source.find("word").unwrap();
13665 let (source, caret) = (d.source.clone(), d.caret);
13666 d.set_mark_color(None);
13667 assert_eq!(d.source, source);
13668 assert_eq!(
13669 d.caret, caret,
13670 "the caret must not ride a change that isn't one"
13671 );
13672 assert_eq!(d.status, None, "and it is not an error either");
13673 }
13674
13675 #[test]
13676 fn djot_spells_the_highlight_and_not_its_colour() {
13677 // The reason the palette is its own capability rather than the Highlight
13678 // button's: `{=word=}` is a highlight djot writes happily, and there is
13679 // no djot spelling for a colour on it.
13680 assert!(Capabilities::of(Format::Djot).mark);
13681 assert!(!Capabilities::of(Format::Djot).mark_color);
13682 assert!(Capabilities::of(Format::Markdown).mark_color);
13683
13684 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
13685 d.caret = d.source.find("word").unwrap();
13686 assert!(
13687 d.caret_in_mark(),
13688 "the caret is in a highlight all the same"
13689 );
13690 d.set_mark_color(Some(MarkColor::Red));
13691 assert_eq!(d.source, "a {=word=} b\n");
13692 assert!(
13693 d.status.as_deref().unwrap_or("").contains("djot"),
13694 "and the refusal names the document's format: {:?}",
13695 d.status
13696 );
13697 }
13698
13699 #[test]
13700 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
13701 // The round trip that matters for a palette: the bytes twig writes are
13702 // bytes its own reparse reads back as a colour, so the swatch that was
13703 // pressed is the swatch that lights afterwards.
13704 let mut d = doc_with("mark_colour_undo", "a word b\n");
13705 d.anchor = Some(2);
13706 d.caret = 6;
13707 d.toggle(InlineKind::Mark);
13708 d.caret = d.source.find("word").unwrap();
13709 d.set_mark_color(Some(MarkColor::Green));
13710 assert_eq!(d.source, "a ==🟢 word== b\n");
13711 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
13712
13713 // One splice, one step: the colour comes off and the highlight stays.
13714 d.undo();
13715 assert_eq!(d.source, "a ==word== b\n");
13716 d.undo();
13717 assert_eq!(d.source, "a word b\n");
13718 }
13719
13720 #[test]
13721 fn every_colour_leaf_names_is_one_twig_writes() {
13722 // The two enums are one vocabulary, and this is what says so: each of
13723 // leaf's colours writes an emoji twig's reparse reads back as *that*
13724 // colour, so `twig_mark_color`'s table cannot quietly pair red with
13725 // orange.
13726 for color in MarkColor::ALL {
13727 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13728 d.caret = d.source.find("word").unwrap();
13729 d.set_mark_color(Some(color));
13730 assert_eq!(d.status, None, "{color:?}");
13731 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13732 }
13733 }
13734
13735 #[test]
13736 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13737 // The two presses a coloured highlight is made of, in the state the
13738 // first one leaves: `toggle` selects the whole `==word==` and puts the
13739 // caret one past the closing `==`, which is *not* in the mark. Asking at
13740 // the caret alone would refuse to colour the highlight just written —
13741 // the selection's start is what answers.
13742 let mut d = doc_with("mark_colour_fresh", "a word b\n");
13743 d.anchor = Some(2);
13744 d.caret = 6;
13745 d.toggle(InlineKind::Mark);
13746 assert_eq!(d.source, "a ==word== b\n");
13747 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13748
13749 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13750 d.set_mark_color(Some(MarkColor::Yellow));
13751 assert_eq!(d.source, "a ==🟡 word== b\n");
13752 assert_eq!(d.status, None);
13753 }
13754
13755 #[test]
13756 fn one_press_highlights_a_selection_and_colours_it() {
13757 // What a toolbar swatch means over a plain selection, and the undo it
13758 // has to have: one press, one step. Two steps would leave an uncoloured
13759 // highlight behind on the way back, which is a state the author never
13760 // asked for and never saw.
13761 let mut d = doc_with("highlight_one", "a word b\n");
13762 d.anchor = Some(2);
13763 d.caret = 6;
13764 d.highlight(Some(MarkColor::Purple));
13765 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
13766 assert_eq!(d.status, None);
13767
13768 d.undo();
13769 assert_eq!(d.source, "a word b\n", "one press, one undo");
13770 }
13771
13772 #[test]
13773 fn one_press_on_an_existing_highlight_only_recolours_it() {
13774 // The other half: inside a highlight there is nothing to make, so the
13775 // compound is the plain gesture and the text is untouched.
13776 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
13777 d.caret = d.source.find("word").unwrap();
13778 d.highlight(Some(MarkColor::Blue));
13779 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
13780 d.undo();
13781 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
13782 }
13783
13784 #[test]
13785 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
13786 // `None` means "no colour", and over bare text that is the Highlight
13787 // button's own job. The fold must not happen here — there is no second
13788 // splice, and folding would take the *previous* edit into this one.
13789 let mut d = doc_with("highlight_none", "a word b and more\n");
13790 d.caret = d.source.find("more").unwrap() + 4; // after "more"
13791 d.insert("!"); // an earlier edit for a wrong fold to swallow
13792 d.anchor = Some(2);
13793 d.caret = 6;
13794 d.highlight(None);
13795 assert_eq!(d.source, "a ==word== b and more!\n");
13796
13797 d.undo();
13798 assert_eq!(
13799 d.source, "a word b and more!\n",
13800 "only the highlight came off"
13801 );
13802 d.undo();
13803 assert_eq!(
13804 d.source, "a word b and more\n",
13805 "and the edit before it survived"
13806 );
13807 }
13808
13809 #[test]
13810 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
13811 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
13812 // a colour cannot be armed with it — so the compound declines rather
13813 // than leaving half a promise.
13814 let mut d = doc_with("highlight_bare", "a word b\n");
13815 d.caret = 4;
13816 d.highlight(Some(MarkColor::Red));
13817 assert_eq!(d.source, "a word b\n");
13818 assert!(d.pending_marks.is_empty(), "and nothing armed");
13819 assert!(d.status.is_some());
13820 }
13821
13822 #[test]
13823 fn a_read_only_document_takes_no_colour() {
13824 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
13825 d.caret = d.source.find("word").unwrap();
13826 d.set_read_only(true);
13827 d.set_mark_color(Some(MarkColor::Red));
13828 assert_eq!(d.source, "a ==word== b\n");
13829 }
13830
13831 #[test]
13832 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
13833 // The other door into `toggle`: no selection, so nothing reaches twig
13834 // until `insert` realises the armed mark. It is armed now — the guard
13835 // above asks `Doc::supports`, which asks with the extensions — and what
13836 // it writes is the same `==…==`.
13837 let mut d = doc_with("sticky_mark", "xy\n");
13838 d.caret = 1;
13839 d.toggle(InlineKind::Mark);
13840 assert!(d.pending_marks.contains(InlineKind::Mark));
13841 d.insert("Z");
13842 assert_eq!(d.source, "x==Z==y\n");
13843 }
13844
13845 #[test]
13846 fn html_documents_still_take_typed_text() {
13847 // The guard covers *markup* gestures and must not touch plain editing:
13848 // twig's splicer is language-neutral, and typing into an HTML document
13849 // is the thing that does work today.
13850 let mut d = html_doc("<p>Hello world</p>\n");
13851 d.caret = d.source.find("world").unwrap();
13852 d.insert("big ");
13853 assert_eq!(d.source, "<p>Hello big world</p>\n");
13854 assert!(d.dirty);
13855 d.backspace();
13856 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
13857 d.undo();
13858 d.undo();
13859 assert_eq!(d.source, "<p>Hello world</p>\n");
13860 }
13861
13862 #[test]
13863 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
13864 // `authorable` only separates "there is a door in" from "there is not",
13865 // and HTML is on the near side of that line — which is exactly why a
13866 // toolbar must not be built from it.
13867 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
13868 assert!(html.authorable());
13869 assert!(
13870 !Doc::from_source("<r>x</r>".into(), Format::Xml)
13871 .unwrap()
13872 .authorable()
13873 );
13874
13875 let caps = html.capabilities();
13876 assert!(caps.bold && caps.italic && caps.code && caps.mark);
13877 assert!(caps.thematic_break && caps.cell_line_break);
13878 // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
13879 // quote, a list, a code block's language, a link and an image — each
13880 // printed as a fresh node where HTML has no marker to rewrite. A task
13881 // box is a form control and a footnote has no spelling, so those two
13882 // are what keeps the record ragged.
13883 assert!(caps.heading && caps.blockquote && caps.bullet_list);
13884 assert!(caps.link && caps.image && caps.code_language);
13885 assert!(!caps.task && !caps.footnote);
13886 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
13887 // twig's table editor would happily re-emit as `| a | b |`.
13888 assert!(!caps.table);
13889
13890 // The two lightweight formats spell everything leaf offers — and still
13891 // differ from each other, which is the other half of why one boolean
13892 // can't serve.
13893 for fmt in [Format::Markdown, Format::Djot] {
13894 let caps = Capabilities::of(fmt);
13895 assert!(
13896 caps.heading && caps.blockquote && caps.ordered_list,
13897 "{fmt:?}"
13898 );
13899 assert!(
13900 caps.task && caps.link && caps.image && caps.table,
13901 "{fmt:?}"
13902 );
13903 }
13904 // Both spell the highlight and the strikethrough: djot natively, and
13905 // Markdown because `Capabilities` asks with `parse_extensions` rather
13906 // than with twig's defaults — `==x==` is text under those, and a mark
13907 // under the `highlight` leaf always parses with.
13908 for fmt in [Format::Markdown, Format::Djot] {
13909 let caps = Capabilities::of(fmt);
13910 assert!(caps.mark && caps.strike, "{fmt:?}");
13911 }
13912 // What still separates them, now that the highlight doesn't: djot has
13913 // no in-cell break, and Markdown spells neither of the scripts.
13914 assert!(Capabilities::of(Format::Djot).superscript);
13915 assert!(!Capabilities::of(Format::Markdown).superscript);
13916 assert!(Capabilities::of(Format::Markdown).cell_line_break);
13917 assert!(!Capabilities::of(Format::Djot).cell_line_break);
13918
13919 // A parse-only format answers no to every one of them, so the coarse
13920 // predicate and the record agree there.
13921 let caps = Capabilities::of(Format::Xml);
13922 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
13923 }
13924
13925 #[test]
13926 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
13927 // The guard exists to name the *document's* format rather than twig's
13928 // internals, so the message has to survive being one leaf writes itself.
13929 // Checked against a gesture twig also refuses, since that is the pair
13930 // most at risk of drifting apart — the task box, once the code
13931 // language stopped being one (twig 3.5).
13932 let mut d = html_doc("<p>Hello</p>\n");
13933 d.caret = d.source.find("Hello").unwrap();
13934 d.toggle_task_item();
13935 assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
13936 assert!(!d.dirty);
13937 }
13938
13939 #[test]
13940 fn table_set_alignment_respells_the_delimiter() {
13941 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13942 d.caret = d.source.find('b').unwrap();
13943 d.table_set_alignment(Alignment::Right);
13944 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
13945 }
13946
13947 #[test]
13948 fn each_empty_table_cell_has_its_own_editable_home() {
13949 // Regression: an empty cell has no twig content_span, so both cells of a
13950 // `| | |` row collapsed onto the row's start (before the first `│`).
13951 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
13952 // tell the cells apart. Each empty cell must now have a distinct home
13953 // inside it.
13954 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
13955 let (c0, c1) = {
13956 let cells = &d.vmap.tables[0].grid[1].cells;
13957 (cells[0].start, cells[1].start)
13958 };
13959 assert!(
13960 c0 < c1,
13961 "the two empty cells have distinct homes: {c0} < {c1}"
13962 );
13963 d.caret = c0;
13964 d.insert("x");
13965 assert_eq!(
13966 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
13967 "typed inside the cell"
13968 );
13969 }
13970
13971 #[test]
13972 fn arrows_step_into_each_empty_table_cell() {
13973 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
13974 let (c0, c1) = {
13975 let cells = &d.vmap.tables[0].grid[1].cells;
13976 (cells[0].start, cells[1].start)
13977 };
13978 d.caret = d.source.find('b').unwrap(); // in the header's second cell
13979 let mut seen = std::collections::HashSet::new();
13980 for _ in 0..6 {
13981 d.move_right(false);
13982 seen.insert(d.caret);
13983 }
13984 assert!(
13985 seen.contains(&c0),
13986 "right arrow reaches the first empty cell"
13987 );
13988 assert!(
13989 seen.contains(&c1),
13990 "right arrow reaches the second empty cell"
13991 );
13992 }
13993
13994 #[test]
13995 fn table_op_off_a_table_is_a_no_op_with_a_status() {
13996 let mut d = doc_with("tbl_none", "just text\n");
13997 d.caret = 3;
13998 d.table_insert_row(true);
13999 assert_eq!(d.source, "just text\n", "nothing changed");
14000 assert!(d.status.is_some(), "a status explains why");
14001 assert!(!d.caret_in_table());
14002 }
14003
14004 #[test]
14005 fn enter_in_an_ordered_list_renumbers_the_following_items() {
14006 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
14007 // the renumber pass keeps them sequential, matching what the view draws.
14008 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
14009 d.caret = d.source.find('a').unwrap() + 1; // end of item a
14010 d.newline();
14011 d.insert("x");
14012 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
14013 }
14014
14015 #[test]
14016 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
14017 for view in [View::Source, View::Wysiwyg] {
14018 let mut d = doc_in(view, "outdent_noop", "hello\n");
14019 d.caret = 2;
14020 d.outdent();
14021 assert_eq!(d.source, "hello\n");
14022 assert!(!d.dirty, "a no-op is not a modification");
14023 d.undo();
14024 assert_eq!(
14025 d.status.as_deref(),
14026 Some("nothing to undo"),
14027 "spends no undo step"
14028 );
14029 assert_eq!(d.source, "hello\n");
14030 }
14031 }
14032
14033 #[test]
14034 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
14035 for view in [View::Source, View::Wysiwyg] {
14036 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
14037 d.anchor = Some(0);
14038 d.caret = 7; // through "two"
14039 d.indent();
14040 assert_eq!(
14041 d.source, " one\n\n two\n",
14042 "the blank line keeps no trailing pad"
14043 );
14044 // Selected, so a second Tab lands on the same lines rather than on
14045 // whatever the shifted offsets now cover.
14046 assert_eq!(d.selection(), Some((0, 12)));
14047 d.indent();
14048 assert_eq!(d.source, " one\n\n two\n");
14049 }
14050 }
14051
14052 #[test]
14053 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
14054 for view in [View::Source, View::Wysiwyg] {
14055 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
14056 d.anchor = Some(0);
14057 d.caret = 15;
14058 d.outdent();
14059 assert_eq!(d.source, "two\none\nnone\n");
14060 }
14061 }
14062
14063 #[test]
14064 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
14065 for view in [View::Source, View::Wysiwyg] {
14066 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
14067 d.anchor = Some(0);
14068 d.caret = 7;
14069 d.indent();
14070 assert_eq!(d.source, " one\n\n two\n");
14071 d.undo();
14072 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
14073 assert_eq!(
14074 d.selection(),
14075 Some((0, 7)),
14076 "with the selection it was aimed at"
14077 );
14078 d.redo();
14079 assert_eq!(d.source, " one\n\n two\n");
14080 assert_eq!(
14081 d.selection(),
14082 Some((0, 12)),
14083 "redo replays the caret the indent placed, not the one splice left"
14084 );
14085 }
14086 }
14087
14088 #[test]
14089 fn vertical_motion_keeps_the_column() {
14090 let mut d = doc_with("move", "abcd\nef\n");
14091 d.caret = 3; // "abc|d" on row 0, col 3
14092 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
14093 assert_eq!(d.caret, 7); // just after "ef"
14094 }
14095
14096 // ── goal column ──────────────────────────────────────────────────────────
14097
14098 #[test]
14099 fn vertical_motion_goal_column_survives_a_short_line() {
14100 // Regression: re-deriving the column from the clamped position on
14101 // every step permanently forgets it once a short line clamps it.
14102 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
14103 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
14104 assert_eq!(
14105 g("abcd|ef\nxy\nghijkl\n", |d| {
14106 d.move_down(false); // clamps to end of "xy"
14107 d.move_down(false); // restores col 4 on the long line
14108 }),
14109 "abcdef\nxy\nghij|kl\n"
14110 );
14111 }
14112
14113 #[test]
14114 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
14115 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
14116 assert_eq!(d.goal_col, None);
14117 d.caret = 4; // row 0, col 4
14118 d.move_down(false); // clamps into "xy"; goal stays the original col
14119 assert_eq!(d.goal_col, Some(4));
14120 assert_eq!(d.caret_pos(), (1, 2));
14121
14122 // A horizontal motion drops the goal column...
14123 d.move_left(false);
14124 assert_eq!(d.goal_col, None);
14125
14126 // ...so the next vertical motion picks up the *new* column (1), not
14127 // the stale one (4).
14128 d.move_down(false);
14129 assert_eq!(d.goal_col, Some(1));
14130 assert_eq!(d.caret_pos(), (2, 1));
14131 }
14132
14133 #[test]
14134 fn editing_clears_the_goal_column() {
14135 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
14136 d.caret = 4;
14137 d.move_down(false);
14138 assert_eq!(d.goal_col, Some(4));
14139 d.insert("Z");
14140 assert_eq!(d.goal_col, None);
14141 }
14142
14143 #[test]
14144 fn vertical_motion_on_an_empty_document_is_a_no_op() {
14145 let mut d = doc_with("empty_vert", "");
14146 d.move_down(false);
14147 assert_eq!(d.caret, 0);
14148 d.move_up(false);
14149 assert_eq!(d.caret, 0);
14150 }
14151
14152 // ── the document's edges ─────────────────────────────────────────────────
14153
14154 #[test]
14155 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
14156 // The reproduction, and the disagreement: Down on the last line ran to
14157 // the end of the document in the source view — by accident, an
14158 // out-of-range row clamping to the end of the string — and did nothing
14159 // whatever in the view leaf opens in. One rule now, in both.
14160 for (view, tag) in VIEWS {
14161 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
14162 d.caret = 1;
14163 d.move_down(false);
14164 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
14165 d.move_up(false);
14166 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
14167 }
14168 }
14169
14170 #[test]
14171 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
14172 // Down off the bottom is a motion like any other, so it latches a goal
14173 // column — and Up comes back to the column the caret left, not to the
14174 // one the document's end happened to be in.
14175 for (view, tag) in VIEWS {
14176 let gap = if view == View::Source { "\n" } else { "\n\n" };
14177 let src = format!("abcdef{gap}ghijkl");
14178 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
14179 d.caret = 2; // row 0, col 2
14180 d.move_down(false);
14181 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
14182 d.move_down(false);
14183 assert_eq!(
14184 d.caret,
14185 src.len(),
14186 "{tag}: Down off the bottom reaches the end"
14187 );
14188 d.move_up(false);
14189 assert_eq!(
14190 d.caret_pos().1,
14191 2,
14192 "{tag}: Up returns to the column Down left"
14193 );
14194 }
14195 }
14196
14197 #[test]
14198 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
14199 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
14200 // Up that did nothing still armed a goal column, and the next Down aimed
14201 // at a column the caret had never been in.
14202 for (view, tag) in VIEWS {
14203 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
14204 d.caret = 0;
14205 d.move_up(false);
14206 assert_eq!(d.caret, 0, "{tag}: already at the start");
14207 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
14208
14209 d.caret = d.source.len();
14210 d.move_down(false);
14211 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
14212 assert_eq!(
14213 d.goal_col, None,
14214 "{tag}: a no-op Down latched a goal column"
14215 );
14216 }
14217 }
14218
14219 // ── soft wrap ────────────────────────────────────────────────────────────
14220 // Every other test here builds the map at 80 columns, where no fixture is
14221 // long enough to fold. A wrap is where one offset belongs to two rows at
14222 // once, and it broke everything that asks the caret what row it is on.
14223
14224 /// The wrapped fixture these cases share, folded at 12 columns into
14225 /// `one two ` / `three four ` / `five six ` / `seven eight`.
14226 fn wrapped_doc(name: &str) -> Doc {
14227 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
14228 d.build_visual(12);
14229 d
14230 }
14231
14232 #[test]
14233 fn home_and_end_work_from_a_wrapped_row() {
14234 // The reproduction: offset 19 is the `f` of "five", the first character
14235 // of the third row — and also the offset the second row ends at. It
14236 // resolved to the *second* row, so End aimed at a place the caret was
14237 // already in and did nothing, while Home walked backwards onto a row the
14238 // caret had left.
14239 let mut d = wrapped_doc("wrap_home_end");
14240 d.caret = 19;
14241 assert_eq!(
14242 d.caret_pos(),
14243 (2, 0),
14244 "the wrap boundary opens the third row"
14245 );
14246 d.move_end(false);
14247 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
14248 d.move_home(false);
14249 assert_eq!(d.caret, 19, "Home left the row the caret was on");
14250 }
14251
14252 #[test]
14253 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
14254 // The row's end is the last offset that is only ever its own: the offset
14255 // past it opens the row below, and aiming there would send a second
14256 // press on to *that* row's end, and a third to the next — End walking
14257 // down the paragraph rather than sitting where it landed.
14258 let mut d = wrapped_doc("wrap_end_twice");
14259 d.caret = 12; // inside "three", on the second row
14260 d.move_end(false);
14261 assert_eq!(
14262 d.caret, 18,
14263 "the end of `three four`, before the space the wrap ate"
14264 );
14265 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
14266 d.move_end(false);
14267 assert_eq!(d.caret, 18, "a second End moved the caret");
14268 d.move_home(false);
14269 assert_eq!(d.caret, 8, "Home takes the row's own start");
14270 }
14271
14272 #[test]
14273 fn vertical_motion_crosses_a_soft_wrap() {
14274 // Down aimed at the row below's column 0, an offset that resolved *up*
14275 // to the row above's end — so it landed on the offset it already had and
14276 // the caret could never leave a paragraph's first row.
14277 let mut d = wrapped_doc("wrap_down");
14278 d.caret = 0;
14279 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
14280 d.move_down(false);
14281 assert_eq!(d.caret, want, "Down stalled");
14282 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
14283 }
14284 d.move_down(false);
14285 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
14286
14287 // ...and back up, one row per press. The goal column is the end of the
14288 // last row, past every other row's width, so each press clamps to the
14289 // row's own last offset rather than to the one that opens the next.
14290 let mut d = wrapped_doc("wrap_up");
14291 d.caret = 39;
14292 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
14293 d.move_up(false);
14294 assert_eq!(d.caret, want, "Up stalled");
14295 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
14296 }
14297 }
14298
14299 #[test]
14300 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
14301 // The kills take the same line Home and End do, so in WYSIWYG they take
14302 // the visual row — and a soft wrap has no newline in it to delete, so
14303 // nothing is joined by reaching the end of one.
14304 let mut d = wrapped_doc("wrap_kill");
14305 d.caret = 19; // the `f` of "five", opening the third row
14306 d.delete_to_line_end();
14307 // The space the wrap ate goes with the row it was drawn on: sparing it
14308 // would leave "four seven", two spaces where the row had been.
14309 assert_eq!(d.source, "one two three four seven eight");
14310
14311 // Backwards from the row's last caret position — which is *before* that
14312 // space, so this one survives, being on the far side of the caret.
14313 let mut d = wrapped_doc("wrap_kill_back");
14314 d.caret = 27;
14315 d.delete_to_line_start();
14316 assert_eq!(d.source, "one two three four seven eight");
14317 }
14318
14319 // ── document start / end ────────────────────────────────────────────────
14320
14321 #[test]
14322 fn move_doc_start_and_end_jump_to_the_edges() {
14323 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
14324 assert_eq!(
14325 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
14326 "|hello\nworld\n"
14327 );
14328 assert_eq!(
14329 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
14330 "hello\nworld\n|"
14331 );
14332 // Already at the edge: a no-op.
14333 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
14334 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
14335 }
14336
14337 #[test]
14338 fn move_doc_start_and_end_extend_the_selection() {
14339 assert_eq!(
14340 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
14341 .move_doc_end(true)),
14342 "hello wor[ld\n|]"
14343 );
14344 assert_eq!(
14345 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
14346 .move_doc_start(true)),
14347 "[|hello wor]ld\n"
14348 );
14349 }
14350
14351 #[test]
14352 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
14353 let mut d = doc_with("empty_edges", "");
14354 d.move_doc_end(false);
14355 assert_eq!(d.caret, 0);
14356 d.move_doc_start(false);
14357 assert_eq!(d.caret, 0);
14358 }
14359
14360 // ── arrow collapses an active selection ─────────────────────────────────
14361
14362 #[test]
14363 fn arrow_collapses_selection_to_its_near_edge() {
14364 let mut d = doc_with("collapse", "hello world\n");
14365
14366 // Forward selection (anchor before caret): Right -> end, Left -> start.
14367 d.anchor = Some(2);
14368 d.caret = 7;
14369 d.move_right(false);
14370 assert_eq!((d.caret, d.anchor), (7, None));
14371
14372 d.anchor = Some(2);
14373 d.caret = 7;
14374 d.move_left(false);
14375 assert_eq!((d.caret, d.anchor), (2, None));
14376
14377 // Backward selection (anchor after caret): edges are the same
14378 // regardless of which end the caret started on.
14379 d.anchor = Some(7);
14380 d.caret = 2;
14381 d.move_right(false);
14382 assert_eq!((d.caret, d.anchor), (7, None));
14383
14384 d.anchor = Some(7);
14385 d.caret = 2;
14386 d.move_left(false);
14387 assert_eq!((d.caret, d.anchor), (2, None));
14388 }
14389
14390 #[test]
14391 fn arrow_with_extend_keeps_growing_the_selection() {
14392 let mut d = doc_with("collapse_extend", "hello world\n");
14393 d.anchor = Some(2);
14394 d.caret = 7;
14395 d.move_right(true); // extend: no collapse, caret steps one further
14396 assert_eq!((d.caret, d.anchor), (8, Some(2)));
14397 }
14398
14399 #[test]
14400 fn arrow_without_a_selection_moves_one_character_as_before() {
14401 let mut d = doc_with("no_collapse", "hello\n");
14402 d.caret = 2;
14403 d.move_right(false);
14404 assert_eq!(d.caret, 3);
14405 d.move_left(false);
14406 assert_eq!(d.caret, 2);
14407 }
14408
14409 /// Press Right until it stops, collecting the offsets walked through. Every
14410 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
14411 /// two stops sharing one source offset can't be moved between, so the caret
14412 /// stalls on the first of them and the walk never reaches the rest.
14413 fn walk_right(d: &mut Doc) -> Vec<usize> {
14414 let mut seen = vec![d.caret];
14415 for _ in 0..2000 {
14416 let before = d.caret;
14417 d.move_right(false);
14418 if d.caret == before {
14419 break;
14420 }
14421 seen.push(d.caret);
14422 }
14423 seen
14424 }
14425
14426 #[test]
14427 fn the_caret_crosses_a_soft_break() {
14428 // A newline inside a paragraph is a `soft_break`, which twig gives no
14429 // span of its own — the space it renders as used to borrow the offset of
14430 // the character before it, and a caret can't move without changing
14431 // offset. Right must walk clean off the end of the first line.
14432 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
14433 d.caret = 0;
14434 let seen = walk_right(&mut d);
14435 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14436 }
14437
14438 #[test]
14439 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
14440 // The paragraph holds one soft break. Folded (the default) it lays out as
14441 // a single reflowed row; Preserve re-lays it as a row per source line.
14442 // The setter must invalidate the cached map for the change to show, and
14443 // again on the way back — so a round trip returns to the folded layout.
14444 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
14445 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
14446 d.build_visual(80);
14447 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
14448
14449 d.set_line_flow(LineFlow::Preserve);
14450 d.build_visual(80);
14451 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
14452
14453 d.set_line_flow(LineFlow::Fold);
14454 d.build_visual(80);
14455 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
14456 }
14457
14458 #[test]
14459 fn the_caret_still_crosses_a_preserved_soft_break() {
14460 // Preserve renders the soft break as a row boundary rather than a space,
14461 // but the caret must still reach every offset — the break's own offset is
14462 // the first row's end stop, so Right walks clean off the end of line one
14463 // onto line two, exactly as it does when the break is folded.
14464 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
14465 d.set_line_flow(LineFlow::Preserve);
14466 d.build_visual(80);
14467 d.caret = 0;
14468 let seen = walk_right(&mut d);
14469 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14470 }
14471
14472 #[test]
14473 fn the_caret_walks_a_code_block() {
14474 // Every glyph of a code block used to map to the block's start, so the
14475 // whole block was a single offset and the caret couldn't move inside it.
14476 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
14477 let mut d = wysiwyg_doc("code_walk", src);
14478 d.caret = 0;
14479 let seen = walk_right(&mut d);
14480 // The fences are markup: hidden, and no caret stop. The code between
14481 // them is reached a character at a time.
14482 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
14483 for off in code.clone() {
14484 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
14485 }
14486 assert!(seen.contains(&code.end), "no stop after the last line");
14487 }
14488
14489 #[test]
14490 fn the_caret_walks_an_indented_code_block() {
14491 // An indented block's text has the four-space indent stripped, so it
14492 // isn't a verbatim slice and its lines have to be re-found. The caret
14493 // lands on the code, never in the indent.
14494 let src = " indented\n code\n";
14495 let mut d = wysiwyg_doc("indent_code_walk", src);
14496 d.caret = 0;
14497 let seen = walk_right(&mut d);
14498 assert!(seen.contains(&src.find("indented").unwrap()));
14499 assert!(seen.contains(&src.find("code").unwrap()));
14500 assert!(
14501 !seen.contains(&0) || seen[0] == 0,
14502 "the caret starts where it was put"
14503 );
14504 // Nothing in the stripped indent is a stop.
14505 for off in [1, 2, 3] {
14506 assert!(!seen.contains(&off), "landed in the indent at {off}");
14507 }
14508 }
14509
14510 #[test]
14511 fn the_caret_leaves_a_tight_heading() {
14512 // "# H" with text directly under it: the heading row's end and the
14513 // separator row's end are the same offset. Right used to find the
14514 // separator's copy, set the caret to where it already was, and stop.
14515 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
14516 d.caret = 2; // the "H"
14517 let seen = walk_right(&mut d);
14518 assert!(
14519 seen.len() > 2,
14520 "Right stalled at the heading's end: {seen:?}"
14521 );
14522 assert!(
14523 seen.contains(&8),
14524 "never reached the end of \"text\": {seen:?}"
14525 );
14526 }
14527
14528 #[test]
14529 fn the_caret_skips_the_gap_between_two_paragraphs() {
14530 // The blank line between two paragraphs is the boundary itself. The
14531 // caret used to be able to sit on it, and typing there landed in the
14532 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
14533 // soft break, so the text visibly snapped back up.
14534 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
14535 d.caret = 1; // the end of "A"
14536 d.move_right(false);
14537 assert_eq!(d.caret, 3, "Right stopped in the gap");
14538 d.insert("x");
14539 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
14540 }
14541
14542 #[test]
14543 fn down_from_a_paragraph_lands_on_the_next_one() {
14544 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
14545 d.caret = 0;
14546 d.move_down(false);
14547 assert_eq!(d.caret, 3, "Down stopped in the gap");
14548 }
14549
14550 #[test]
14551 fn clicking_the_gap_lands_on_real_text() {
14552 // A click can still *reach* the gap — it's drawn, so it's clickable.
14553 // It has to resolve to somewhere the caret can be.
14554 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
14555 d.click(1, 0, false); // the gap row
14556 assert!(
14557 d.caret == 1 || d.caret == 3,
14558 "click left the caret in the gap at {}",
14559 d.caret
14560 );
14561 d.insert("x");
14562 // Either edge of the boundary is a fair place to land; inside it isn't.
14563 assert!(
14564 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
14565 "click in the gap typed into the boundary: {:?}",
14566 d.source
14567 );
14568 }
14569
14570 #[test]
14571 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
14572 // Enter inserts a paragraph break, which leaves a blank line spare on
14573 // either side of a new one. That middle line is a real empty paragraph:
14574 // the caret lands there, and typing makes a paragraph rather than
14575 // extending a neighbour.
14576 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
14577 d.caret = 1;
14578 d.newline();
14579 assert_eq!(d.source, "A\n\n\n\nB\n");
14580 d.build_visual(80);
14581 let (row, _) = d.caret_pos();
14582 assert!(
14583 d.vmap.row_is_navigable(row),
14584 "the caret landed on a gap row"
14585 );
14586 d.insert("x");
14587 assert_eq!(
14588 d.source, "A\n\nx\n\nB\n",
14589 "the new paragraph merged into a neighbour"
14590 );
14591 }
14592
14593 #[test]
14594 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
14595 let mut d = wysiwyg_doc("gap_eof", "A\n");
14596 d.caret = 1;
14597 d.newline();
14598 d.build_visual(80);
14599 let (row, _) = d.caret_pos();
14600 assert!(
14601 d.vmap.row_is_navigable(row),
14602 "the caret landed on a gap row"
14603 );
14604 d.insert("x");
14605 assert!(
14606 d.source.starts_with("A\n\n") && d.source.contains('x'),
14607 "typing at the end merged into A: {:?}",
14608 d.source
14609 );
14610 }
14611
14612 #[test]
14613 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
14614 // A paragraph broken over two source lines is one paragraph. Selecting
14615 // it must not stop at the newline inside it — that newline is markup the
14616 // rich-text view exists to hide.
14617 let src = "one two\nthree four\n\nnext\n";
14618 let mut d = wysiwyg_doc("triple_para", src);
14619 d.select_block_at(2);
14620 assert_eq!(
14621 d.selected_text(),
14622 Some("one two\nthree four"),
14623 "stopped at the soft break"
14624 );
14625 }
14626
14627 #[test]
14628 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
14629 // The reader scrolls down past the caret's row. Nothing moved the
14630 // caret, so the view must stay where it was put — the old code revealed
14631 // the caret every frame, which dragged the view straight back and made
14632 // the document unscrollable past the caret.
14633 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
14634 d.caret = 0;
14635 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
14636 d.scroll = 4; // the wheel
14637 d.follow_caret(0, 3, 9);
14638 assert_eq!(
14639 d.scroll, 4,
14640 "the wheel was overruled by a caret that never moved"
14641 );
14642 }
14643
14644 #[test]
14645 fn moving_the_caret_brings_the_view_back_to_it() {
14646 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
14647 d.caret = 0;
14648 d.follow_caret(0, 3, 9);
14649 d.scroll = 6; // scrolled away
14650 d.move_right(false); // ...and now the caret moves
14651 let (row, _) = d.caret_pos();
14652 d.follow_caret(row, 3, 9);
14653 assert!(
14654 d.scroll <= row && row < d.scroll + 3,
14655 "caret row {row} off screen at scroll {}",
14656 d.scroll
14657 );
14658 }
14659
14660 #[test]
14661 fn scrolling_stops_at_the_last_row() {
14662 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
14663 d.caret = 0;
14664 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
14665 d.scroll = 999; // the wheel, spun hard
14666 d.follow_caret(0, 3, 3);
14667 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
14668 }
14669
14670 #[test]
14671 fn every_cell_of_a_wide_table_is_reachable() {
14672 // A table whose cells are far wider than the surface: the columns are
14673 // cut to fit and the text wraps inside them, so no cell hangs off the
14674 // right edge where the caret can never go.
14675 let src = "| Ingredient | Notes |\n|---|---|\n\
14676 | flour milled coarse | sift it twice before folding it in |\n";
14677 let mut d = wysiwyg_doc("wide_table_walk", src);
14678 d.build_visual(30);
14679 d.caret = 0;
14680 let seen = walk_right(&mut d);
14681 for word in ["Ingredient", "Notes", "coarse", "folding"] {
14682 let at = src.find(word).unwrap();
14683 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
14684 }
14685 }
14686
14687 // ── view parity ──────────────────────────────────────────────────────────
14688 // `doc_with` pins the source view, so everything above tests a view users
14689 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
14690 // deletion golden cases through *both*, plus the WYSIWYG cases the two
14691 // can't share: where the source carries markup the rendered text is a
14692 // different string, and the views agreeing would itself be the bug.
14693
14694 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
14695
14696 /// Run `action` in both views on one `|`-marked fixture and assert they
14697 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
14698 /// source verbatim, so the two views are looking at the same text and any
14699 /// disagreement is one of them having lost the plot.
14700 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
14701 let (src, caret) = parse_caret(marked);
14702 let run = |view: View, tag: &str| {
14703 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
14704 d.caret = caret;
14705 action(&mut d);
14706 render_caret(&d)
14707 };
14708 let source = run(VIEWS[0].0, VIEWS[0].1);
14709 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
14710 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
14711 source
14712 }
14713
14714 #[test]
14715 fn word_motion_agrees_across_the_views_on_plain_prose() {
14716 let g = both_views;
14717 assert_eq!(
14718 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
14719 "hello |world"
14720 );
14721 assert_eq!(
14722 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
14723 "|hello world"
14724 );
14725 assert_eq!(
14726 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
14727 "hello| world"
14728 );
14729 assert_eq!(
14730 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
14731 "hello world|"
14732 );
14733 assert_eq!(
14734 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
14735 "foo|.bar"
14736 );
14737 assert_eq!(
14738 g("par_ext", "hello |world", |d| d.move_word_right(true)),
14739 "hello [world|]"
14740 );
14741 }
14742
14743 #[test]
14744 fn word_deletion_agrees_across_the_views_on_plain_prose() {
14745 let g = both_views;
14746 assert_eq!(
14747 g("par_db", "hello world|", |d| d.delete_word_back()),
14748 "hello |"
14749 );
14750 assert_eq!(
14751 g("par_df", "hello |world", |d| d.delete_word_forward()),
14752 "hello |"
14753 );
14754 assert_eq!(
14755 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
14756 "|bar baz"
14757 );
14758 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
14759 }
14760
14761 #[test]
14762 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
14763 let g = both_views;
14764 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
14765 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
14766 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
14767 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
14768 }
14769
14770 #[test]
14771 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
14772 // The reproduction: the stop table was built one stop per `char`, so
14773 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
14774 // source view, which steps by grapheme, can't reach and backspace can't
14775 // survive. The two views must land on the same offset.
14776 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
14777 for (view, tag) in VIEWS {
14778 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
14779 d.caret = 1;
14780 d.move_right(false);
14781 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
14782
14783 // ...and the edit that used to sever a joiner off the front of it.
14784 d.backspace();
14785 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
14786 assert_eq!(d.caret, 1);
14787 }
14788 }
14789
14790 #[test]
14791 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
14792 for (view, tag) in VIEWS {
14793 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
14794 d.caret = 0;
14795 d.move_right(false);
14796 assert_eq!(
14797 d.caret,
14798 "e\u{0301}".len(),
14799 "{tag} stopped on the combining mark"
14800 );
14801 }
14802 }
14803
14804 #[test]
14805 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
14806 // The general form: whatever route the caret takes through a document
14807 // full of clusters, it never lands between the codepoints of one — so no
14808 // motion-then-backspace sequence can leave a dangling joiner behind.
14809 use unicode_segmentation::UnicodeSegmentation;
14810
14811 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
14812 let mut d = wysiwyg_doc("cluster_walk", src);
14813 d.caret = 0;
14814 let boundaries: Vec<usize> = src
14815 .grapheme_indices(true)
14816 .map(|(i, _)| i)
14817 .chain(std::iter::once(src.len()))
14818 .collect();
14819 for off in walk_right(&mut d) {
14820 assert!(
14821 boundaries.contains(&off),
14822 "Right stopped at {off}, inside a grapheme cluster"
14823 );
14824 }
14825 }
14826
14827 #[test]
14828 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
14829 // The reproduction: ⌥→ from inside the opening `**` computed its
14830 // boundary over the raw source and landed on byte 8 — inside the
14831 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
14832 // "bold". The caret drew past the bold word and sat inside it.
14833 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
14834 d.caret = 2;
14835 d.move_word_right(false);
14836 assert!(
14837 d.vmap.is_stop(d.caret),
14838 "landed at {}, not a caret stop",
14839 d.caret
14840 );
14841 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
14842 // The rendered row is "a bold c": column 6 is the space just past "bold",
14843 // and now the caret is really there rather than only drawn there.
14844 assert_eq!(d.caret_pos(), (0, 6));
14845
14846 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
14847 d.move_word_left(false);
14848 assert_eq!(d.caret, 4);
14849 assert_eq!(d.caret_pos(), (0, 2));
14850 }
14851
14852 #[test]
14853 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
14854 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
14855 // inside the closing `**`, and left "a ** c\n" — delimiters with no
14856 // opener. Glyph space covers the word alone, which would leave
14857 // "a **** c": markup wrapped around nothing. The word and the styling
14858 // that was only ever the word's go together.
14859 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
14860 d.caret = 10;
14861 d.delete_word_back();
14862 assert_eq!(d.source, "a c\n");
14863 assert_eq!(d.caret, 2);
14864
14865 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
14866 d.caret = 4; // the "b"
14867 d.delete_word_forward();
14868 assert_eq!(d.source, "a c\n");
14869 }
14870
14871 #[test]
14872 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
14873 let src = "a ***bold*** c\n";
14874 let mut d = wysiwyg_doc("wys_word_del_nest", src);
14875 d.caret = src.find(" c").unwrap();
14876 d.delete_word_back();
14877 assert_eq!(
14878 d.source, "a c\n",
14879 "the emph inside the strong empties it too"
14880 );
14881
14882 let src = "a `code` c\n";
14883 let mut d = wysiwyg_doc("wys_word_del_code", src);
14884 d.caret = src.find(" c").unwrap();
14885 d.delete_word_back();
14886 assert_eq!(d.source, "a c\n");
14887 }
14888
14889 #[test]
14890 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
14891 // Only an *emptied* node goes. Take one word of two and the `**` still
14892 // has a job to do — over the word that's left, with the space the delete
14893 // pushed against the opening delimiter moved out in front of it, or the
14894 // run would be no run at all (`** words**` is literal asterisks — see
14895 // the mark-edge rule on `splice`).
14896 let src = "a **two words** c\n";
14897 let mut d = wysiwyg_doc("wys_word_del_partial", src);
14898 d.caret = src.find(" words").unwrap();
14899 d.delete_word_back();
14900 assert_eq!(d.source, "a **words** c\n");
14901 }
14902
14903 #[test]
14904 fn source_view_word_motion_still_walks_the_markup() {
14905 // The other half of the decision: in the source view the `**` are
14906 // characters like any other — they're on the screen, so word motion has
14907 // to stop at them and a word-delete has to leave them behind. Only
14908 // WYSIWYG hides them, so only WYSIWYG steps over them.
14909 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
14910 assert_eq!(
14911 g("src_word_motion", "a |**bold** c\n", |d| d
14912 .move_word_right(false)),
14913 "a **bold|** c\n"
14914 );
14915 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
14916 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
14917 assert_eq!(
14918 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
14919 "a **| c\n"
14920 );
14921 }
14922
14923 #[test]
14924 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
14925 // The single invariant both bugs violated: the caret draws and edits at
14926 // the same place only when it's on a stop. `debug_assert_on_a_stop`
14927 // makes the same claim in-place; this pins it from the outside, over a
14928 // document with every kind of thing the map has to be careful about.
14929 // At two widths: the wide one every other test builds at, where no
14930 // fixture folds, and one narrow enough that they all do. A soft wrap is
14931 // where an offset stops being on exactly one row, and testing only the
14932 // width that never wraps is how the caret came to be pinned at the first
14933 // one Down reached.
14934 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
14935 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
14936 // A table of named operations, which is what it looks like.
14937 #[allow(clippy::type_complexity)]
14938 let motions: [(&str, fn(&mut Doc)); 8] = [
14939 ("right", |d| d.move_right(false)),
14940 ("left", |d| d.move_left(false)),
14941 ("word_right", |d| d.move_word_right(false)),
14942 ("word_left", |d| d.move_word_left(false)),
14943 ("down", |d| d.move_down(false)),
14944 ("up", |d| d.move_up(false)),
14945 ("home", |d| d.move_home(false)),
14946 ("end", |d| d.move_end(false)),
14947 ];
14948 for width in [80, 12] {
14949 let mut d = wysiwyg_doc("stop_invariant", src);
14950 d.build_visual(width);
14951 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14952 assert!(stops.len() > 20, "fixture should have plenty of stops");
14953 for start in stops {
14954 for (name, motion) in &motions {
14955 d.caret = start;
14956 d.anchor = None;
14957 motion(&mut d);
14958 assert!(
14959 d.vmap.is_stop(d.caret),
14960 "{name} from {start} at width {width} landed at {} — not a caret stop",
14961 d.caret
14962 );
14963 }
14964 }
14965 }
14966 }
14967
14968 #[test]
14969 fn no_wysiwyg_motion_is_a_dead_end() {
14970 // Down held to the bottom of a document reaches the bottom, and Up held
14971 // to the top reaches the top — from anywhere, at a width that wraps. The
14972 // invariant above says a motion lands somewhere legal; this one says it
14973 // gets somewhere at all, which is what a caret pinned at a wrap boundary
14974 // was quietly failing to do while every assertion around it held.
14975 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
14976 - item one two three four five\n\nlast\n";
14977 for width in [80, 12] {
14978 let mut d = wysiwyg_doc("no_dead_end", src);
14979 d.build_visual(width);
14980 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14981 let (first, last) = (stops[0], stops[stops.len() - 1]);
14982 for &start in &stops {
14983 for (name, motion, want) in [
14984 (
14985 "down",
14986 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
14987 last,
14988 ),
14989 ("up", |d: &mut Doc| d.move_up(false), first),
14990 ] {
14991 d.caret = start;
14992 d.anchor = None;
14993 d.goal_col = None;
14994 // Every row, plus the presses the edges take, plus slack.
14995 for _ in 0..d.vmap.num_rows() + 4 {
14996 motion(&mut d);
14997 }
14998 assert_eq!(
14999 d.caret, want,
15000 "{name} held from {start} at width {width} never arrived"
15001 );
15002 }
15003 }
15004 }
15005 }
15006 // ── display columns ──────────────────────────────────────────────────────
15007 // A `col` is a terminal cell, not a character. The two are the same number
15008 // for the ASCII the fixtures above are written in, which is how they came
15009 // apart in the first place: `你` is one character drawn in two cells, so a
15010 // column counted in characters names a cell the text isn't in — one earlier
15011 // for every wide character to its left.
15012
15013 #[test]
15014 fn a_wide_character_is_two_columns_wide() {
15015 // The reproduction: `你` is one char and two cells, so the caret just
15016 // past it drew at column 1 — inside the character it had already left.
15017 for (view, tag) in VIEWS {
15018 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
15019 d.caret = "你".len();
15020 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
15021 d.caret = "你好".len();
15022 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
15023 }
15024 }
15025
15026 #[test]
15027 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
15028 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
15029 // two-cell — measuring six cells one at a time, but the character they
15030 // spell is drawn in two. Width belongs to the cluster, not the glyph,
15031 // and the frontends measure it the same way.
15032 let family = "👨👩👧";
15033 for (view, tag) in VIEWS {
15034 let src = format!("a{family}b\n");
15035 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
15036 d.caret = 1 + family.len();
15037 assert_eq!(
15038 d.caret_pos(),
15039 (0, 3),
15040 "{tag}: 'a' is one cell, the family two"
15041 );
15042 }
15043 }
15044
15045 #[test]
15046 fn both_cells_of_a_wide_character_mean_the_character() {
15047 // Clicking the far half of `好` is still clicking `好`: half a character
15048 // is not a place the caret can be, so it comes to rest at the
15049 // character's start — the column it would have been drawn at anyway.
15050 for (view, tag) in VIEWS {
15051 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
15052 for col in [2, 3] {
15053 d.caret = 0;
15054 d.click(0, col, false);
15055 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
15056 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
15057 }
15058 // Past the last cell is the line's end, as it is for ASCII.
15059 d.click(0, 9, false);
15060 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
15061 }
15062 }
15063
15064 #[test]
15065 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
15066 // The mapping is only a mapping if it inverts: the cell the caret is
15067 // drawn in has to be the cell that brings it back to the same offset.
15068 // Over a fixture where a character may be one cell or two, and one
15069 // codepoint or five.
15070 use unicode_segmentation::UnicodeSegmentation;
15071
15072 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
15073
15074 let mut d = doc_in(View::Source, "roundtrip_source", src);
15075 // Every offset the source view's caret can occupy: it steps by grapheme
15076 // cluster, so those are its boundaries.
15077 for (off, _) in src
15078 .grapheme_indices(true)
15079 .chain(std::iter::once((src.len(), "")))
15080 {
15081 d.caret = off;
15082 let (row, col) = d.caret_pos();
15083 d.click(row, col, false);
15084 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
15085 }
15086
15087 // And in WYSIWYG, where the offsets the caret can occupy are the map's
15088 // stops rather than every boundary.
15089 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
15090 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
15091 assert!(stops.len() > 20, "fixture should have plenty of stops");
15092 for off in stops {
15093 d.caret = off;
15094 let (row, col) = d.caret_pos();
15095 d.click(row, col, false);
15096 assert_eq!(
15097 d.caret, off,
15098 "wysiwyg: {off} → ({row}, {col}) → {}",
15099 d.caret
15100 );
15101 }
15102 }
15103
15104 #[test]
15105 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
15106 // Down from under `世` lands under the glyph in that cell, not two
15107 // characters further along the line. The goal is a column, so a line of
15108 // wide characters and a line of ASCII line up the way they're drawn.
15109 //
15110 // The gap differs by view: a bare newline inside a paragraph is a soft
15111 // break, which WYSIWYG draws as a space on a single row. The views share
15112 // a grid only where the source's lines are the renderer's rows too.
15113 for (view, tag) in VIEWS {
15114 let gap = if view == View::Source { "\n" } else { "\n\n" };
15115 let src = format!("你好世{gap}abcdef\n");
15116 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
15117 d.caret = "你好".len();
15118 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
15119 d.move_down(false);
15120 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
15121 assert!(
15122 d.source[d.caret..].starts_with('e'),
15123 "{tag}: landed on the wrong glyph"
15124 );
15125 }
15126 }
15127
15128 #[test]
15129 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
15130 // Down from column 3 onto `你好`, whose characters start at columns 0
15131 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
15132 // between the cells of one character, so the caret rests on it — and on
15133 // its start, which is the only offset there that is a caret stop.
15134 for (view, tag) in VIEWS {
15135 let gap = if view == View::Source { "\n" } else { "\n\n" };
15136 let src = format!("abcdef{gap}你好\n");
15137 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
15138 let line = src.find('你').unwrap();
15139 d.caret = 3;
15140 d.move_down(false);
15141 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
15142 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
15143 }
15144 }
15145
15146 #[test]
15147 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
15148 // The column the cell's text is laid out in is measured in cells, so the
15149 // caret walking that text has to be too — the two agreeing is the whole
15150 // point of the grid staying square.
15151 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
15152 let at = d.source.find("你").unwrap();
15153 d.caret = at;
15154 let (row, col) = d.caret_pos();
15155 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
15156 // cells further along.
15157 assert_eq!(col, 2, "the cell's first character");
15158 d.move_right(false);
15159 assert_eq!(
15160 d.caret_pos(),
15161 (row, 4),
15162 "`好` is drawn past `你`'s two cells"
15163 );
15164 assert_eq!(d.caret, at + "你".len());
15165 }
15166
15167 // ── active inline marks ───────────────────────────────────────────────────
15168
15169 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
15170 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
15171 let (src, caret) = parse_caret(marked);
15172 let mut d = doc_in(view, name, &src);
15173 d.caret = caret;
15174 d.active_inline_marks().iter().collect()
15175 }
15176
15177 /// The marks over the selection `[start, end)`.
15178 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
15179 let mut d = doc_in(view, name, src);
15180 d.anchor = Some(start);
15181 d.caret = end;
15182 d.active_inline_marks().iter().collect()
15183 }
15184
15185 #[test]
15186 fn a_caret_in_a_mark_reports_it() {
15187 for (view, tag) in VIEWS {
15188 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
15189 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
15190 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
15191 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
15192 // Plain text under no mark lights nothing — the toolbar's resting state.
15193 assert_eq!(m("a| **bold** b"), [], "{tag}");
15194 assert!(m("plain t|ext").is_empty(), "{tag}");
15195 }
15196 }
15197
15198 #[test]
15199 fn nested_marks_all_report() {
15200 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
15201 // the ancestor chain is a chain, and every mark on it is in force.
15202 for (view, tag) in VIEWS {
15203 assert_eq!(
15204 marks(
15205 view,
15206 &format!("marks_nested_{tag}"),
15207 "**bold and *bo|th*** end"
15208 ),
15209 [InlineKind::Strong, InlineKind::Emph],
15210 "{tag}"
15211 );
15212 }
15213 }
15214
15215 #[test]
15216 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
15217 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
15218 // the first byte of its text and the byte after its last — both inside
15219 // the mark's span, both places typing lands inside the bold. The offset
15220 // past the closing delimiter is the next text, and reports nothing.
15221 let src = "a **bold** b";
15222 let inner_start = src.find("bold").unwrap(); // 4
15223 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
15224 for (view, tag) in VIEWS {
15225 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
15226 for off in [2, 3, inner_start, inner_end, 9] {
15227 d.caret = off;
15228 assert!(
15229 d.active_inline_marks().contains(InlineKind::Strong),
15230 "{tag}: offset {off} is inside the strong span"
15231 );
15232 }
15233 for off in [0, 1, 10, 11, 12] {
15234 d.caret = off;
15235 assert!(
15236 !d.active_inline_marks().contains(InlineKind::Strong),
15237 "{tag}: offset {off} is outside the strong run"
15238 );
15239 }
15240 }
15241 }
15242
15243 #[test]
15244 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
15245 // Regression: twig resolves an offset that is one node's end and the
15246 // next one's start to the node that *starts* there, so `**bold**|\n`
15247 // isn't bold. With nothing following there's no tie to break and the
15248 // chain still ended at the mark, which made a trailing `\n` — not the
15249 // text — decide whether the caret after a bold word reported bold. It's
15250 // the offset past the mark either way, and typing there is plain either
15251 // way. A blank document typed into is exactly this shape.
15252 for (view, tag) in VIEWS {
15253 let m = |name: String, marked| marks(view, &name, marked);
15254 assert_eq!(
15255 m(format!("marks_eob_{tag}"), "**bold**|"),
15256 [],
15257 "{tag}: no trailing newline"
15258 );
15259 assert_eq!(
15260 m(format!("marks_eol_{tag}"), "**bold**|\n"),
15261 [],
15262 "{tag}: with one"
15263 );
15264 // And the last offset that *is* in the mark still is.
15265 assert_eq!(
15266 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
15267 [InlineKind::Strong],
15268 "{tag}"
15269 );
15270 }
15271 }
15272
15273 #[test]
15274 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
15275 let src = "a **bold** b";
15276 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
15277 for (view, tag) in VIEWS {
15278 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
15279 // The whole bold word, and a slice of it.
15280 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
15281 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
15282 // Ending exactly at the closing delimiter's start is still all-bold:
15283 // an exclusive end sits *past* the last selected character, so the
15284 // question is asked of the character, not the boundary.
15285 assert_eq!(
15286 m(b, d_ + 2),
15287 [InlineKind::Strong],
15288 "{tag}: through the close"
15289 );
15290 // Half in, half out: Bold lit here would claim a press turns it off.
15291 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
15292 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
15293 }
15294 }
15295
15296 #[test]
15297 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
15298 // Both ends are bold, but the space between them isn't — two runs are two
15299 // nodes, which is exactly what the node id catches and a kind-only
15300 // comparison would not.
15301 let src = "**one** **two**";
15302 for (view, tag) in VIEWS {
15303 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
15304 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
15305 }
15306 }
15307
15308 #[test]
15309 fn marks_read_the_document_as_it_is_edited() {
15310 // The point of asking twig every frame instead of caching: the answer has
15311 // to follow the toggle that changed it.
15312 let mut d = wysiwyg_doc("marks_live", "one two\n");
15313 d.anchor = Some(0);
15314 d.caret = 3;
15315 assert!(d.active_inline_marks().is_empty(), "plain to start");
15316 d.toggle(InlineKind::Strong);
15317 assert_eq!(d.source, "**one** two\n");
15318 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
15319 assert!(d.active_inline_marks().contains(InlineKind::Strong));
15320 d.toggle(InlineKind::Strong);
15321 assert!(d.active_inline_marks().is_empty(), "and off again");
15322 }
15323
15324 #[test]
15325 fn a_link_is_not_an_inline_mark() {
15326 // `link`/`str` are inline nodes, but nothing on the inline toolbar
15327 // toggles them — a set with a "link mark" in it would have no button.
15328 for (view, tag) in VIEWS {
15329 assert_eq!(
15330 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
15331 [],
15332 "{tag}"
15333 );
15334 }
15335 }
15336
15337 // ── blank documents ───────────────────────────────────────────────────────
15338
15339 #[test]
15340 fn a_blank_document_is_untitled_empty_and_markdown() {
15341 let mut d = Doc::blank().unwrap();
15342 assert!(d.is_untitled());
15343 assert_eq!(d.path, PathBuf::new());
15344 assert_eq!(
15345 d.file_name(),
15346 "untitled",
15347 "the header has to show something"
15348 );
15349 assert_eq!(d.format_name(), "markdown");
15350 assert_eq!(d.source, "");
15351 assert!(!d.dirty, "nothing typed yet is nothing to lose");
15352 assert_eq!(d.disk_state(), DiskState::Untitled);
15353 // And it's a document you can be in: the default view renders it.
15354 d.build_visual(80);
15355 assert_eq!(d.caret, 0);
15356 }
15357
15358 #[test]
15359 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
15360 let mut d = Doc::blank().unwrap();
15361 d.insert("hello");
15362 assert!(d.dirty);
15363 d.save();
15364 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
15365 assert!(d.dirty, "it must not come away believing it saved");
15366 assert!(d.is_untitled(), "and it still has no file");
15367 }
15368
15369 #[test]
15370 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
15371 let p = temp_path("blank_save_as");
15372 let mut d = Doc::blank().unwrap();
15373 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
15374 // be kept literal (`\#`); this test is about save-as, not escaping (which
15375 // has its own test), so it types nothing that escaping would touch.
15376 d.insert("hi");
15377 d.save_as(p.clone());
15378 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
15379 assert!(!d.is_untitled());
15380 assert!(!d.dirty);
15381 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
15382 assert_eq!(
15383 d.disk_state(),
15384 DiskState::Unchanged,
15385 "the watermark is stamped"
15386 );
15387 // And ⌘S is a plain save from here on.
15388 d.insert("!");
15389 d.save();
15390 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
15391 let _ = std::fs::remove_file(&p);
15392 }
15393
15394 // ── a file that isn't there yet ───────────────────────────────────────────
15395
15396 /// A unique path in the temp dir with the given extension, guaranteed not to
15397 /// exist — what `leaf notes.md` is handed when the file has never been made.
15398 fn missing_path(name: &str, ext: &str) -> PathBuf {
15399 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15400 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15401 let mut p = std::env::temp_dir();
15402 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
15403 let _ = std::fs::remove_file(&p);
15404 p
15405 }
15406
15407 #[test]
15408 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
15409 let p = missing_path("named", "md");
15410 let mut d = Doc::open_or_create(p.clone()).unwrap();
15411
15412 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
15413 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
15414 assert!(
15415 !d.is_untitled(),
15416 "it has the name the user asked for — ^S must not detour to Save As"
15417 );
15418 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
15419 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
15420 assert!(!p.exists(), "and opening it wrote nothing");
15421 // And it's a document you can be in.
15422 d.build_visual(80);
15423 assert_eq!(d.caret, 0);
15424 }
15425
15426 #[test]
15427 fn a_new_file_is_created_by_its_first_save() {
15428 let p = missing_path("first_save", "md");
15429 let mut d = Doc::open_or_create(p.clone()).unwrap();
15430 d.insert("hello\n");
15431 assert!(d.dirty);
15432 d.save();
15433
15434 assert_eq!(
15435 std::fs::read_to_string(&p).unwrap(),
15436 "hello\n",
15437 "a plain ^S wrote it — no Save As, no name to invent"
15438 );
15439 assert!(!d.dirty);
15440 assert_eq!(d.disk_state(), DiskState::Unchanged);
15441 let _ = std::fs::remove_file(&p);
15442 }
15443
15444 #[test]
15445 fn a_new_file_takes_its_format_from_the_extension() {
15446 // The one thing `blank` can't do: with no name it has to assume Markdown,
15447 // and typing djot into a Markdown parse is the wrong buffer.
15448 let dj = missing_path("format", "dj");
15449 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
15450 let md = missing_path("format", "md");
15451 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
15452 }
15453
15454 #[test]
15455 fn a_new_file_reports_itself_missing_until_it_is_saved() {
15456 // Not `Untitled` — that's the answer for a document with no path, and it
15457 // would tell a frontend there is nothing a save could collide with. Here
15458 // there is a path, and the file simply isn't at it yet.
15459 let p = missing_path("disk_state", "md");
15460 let mut d = Doc::open_or_create(p.clone()).unwrap();
15461 assert_eq!(d.disk_state(), DiskState::Missing);
15462
15463 // Somebody else creates it while the buffer is open: that's an overwrite
15464 // the frontend has to be able to prompt about, exactly as for an opened
15465 // file. Their bytes, not ours, so `Changed`.
15466 std::fs::write(&p, "theirs\n").unwrap();
15467 assert_eq!(d.disk_state(), DiskState::Changed);
15468
15469 // Saving makes the file ours and re-stamps the watermark.
15470 d.insert("ours\n");
15471 d.save();
15472 assert_eq!(d.disk_state(), DiskState::Unchanged);
15473 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
15474 let _ = std::fs::remove_file(&p);
15475 }
15476
15477 #[test]
15478 fn open_or_create_still_opens_a_file_that_is_there() {
15479 let d = doc_with("open_or_create_existing", "body\n");
15480 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
15481 assert_eq!(reopened.source, "body\n");
15482 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
15483 }
15484
15485 #[test]
15486 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
15487 // A mistyped flag or a stray argument must not become a buffer promising
15488 // to save somewhere — the same refusal `open` gives a real file.
15489 let mut p = std::env::temp_dir();
15490 p.push("leaf_test_new_bad_ext.wat");
15491 assert!(Doc::open_or_create(p).is_err());
15492 let mut none = std::env::temp_dir();
15493 none.push("leaf_test_new_no_ext");
15494 assert!(Doc::open_or_create(none).is_err());
15495 }
15496
15497 #[test]
15498 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
15499 // Opening reads nothing, so there is nothing to fail on yet; the write is
15500 // where it fails, and it says so rather than claiming a save.
15501 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
15502 let mut d = Doc::open_or_create(p).unwrap();
15503 d.insert("x");
15504 d.save();
15505 assert!(
15506 d.status.as_deref().unwrap().starts_with("save failed:"),
15507 "got {:?}",
15508 d.status
15509 );
15510 assert!(d.dirty, "it must not come away believing it saved");
15511 }
15512
15513 // ── save as ───────────────────────────────────────────────────────────────
15514
15515 /// A unique path in the temp dir that no fixture wrote — a Save As target.
15516 fn temp_path(name: &str) -> PathBuf {
15517 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15518 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15519 let mut p = std::env::temp_dir();
15520 p.push(format!("leaf_test_target_{name}_{seq}.md"));
15521 let _ = std::fs::remove_file(&p);
15522 p
15523 }
15524
15525 #[test]
15526 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
15527 let mut d = doc_with("save_as_move", "original\n");
15528 let old = d.path.clone();
15529 let new = temp_path("save_as_move");
15530 d.insert("edited: ");
15531 d.save_as(new.clone());
15532
15533 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
15534 assert_eq!(
15535 std::fs::read_to_string(&old).unwrap(),
15536 "original\n",
15537 "Save As doesn't touch the file it came from"
15538 );
15539 assert_eq!(d.path, new, "the document moved");
15540 assert!(!d.dirty);
15541 assert_eq!(
15542 d.status.as_deref(),
15543 Some(&*format!("saved {}", d.file_name()))
15544 );
15545
15546 // Every later save follows it, which is the whole difference from a copy.
15547 d.caret = 0;
15548 d.insert("re-");
15549 d.save();
15550 assert_eq!(
15551 std::fs::read_to_string(&new).unwrap(),
15552 "re-edited: original\n"
15553 );
15554 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
15555 let _ = std::fs::remove_file(&new);
15556 }
15557
15558 #[test]
15559 fn save_as_overwrites_an_existing_target() {
15560 // The picker already asked; asking again down here is the same question
15561 // twice, and the second one has no way to be answered.
15562 let new = temp_path("save_as_over");
15563 std::fs::write(&new, "theirs\n").unwrap();
15564 let mut d = doc_with("save_as_over", "ours\n");
15565 d.save_as(new.clone());
15566 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
15567 let _ = std::fs::remove_file(&new);
15568 }
15569
15570 #[test]
15571 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
15572 let mut d = doc_with("save_as_fail", "body\n");
15573 let old = d.path.clone();
15574 d.insert("x");
15575 // A directory that doesn't exist: the write can't land.
15576 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
15577 d.save_as(bad);
15578
15579 assert_eq!(
15580 d.path, old,
15581 "the document must not move to a file that isn't there"
15582 );
15583 assert!(d.dirty, "and must not believe it saved");
15584 assert!(
15585 d.status.as_deref().unwrap().starts_with("save failed:"),
15586 "the same failure a plain save reports, got {:?}",
15587 d.status
15588 );
15589 // The original is still the document's file, and still saveable.
15590 d.save();
15591 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
15592 assert!(!d.dirty);
15593 }
15594
15595 #[test]
15596 fn save_as_renames_without_reparsing_the_format() {
15597 // `.dj` on the name doesn't make the buffer djot: it was parsed as
15598 // Markdown and still is, and saying otherwise would be a conversion the
15599 // user never asked for (and an undo history thrown away to do it).
15600 let mut d = doc_with("save_as_format", "**b**\n");
15601 let mut new = temp_path("save_as_format");
15602 new.set_extension("dj");
15603 d.save_as(new.clone());
15604 assert_eq!(d.format_name(), "markdown");
15605 let _ = std::fs::remove_file(&new);
15606 }
15607
15608 // ── external change / reload ──────────────────────────────────────────────
15609
15610 #[test]
15611 fn an_untouched_file_reports_unchanged() {
15612 let mut d = doc_with("disk_clean", "body\n");
15613 assert_eq!(d.disk_state(), DiskState::Unchanged);
15614 // Editing the buffer is not editing the file.
15615 d.insert("x");
15616 assert_eq!(d.disk_state(), DiskState::Unchanged);
15617 assert!(d.dirty);
15618 // Saving re-stamps the watermark rather than reporting our own bytes back.
15619 d.save();
15620 assert_eq!(d.disk_state(), DiskState::Unchanged);
15621 }
15622
15623 #[test]
15624 fn a_file_written_underneath_reports_changed() {
15625 let mut d = doc_with("disk_changed", "body\n");
15626 std::fs::write(&d.path, "someone else\n").unwrap();
15627 assert_eq!(d.disk_state(), DiskState::Changed);
15628 // Dirty *and* changed is the clobber: both halves are readable, and
15629 // leaf-core takes neither side.
15630 d.insert("x");
15631 assert!(d.dirty && d.disk_state() == DiskState::Changed);
15632 // Saving anyway is allowed — the frontend asked, or chose not to.
15633 d.save();
15634 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
15635 assert_eq!(d.disk_state(), DiskState::Unchanged);
15636 }
15637
15638 #[test]
15639 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
15640 // The hash is what makes this honest: the file was written (a fresh
15641 // mtime), and nothing about the document is stale.
15642 let d = doc_with("disk_same_bytes", "body\n");
15643 std::fs::write(&d.path, "body\n").unwrap();
15644 assert_eq!(d.disk_state(), DiskState::Unchanged);
15645 }
15646
15647 #[test]
15648 fn a_deleted_file_reports_missing() {
15649 let mut d = doc_with("disk_missing", "body\n");
15650 std::fs::remove_file(&d.path).unwrap();
15651 assert_eq!(d.disk_state(), DiskState::Missing);
15652 // A save recreates it, and the document is whole again.
15653 d.save();
15654 assert_eq!(d.disk_state(), DiskState::Unchanged);
15655 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
15656 }
15657
15658 #[test]
15659 fn reload_replaces_the_document_with_the_file() {
15660 for (view, tag) in VIEWS {
15661 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
15662 d.insert("edited ");
15663 assert!(d.dirty);
15664 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15665 d.reload();
15666
15667 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
15668 assert!(!d.dirty, "{tag}: the file is what we have");
15669 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
15670 assert_eq!(
15671 d.status.as_deref(),
15672 Some(&*format!("reloaded {}", d.file_name()))
15673 );
15674 // The reloaded tree is live, not the old parse.
15675 d.caret = d.source.find("three").unwrap();
15676 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
15677 }
15678 }
15679
15680 #[test]
15681 fn reload_clamps_the_caret_and_drops_the_selection() {
15682 let mut d = doc_with("reload_caret", "a long first line\n");
15683 d.caret = 12;
15684 d.anchor = Some(4);
15685 std::fs::write(&d.path, "short\n").unwrap();
15686 d.reload();
15687 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
15688 assert_eq!(
15689 d.anchor, None,
15690 "a selection over bytes that changed is a lie"
15691 );
15692 assert!(d.selection().is_none());
15693
15694 // A caret the file still has room for stays put.
15695 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
15696 d.caret = 2;
15697 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15698 d.reload();
15699 assert_eq!(d.caret, 2);
15700 }
15701
15702 /// A silent reload is something that happened *to* a reader — a formatter,
15703 /// a `git checkout` — so it has to be undoable like anything else that
15704 /// changes the document, and undoable as one step rather than as however
15705 /// many the file happens to differ by.
15706 #[test]
15707 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
15708 let mut d = doc_with("reload_undo", "body\n");
15709 d.insert("x");
15710 assert_eq!(d.source, "xbody\n");
15711 std::fs::write(&d.path, "replaced\n").unwrap();
15712 d.reload();
15713 assert_eq!(d.source, "replaced\n");
15714 assert!(!d.dirty, "a reload lands clean");
15715
15716 // One ^Z takes the whole swap off, and hands back the unsaved work it
15717 // replaced — which is unsaved again, because the file no longer says it.
15718 d.undo();
15719 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
15720 assert!(d.dirty, "and what it comes back to is unsaved");
15721 // …and the history under it is still there.
15722 d.undo();
15723 assert_eq!(
15724 d.source, "body\n",
15725 "the typing before the reload undoes too"
15726 );
15727 // Redo walks back up through the reload.
15728 d.redo();
15729 d.redo();
15730 assert_eq!(d.source, "replaced\n");
15731 }
15732
15733 /// A file rewritten with the bytes it already had is not an edit, so it
15734 /// must not leave an undo step behind for something nobody did.
15735 #[test]
15736 fn reloading_identical_bytes_pushes_no_undo_step() {
15737 let mut d = doc_with("reload_same", "body\n");
15738 d.insert("x");
15739 std::fs::write(&d.path, "xbody\n").unwrap();
15740 d.reload();
15741 assert_eq!(d.source, "xbody\n");
15742 assert!(!d.dirty, "the file now says what the buffer does");
15743 d.undo();
15744 assert_eq!(
15745 d.source, "body\n",
15746 "one step back is the typing, not a no-op"
15747 );
15748 }
15749
15750 #[test]
15751 fn a_reload_that_cant_read_leaves_the_document_alone() {
15752 let mut d = doc_with("reload_gone", "body\n");
15753 d.insert("x");
15754 std::fs::remove_file(&d.path).unwrap();
15755 d.reload();
15756 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
15757 assert!(d.dirty);
15758 assert!(
15759 d.status.as_deref().unwrap().starts_with("reload failed:"),
15760 "{:?}",
15761 d.status
15762 );
15763
15764 // And an untitled document has nothing to reload from.
15765 let mut d = Doc::blank().unwrap();
15766 d.insert("typed");
15767 d.reload();
15768 assert_eq!(d.source, "typed");
15769 assert_eq!(d.status.as_deref(), Some("no file to reload"));
15770 }
15771
15772 #[test]
15773 fn a_read_only_document_refuses_every_door() {
15774 let mut d = doc_with("readonly", "one two three\n");
15775 d.insert("x");
15776 assert!(d.dirty, "writable first, so the undo step exists");
15777 d.set_read_only(true);
15778 let before = d.source.clone();
15779 d.insert("y");
15780 d.backspace();
15781 d.undo();
15782 d.redo();
15783 assert_eq!(d.source, before, "no door moved a byte");
15784 d.set_read_only(false);
15785 d.undo();
15786 assert_ne!(d.source, before, "off again, the same doors work");
15787 }
15788
15789 /// The doors that go to twig's own verbs rather than through the splice.
15790 /// Typed text in the rendered view under the default markup mode is the
15791 /// everyday one — it is what a keystroke in leaf-web or the Apple views
15792 /// becomes — and it walked straight past the gate.
15793 #[test]
15794 fn a_read_only_document_refuses_the_doors_around_the_splice() {
15795 let mut d = wysiwyg_doc(
15796 "readonly-doors",
15797 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
15798 );
15799 d.set_markup_mode(MarkupMode::None);
15800 d.set_read_only(true);
15801 let before = d.source.clone();
15802 d.place_caret(3, false);
15803 d.insert("y");
15804 d.insert_link("https://example.com");
15805 d.insert_image("a.png", "alt");
15806 d.insert_thematic_break();
15807 d.insert_footnote();
15808 d.place_caret(0, false);
15809 d.place_caret(3, true);
15810 d.toggle(InlineKind::Strong);
15811 d.toggle_heading(2);
15812 d.set_block(BlockKind::Paragraph);
15813 d.toggle_list(false);
15814 d.toggle_blockquote();
15815 d.toggle_task_item();
15816 d.newline();
15817 d.indent();
15818 d.set_code_language("rust");
15819 let in_cell = d.source.find("| c").unwrap() + 2;
15820 d.place_caret(in_cell, false);
15821 assert!(d.caret_in_table(), "the caret is in the grid");
15822 assert!(!d.cell_line_break(), "the cell break reports the refusal");
15823 assert_eq!(d.source, before, "no door moved a byte");
15824 assert!(!d.dirty, "nothing to save");
15825 d.set_read_only(false);
15826 d.place_caret(3, false);
15827 d.insert("y");
15828 assert_ne!(d.source, before, "off again, the same doors work");
15829 }
15830
15831 #[test]
15832 fn a_selection_quote_carries_its_context_on_char_boundaries() {
15833 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
15834 let start = d.source.find("exact").unwrap();
15835 d.place_caret(start, false);
15836 d.place_caret(start + "exact".len(), true);
15837 let q = d.selection_quote(3).unwrap();
15838 assert_eq!(q.exact, "exact");
15839 assert_eq!(
15840 q.prefix, "你好 ",
15841 "chars, not bytes — the multibyte pair counts as two"
15842 );
15843 assert_eq!(q.suffix, " 世界");
15844 assert_eq!(&d.source[q.start..q.end], "exact");
15845 // At the edges the context clips rather than erring.
15846 d.place_caret(0, false);
15847 d.place_caret(6, true);
15848 let q = d.selection_quote(40).unwrap();
15849 assert_eq!(q.prefix, "");
15850 assert_eq!(q.exact, "before");
15851 // No selection is no quote.
15852 d.place_caret(0, false);
15853 assert!(d.selection_quote(3).is_none());
15854 }
15855
15856 #[test]
15857 fn highlights_are_kept_sorted_and_answer_point_queries() {
15858 let mut d = doc_with("hl", "one two three\n");
15859 d.set_highlights(vec![
15860 Highlight {
15861 start: 8,
15862 end: 13,
15863 id: "b".into(),
15864 color: None,
15865 marker: None,
15866 },
15867 Highlight {
15868 start: 0,
15869 end: 3,
15870 id: "a".into(),
15871 color: Some("#ffe066".into()),
15872 marker: None,
15873 },
15874 Highlight {
15875 start: 5,
15876 end: 5,
15877 id: "empty".into(),
15878 color: None,
15879 marker: None,
15880 },
15881 ]);
15882 assert_eq!(
15883 d.highlights()
15884 .iter()
15885 .map(|h| h.id.as_str())
15886 .collect::<Vec<_>>(),
15887 ["a", "b"],
15888 "sorted by start, the empty range dropped"
15889 );
15890 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
15891 assert_eq!(d.highlight_at(3), None, "end is exclusive");
15892 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
15893 d.set_highlights(Vec::new());
15894 assert!(d.highlights().is_empty(), "a replace is a replace");
15895 }
15896
15897 /// `Highlight::covering` and the cursor over it are what both painters ask
15898 /// per glyph, so they have to answer the same as the scan they replaced —
15899 /// including in the gaps, which is where most glyphs are.
15900 #[test]
15901 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
15902 let hl = |start: usize, end: usize, id: &str| Highlight {
15903 start,
15904 end,
15905 id: id.into(),
15906 color: None,
15907 marker: None,
15908 };
15909 // Disjoint, as search hits are: in a range, in a gap, and past the end.
15910 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
15911 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
15912 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
15913 assert_eq!(
15914 Highlight::covering(&hits, 105),
15915 None,
15916 "a gap covers nothing"
15917 );
15918 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
15919 assert_eq!(Highlight::covering(&hits, 9_999), None);
15920 assert_eq!(Highlight::covering(&[], 0), None);
15921
15922 // Nested: first by start, so a hit inside an annotation still resolves
15923 // to the annotation — and the range that stops short doesn't mask it.
15924 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
15925 assert_eq!(
15926 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
15927 Some("outer")
15928 );
15929 assert_eq!(
15930 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
15931 Some("outer")
15932 );
15933 }
15934
15935 /// The cursor is an optimisation, so the only thing worth asserting is that
15936 /// it is not also a change of answer — at every offset, over a list with a
15937 /// nest in it, walked forwards and then backwards.
15938 #[test]
15939 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
15940 let hl = |start: usize, end: usize, id: &str| Highlight {
15941 start,
15942 end,
15943 id: id.into(),
15944 color: None,
15945 marker: None,
15946 };
15947 let mut list = vec![
15948 hl(0, 20, "outer"),
15949 hl(5, 10, "inner"),
15950 hl(30, 33, "hit"),
15951 hl(40, 43, "hit"),
15952 ];
15953 list.sort_by_key(|h| (h.start, h.end));
15954
15955 let mut cursor = HighlightCursor::new(&list);
15956 for offset in 0..50 {
15957 assert_eq!(
15958 cursor.at(offset).map(|h| h.id.as_str()),
15959 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15960 "cursor disagrees at {offset}"
15961 );
15962 }
15963 // Backwards: the cursor re-seats rather than answering from where it
15964 // had got to, so a painter that revisits a row is still told the truth.
15965 for offset in (0..50).rev() {
15966 assert_eq!(
15967 cursor.at(offset).map(|h| h.id.as_str()),
15968 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15969 "cursor disagrees walking back at {offset}"
15970 );
15971 }
15972 }
15973
15974 // ── the presentation vocabulary ─────────────────────────────────────────
15975
15976 /// A document in `format`, for the gesture tests that want more than the
15977 /// Markdown `doc_with` writes.
15978 fn fmt_doc(body: &str, format: Format) -> Doc {
15979 Doc::from_source(body.to_string(), format).unwrap()
15980 }
15981
15982 /// Alignment is a block property, so the gesture is `set_block_attrs` on
15983 /// the caret's block whatever is selected — and each format spells it its
15984 /// own way: djot's `{…}` line above the block, a `<div>` around it in
15985 /// Markdown (the format has nowhere else to put it), the tag in HTML.
15986 #[test]
15987 fn set_alignment_spells_the_class_the_format_s_own_way() {
15988 let mut dj = fmt_doc("hello\n", Format::Djot);
15989 dj.caret = 1;
15990 dj.set_alignment(Some(Align::Center));
15991 assert_eq!(dj.source, "{.center}\nhello\n");
15992 assert!(dj.dirty);
15993 assert_eq!(dj.status, None);
15994
15995 let mut md = fmt_doc("hello\n", Format::Markdown);
15996 md.caret = 1;
15997 md.set_alignment(Some(Align::Right));
15998 assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
15999
16000 let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
16001 html.caret = html.source.find("hello").unwrap();
16002 html.set_alignment(Some(Align::Justify));
16003 assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
16004 }
16005
16006 /// Each gesture edits **one key and keeps the rest** — twig's contract is
16007 /// replace-not-merge, so leaf reads the node's attributes, edits its own
16008 /// key out of them, and passes the list back whole. A document from
16009 /// elsewhere passes through the editor unharmed.
16010 #[test]
16011 fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
16012 let mut d = fmt_doc(
16013 "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
16014 Format::Djot,
16015 );
16016 d.caret = d.source.find("hello").unwrap();
16017 d.set_alignment(Some(Align::Right));
16018 // `center` goes, `lead` stays, and neither the id nor the spacing is
16019 // touched.
16020 // The serializer picks the order; what matters is which keys survive.
16021 assert!(d.source.contains(".lead"), "{:?}", d.source);
16022 assert!(d.source.contains(".right"), "{:?}", d.source);
16023 assert!(!d.source.contains(".center"), "{:?}", d.source);
16024 assert!(d.source.contains("#intro"), "{:?}", d.source);
16025 assert!(
16026 d.source.contains("data-line-height=\"1.5\""),
16027 "{:?}",
16028 d.source
16029 );
16030 assert_eq!(d.alignment_at_caret(), Some(Align::Right));
16031 assert_eq!(
16032 d.line_spacing_at_caret(),
16033 Some(LineHeight::Step(LineSpacing::OneHalf))
16034 );
16035
16036 // And the other way round: the spacing gesture leaves the classes be.
16037 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16038 assert!(d.source.contains(".lead"), "{:?}", d.source);
16039 assert!(d.source.contains(".right"), "{:?}", d.source);
16040 assert_eq!(
16041 d.line_spacing_at_caret(),
16042 Some(LineHeight::Step(LineSpacing::Double))
16043 );
16044 }
16045
16046 /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
16047 /// owns go out of `class`, and a block left with nothing at all is spelled
16048 /// bare again — in Markdown by unwrapping the div twig wrapped it in.
16049 #[test]
16050 fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
16051 let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
16052 dj.caret = dj.source.find("hello").unwrap();
16053 dj.set_alignment(None);
16054 assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
16055 assert_eq!(dj.alignment_at_caret(), None);
16056
16057 let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
16058 bare.caret = bare.source.find("hello").unwrap();
16059 bare.set_alignment(None);
16060 assert_eq!(
16061 bare.source, "hello\n",
16062 "the last key takes the line with it"
16063 );
16064
16065 let mut md = fmt_doc("hello\n", Format::Markdown);
16066 md.caret = 1;
16067 md.set_alignment(Some(Align::Center));
16068 assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
16069 md.caret = md.source.find("hello").unwrap();
16070 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
16071 assert_eq!(
16072 md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
16073 "the second key rewrites the div rather than nesting a second"
16074 );
16075 md.caret = md.source.find("hello").unwrap();
16076 md.set_alignment(None);
16077 md.caret = md.source.find("hello").unwrap();
16078 md.set_line_spacing(None);
16079 assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
16080 }
16081
16082 /// Size, face and colour are the run's over a selection and the block's
16083 /// with none — so "make this paragraph larger" is a click with the caret in
16084 /// it rather than a select-all first.
16085 #[test]
16086 fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
16087 // With a selection: a span, in each format's own spelling.
16088 let mut dj = fmt_doc("a big b\n", Format::Djot);
16089 dj.anchor = Some(2);
16090 dj.caret = 5;
16091 dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16092 assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
16093 assert_eq!(
16094 dj.font_size_at_caret(),
16095 Some(FontSize::Step(SizeStep::Large))
16096 );
16097
16098 let mut md = fmt_doc("a big b\n", Format::Markdown);
16099 md.anchor = Some(2);
16100 md.caret = 5;
16101 md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
16102 assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
16103 assert_eq!(
16104 md.text_color_at_caret(),
16105 Some(TextColor::Named(MarkColor::Blue))
16106 );
16107
16108 // Without one: the caret's block, through the block gesture.
16109 let mut block = fmt_doc("a big b\n", Format::Djot);
16110 block.caret = 3;
16111 block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
16112 assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
16113 assert_eq!(
16114 block.font_family_at_caret(),
16115 Some(FontFace::Generic(FontFamily::Monospace))
16116 );
16117 }
16118
16119 /// The *Other…* row of each of the four menus: a value goes into the
16120 /// document in its canonical spelling and comes back out of the query as
16121 /// the same value. One round trip per property, because the four go out
16122 /// through different doors — two block gestures, and the run three through
16123 /// the span that `wrap_range_attrs` mints.
16124 #[test]
16125 fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
16126 // Size: the run three, over a selection.
16127 let mut d = fmt_doc("a big b\n", Format::Djot);
16128 d.anchor = Some(2);
16129 d.caret = 5;
16130 d.set_font_size(FontSize::points(14.0));
16131 assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
16132 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16133
16134 // Colour, onto the same span — the gesture keeps the size it finds.
16135 d.set_text_color(Some(TextColor::Rgb {
16136 r: 0xc0,
16137 g: 0x30,
16138 b: 0x30,
16139 }));
16140 assert_eq!(
16141 d.source,
16142 "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
16143 );
16144 assert_eq!(
16145 d.text_color_at_caret(),
16146 Some(TextColor::Rgb {
16147 r: 0xc0,
16148 g: 0x30,
16149 b: 0x30
16150 })
16151 );
16152
16153 // Face: a family name, as given.
16154 d.set_font_family(Some(FontFace::Named("Garamond".into())));
16155 assert!(
16156 d.source.contains("data-font=\"Garamond\""),
16157 "{:?}",
16158 d.source
16159 );
16160 assert_eq!(
16161 d.font_family_at_caret(),
16162 Some(FontFace::Named("Garamond".into()))
16163 );
16164
16165 // Line spacing: a block gesture, and an exact ratio.
16166 let mut block = fmt_doc("hello\n", Format::Djot);
16167 block.caret = 1;
16168 block.set_line_spacing(LineHeight::ratio(1.3));
16169 assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
16170 assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
16171
16172 // And a value spelled long is written back short, so the same press
16173 // twice writes the same bytes: `14.0pt` in, `14pt` out.
16174 let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
16175 long.caret = long.source.find("hello").unwrap();
16176 assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
16177 let in_force = long.font_size_at_caret();
16178 long.set_font_size(in_force);
16179 assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
16180 }
16181
16182 /// A value the grammar does not cover is what it was before the vocabulary
16183 /// opened: carried untouched by the document, answered `None` by the query
16184 /// so the menu ticks *Default*, and rewritten only by a gesture on its own
16185 /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
16186 #[test]
16187 fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
16188 let src =
16189 "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
16190 let mut d = fmt_doc(src, Format::Djot);
16191 d.caret = d.source.find("hello").unwrap();
16192 assert_eq!(d.font_size_at_caret(), None);
16193 assert_eq!(d.text_color_at_caret(), None);
16194 assert_eq!(d.line_spacing_at_caret(), None);
16195
16196 // The keys are still there, untouched, after a gesture on a *different*
16197 // key — "edit one key and keep the rest" holds for a value it cannot
16198 // read as readily as for one it can.
16199 d.set_alignment(Some(Align::Center));
16200 assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
16201 assert!(
16202 d.source.contains("data-color=\"rgb(1,2,3)\""),
16203 "{:?}",
16204 d.source
16205 );
16206 assert!(
16207 d.source.contains("data-line-height=\"1.3em\""),
16208 "{:?}",
16209 d.source
16210 );
16211 // And the gesture on its *own* key replaces it, which is the one way a
16212 // carried value ever changes.
16213 d.caret = d.source.find("hello").unwrap();
16214 d.set_font_size(FontSize::points(12.0));
16215 assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
16216 assert!(!d.source.contains("huge"), "{:?}", d.source);
16217 }
16218
16219 /// The nearest node wins whichever *form* either node wrote: a value inside
16220 /// a name, a name inside a value. The fold has one rule and does not learn
16221 /// a second one for exact values.
16222 #[test]
16223 fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
16224 // A value inside a name: the block says `small`, the span says `14pt`.
16225 let mut d = fmt_doc(
16226 "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
16227 Format::Djot,
16228 );
16229 d.caret = d.source.find('y').unwrap();
16230 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16231 d.caret = d.source.find('x').unwrap();
16232 assert_eq!(
16233 d.font_size_at_caret(),
16234 Some(FontSize::Step(SizeStep::Small))
16235 );
16236
16237 // And a name inside a value, which is the same rule read the other way.
16238 let mut e = fmt_doc(
16239 "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
16240 Format::Djot,
16241 );
16242 e.caret = e.source.find('y').unwrap();
16243 assert_eq!(
16244 e.font_size_at_caret(),
16245 Some(FontSize::Step(SizeStep::Small))
16246 );
16247 assert_eq!(
16248 e.text_color_at_caret(),
16249 Some(TextColor::Rgb {
16250 r: 0xc0,
16251 g: 0x30,
16252 b: 0x30
16253 }),
16254 "the block's colour still reaches the span"
16255 );
16256 e.caret = e.source.find('x').unwrap();
16257 assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
16258 }
16259
16260 /// twig re-styles the span a range already lies in rather than nesting a
16261 /// second, and an empty set unwraps it — so a second press of the menu
16262 /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
16263 /// means "the theme's own" takes the span away.
16264 #[test]
16265 fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
16266 let mut d = fmt_doc("a big b\n", Format::Djot);
16267 d.anchor = Some(2);
16268 d.caret = 5;
16269 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16270 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16271
16272 // The selection `wrap_range_attrs` left behind covers the whole span;
16273 // colouring it now keeps the size, because the gesture reads the span's
16274 // attributes before it edits its own key.
16275 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16276 assert_eq!(
16277 d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
16278 "one span, both keys"
16279 );
16280 assert_eq!(
16281 d.font_size_at_caret(),
16282 Some(FontSize::Step(SizeStep::Large))
16283 );
16284 assert_eq!(
16285 d.text_color_at_caret(),
16286 Some(TextColor::Named(MarkColor::Red))
16287 );
16288
16289 d.set_text_color(None);
16290 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16291 d.set_font_size(None);
16292 assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
16293 assert_eq!(d.font_size_at_caret(), None);
16294 }
16295
16296 /// The queries read the nearest node that names the property: the span the
16297 /// caret is in, then its block, then the `div`s around it.
16298 #[test]
16299 fn a_presentation_query_reads_the_nearest_node_that_names_it() {
16300 let mut d = fmt_doc(
16301 "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
16302 Format::Djot,
16303 );
16304 // In the span: its own size, the block's face and alignment.
16305 d.caret = d.source.find('y').unwrap();
16306 assert_eq!(
16307 d.font_size_at_caret(),
16308 Some(FontSize::Step(SizeStep::XxLarge))
16309 );
16310 assert_eq!(
16311 d.font_family_at_caret(),
16312 Some(FontFace::Generic(FontFamily::Serif))
16313 );
16314 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
16315 assert_eq!(d.line_spacing_at_caret(), None);
16316 assert_eq!(d.text_color_at_caret(), None);
16317
16318 // Outside it: the block's size.
16319 d.caret = d.source.find('x').unwrap();
16320 assert_eq!(
16321 d.font_size_at_caret(),
16322 Some(FontSize::Step(SizeStep::Small))
16323 );
16324
16325 // And through a Markdown div, which is where a Markdown block's
16326 // attributes live.
16327 let mut md = fmt_doc(
16328 "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
16329 Format::Markdown,
16330 );
16331 md.caret = md.source.find("hello").unwrap();
16332 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16333 assert_eq!(
16334 md.font_size_at_caret(),
16335 Some(FontSize::Step(SizeStep::Large))
16336 );
16337
16338 // A document that names none of it answers `None` everywhere, which is
16339 // "the theme's own" and what every toolbar draws unlit.
16340 let mut plain = doc_with("plain_presentation", "hello\n");
16341 plain.caret = 1;
16342 assert_eq!(plain.alignment_at_caret(), None);
16343 assert_eq!(plain.line_spacing_at_caret(), None);
16344 assert_eq!(plain.font_size_at_caret(), None);
16345 assert_eq!(plain.font_family_at_caret(), None);
16346 assert_eq!(plain.text_color_at_caret(), None);
16347 }
16348
16349 /// A djot fenced div is anonymous the way an attributed span is, and is a
16350 /// block all the same — the *form* is the whole of what tells them apart.
16351 /// Read as a span it poisoned both halves: the run gesture copied the div's
16352 /// entire attribute set onto the span it minted, duplicating the `id`, and
16353 /// the run and block queries answered off a node the walker draws nothing
16354 /// for.
16355 #[test]
16356 fn a_djot_fenced_div_is_not_an_attributed_span() {
16357 let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
16358 let mut d = fmt_doc(src, Format::Djot);
16359 let at = d.source.find("world").unwrap();
16360 d.anchor = Some(at);
16361 d.caret = at + "world".len();
16362 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16363 assert_eq!(
16364 d.source,
16365 "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
16366 "the span carries its own key and nothing of the div's"
16367 );
16368
16369 // And the queries stop at the block: a djot div is not a `<div>`, the
16370 // walker lends its keys to nothing inside it, and a query that said
16371 // otherwise would tick a menu entry no glyph on screen obeys.
16372 assert_eq!(
16373 d.text_color_at_caret(),
16374 Some(TextColor::Named(MarkColor::Red))
16375 );
16376 assert_eq!(d.font_size_at_caret(), None);
16377 assert_eq!(d.alignment_at_caret(), None);
16378 }
16379
16380 /// Clearing a property the block does not name and a `div` around it does
16381 /// would write nothing and change nothing — twig's `set_block_attrs`
16382 /// reaches one node, and the div is not it. The gesture says so instead of
16383 /// leaving the author pressing an entry that never ticks.
16384 #[test]
16385 fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
16386 // Markdown, two paragraphs in one div: not the sole-child shape twig
16387 // writes, so `block_attrs_at_caret` reads the paragraph and the
16388 // paragraph names none of it.
16389 let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
16390 let mut md = fmt_doc(src, Format::Markdown);
16391 md.caret = md.source.find("hello").unwrap();
16392 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16393
16394 md.set_alignment(None);
16395 assert_eq!(md.source, src, "nothing written");
16396 assert!(!md.dirty);
16397 assert_eq!(
16398 md.status.as_deref(),
16399 Some("alignment: set on the div around the block")
16400 );
16401 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16402
16403 // The same for a `data-` key, at both levels — the block pair and the
16404 // run three, the run three at a bare caret being the block gesture.
16405 md.set_line_spacing(None);
16406 assert_eq!(md.source, src);
16407 assert_eq!(
16408 md.status.as_deref(),
16409 Some("line spacing: set on the div around the block")
16410 );
16411 md.set_font_size(None);
16412 assert_eq!(md.source, src);
16413 assert_eq!(
16414 md.status.as_deref(),
16415 Some("size: set on the div around the block")
16416 );
16417
16418 // HTML has no sole-child fold at all: a block's attributes go on the
16419 // block, so the div around one is always out of reach.
16420 let html_src = "<div class=\"center\"><p>hi</p></div>\n";
16421 let mut html = fmt_doc(html_src, Format::Html);
16422 html.caret = html.source.find("hi").unwrap();
16423 assert_eq!(html.alignment_at_caret(), Some(Align::Center));
16424 html.set_alignment(None);
16425 assert_eq!(html.source, html_src);
16426 assert!(!html.dirty);
16427 assert_eq!(
16428 html.status.as_deref(),
16429 Some("alignment: set on the div around the block")
16430 );
16431
16432 // And it is a refusal, not a rule against clearing: a block that names
16433 // the property itself still loses it, div or no div.
16434 let mut own = fmt_doc(
16435 "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
16436 Format::Html,
16437 );
16438 own.caret = own.source.find("hi").unwrap();
16439 own.set_alignment(None);
16440 assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
16441 assert_eq!(own.status, None);
16442 }
16443
16444 /// An edited key is rewritten **where it stands**. The proposal's worked
16445 /// example is the test: a paragraph that came in as `id="intro"
16446 /// class="lead center" data-line-height="1.5"` and is right-aligned goes
16447 /// out as the same list with one token changed. Removing the key and
16448 /// pushing it back shuffled a document's attributes on every press.
16449 #[test]
16450 fn an_edited_key_keeps_its_place_among_the_attributes() {
16451 let mut html = fmt_doc(
16452 "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
16453 Format::Html,
16454 );
16455 html.caret = html.source.find("hello").unwrap();
16456 html.set_alignment(Some(Align::Right));
16457 assert_eq!(
16458 html.source,
16459 "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
16460 );
16461
16462 // A `data-` key the same way, and a key the block did not have still
16463 // goes on the end.
16464 html.caret = html.source.find("hello").unwrap();
16465 html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16466 assert_eq!(
16467 html.source,
16468 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
16469 );
16470 html.caret = html.source.find("hello").unwrap();
16471 html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16472 assert_eq!(
16473 html.source,
16474 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
16475 );
16476
16477 // Djot writes the same list in its own spelling, and the order is the
16478 // author's there too.
16479 let mut dj = fmt_doc(
16480 "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
16481 Format::Djot,
16482 );
16483 dj.caret = dj.source.find("hello").unwrap();
16484 dj.set_alignment(Some(Align::Right));
16485 assert_eq!(
16486 dj.source,
16487 "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
16488 );
16489 }
16490
16491 /// A page break is a block, so twig alone lands one after the caret's whole
16492 /// block; the paragraph is parted at the caret first, exactly as
16493 /// `insert_thematic_break` parts it, and each format spells the directive
16494 /// its own way.
16495 #[test]
16496 fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
16497 let mut md = doc_with("page_break_md", "hello world\n");
16498 md.caret = 5;
16499 md.insert_page_break();
16500 assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
16501 assert!(md.dirty);
16502 assert_eq!(md.status, None);
16503
16504 let mut dj = fmt_doc("hello world\n", Format::Djot);
16505 dj.caret = 5;
16506 dj.insert_page_break();
16507 assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
16508
16509 // At a block's end there is no second half to mint, so the break simply
16510 // follows the block — the rule the rule button already has.
16511 let mut end = doc_with("page_break_end", "hello\n");
16512 end.caret = 5;
16513 end.insert_page_break();
16514 assert_eq!(end.source, "hello\n\n::page-break\n");
16515
16516 // And it reaches the map as the placeholder row a frontend paginates on.
16517 end.view = View::Wysiwyg;
16518 end.build_visual(80);
16519 assert_eq!(
16520 end.vmap
16521 .rows
16522 .iter()
16523 .find_map(|r| r.leaf_directive.as_ref())
16524 .map(|m| m.name.as_str()),
16525 Some(PAGE_BREAK)
16526 );
16527 }
16528
16529 /// The vocabulary's capabilities, per format. The two block properties are
16530 /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
16531 /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
16532 /// keeps an id and a role and has no slot for a `data-` key.
16533 #[test]
16534 fn the_presentation_capabilities_are_ragged_per_format() {
16535 for fmt in [Format::Markdown, Format::Djot, Format::Html] {
16536 let c = Capabilities::of(fmt);
16537 assert!(c.alignment, "{fmt:?} alignment");
16538 assert!(c.line_spacing, "{fmt:?} line spacing");
16539 assert!(c.font_size, "{fmt:?} size");
16540 assert!(c.font_family, "{fmt:?} face");
16541 assert!(c.text_color, "{fmt:?} colour");
16542 }
16543 // Markdown spells both only under the extensions leaf parses with — a
16544 // `<div>` and a `<span>` read back as containers under `html_elements`,
16545 // and `::page-break` as a directive under `directives`. Ask twig's own
16546 // defaults and the answer is no, which is why `Capabilities` is built
16547 // with `supports_with`.
16548 assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
16549 assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
16550 assert!(!Format::Markdown.supports(Gesture::InsertDirective));
16551
16552 let adoc = Capabilities::of(Format::Asciidoc);
16553 assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
16554 assert!(
16555 !adoc.font_size && !adoc.font_family && !adoc.text_color,
16556 "AsciiDoc has no inline spelling that keeps a data- key"
16557 );
16558
16559 // XML spells none of it, and neither page break.
16560 let xml = Capabilities::of(Format::Xml);
16561 assert!(!xml.alignment && !xml.font_size && !xml.page_break);
16562 assert!(Capabilities::of(Format::Markdown).page_break);
16563 assert!(Capabilities::of(Format::Djot).page_break);
16564
16565 // And those two *only*, though twig spells the gesture in HTML and
16566 // AsciiDoc as well: it spells it differently there —
16567 // `<page-break></page-break>` and `<<<` — and the walker reads neither,
16568 // so the button would write a break that draws as nothing at all in
16569 // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
16570 // what leaf can show, not what twig can write. See
16571 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
16572 let exts = parse_extensions();
16573 assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
16574 assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
16575 assert!(!Capabilities::of(Format::Html).page_break);
16576 assert!(!Capabilities::of(Format::Asciidoc).page_break);
16577 }
16578
16579 /// A format that cannot spell a property refuses in its own words and
16580 /// writes nothing — the guard every other gesture has.
16581 #[test]
16582 fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
16583 let src = "<doc><p>hello</p></doc>\n";
16584 #[allow(clippy::type_complexity)]
16585 let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
16586 ("alignment", &|d: &mut Doc| {
16587 d.set_alignment(Some(Align::Center))
16588 }),
16589 ("line spacing", &|d: &mut Doc| {
16590 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
16591 }),
16592 ("size", &|d: &mut Doc| {
16593 d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
16594 }),
16595 ("face", &|d: &mut Doc| {
16596 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
16597 }),
16598 ("colour", &|d: &mut Doc| {
16599 d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
16600 }),
16601 ("page break", &|d: &mut Doc| d.insert_page_break()),
16602 ];
16603 for (name, op) in ops {
16604 let mut d = fmt_doc(src, Format::Xml);
16605 let at = d.source.find("hello").unwrap();
16606 d.caret = at;
16607 d.anchor = Some(at + 5);
16608 op(&mut d);
16609 assert_eq!(d.source, src, "{name} edited an XML document");
16610 assert!(!d.dirty, "{name} marked the document dirty");
16611 let status = d.status.as_deref().unwrap_or("");
16612 assert!(
16613 status.contains("xml"),
16614 "{name}: the refusal should name the format, got {status:?}"
16615 );
16616 }
16617
16618 // AsciiDoc is the ragged one: the block gesture works where the run
16619 // gesture does not, and a *selection* is what tells the two apart.
16620 let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
16621 adoc.anchor = Some(0);
16622 adoc.caret = 5;
16623 adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16624 assert_eq!(adoc.source, "hello world\n", "no inline spelling");
16625 assert!(adoc.status.is_some());
16626 }
16627
16628 /// A read-only document takes none of it, and a caret on a blank line has
16629 /// no block to carry an attribute — both say so rather than writing.
16630 #[test]
16631 fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
16632 let mut ro = fmt_doc("hello\n", Format::Djot);
16633 ro.read_only = true;
16634 ro.caret = 1;
16635 ro.set_alignment(Some(Align::Center));
16636 assert_eq!(ro.source, "hello\n");
16637
16638 let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
16639 blank.caret = 2; // the empty line between the two paragraphs
16640 blank.set_alignment(Some(Align::Center));
16641 assert_eq!(blank.source, "a\n\n\nb\n");
16642 assert!(
16643 blank.status.as_deref().unwrap_or("").contains("no block"),
16644 "got {:?}",
16645 blank.status
16646 );
16647 }
16648
16649 /// A block attribute gesture keeps the caret on the **text** it was on, not
16650 /// on the byte offset it had. Markdown has nowhere to put a paragraph's
16651 /// attributes but a `<div>` around it, and twig splices the div and the
16652 /// block it wraps as one region — so a caret that kept its offset landed in
16653 /// the markup, and every press after the first answered "no block at the
16654 /// caret" with the toolbar's queries reading nothing.
16655 #[test]
16656 fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
16657 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16658 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16659 md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
16660
16661 // Wrapping: the div and two blank lines open above the block.
16662 md.set_alignment(Some(Align::Center));
16663 assert_eq!(
16664 md.source,
16665 "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
16666 );
16667 assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
16668 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16669
16670 // Re-styling: the attribute line changes length under the same caret,
16671 // and the second press reaches the same block rather than nothing.
16672 md.set_alignment(Some(Align::Right));
16673 assert_eq!(
16674 md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
16675 "a second press re-styles the div"
16676 );
16677 assert_eq!(md.status, None);
16678 assert_eq!(word(&md), 2);
16679
16680 // A second key on the same div — the line grows, the caret rides it.
16681 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16682 assert_eq!(
16683 md.source,
16684 "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16685 );
16686 assert_eq!(word(&md), 2);
16687 assert_eq!(
16688 md.line_spacing_at_caret(),
16689 Some(LineHeight::Step(LineSpacing::Double))
16690 );
16691
16692 // Unwrapping: the line shrinks, and then the div goes altogether.
16693 md.set_alignment(None);
16694 assert_eq!(
16695 md.source,
16696 "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16697 );
16698 assert_eq!(word(&md), 2);
16699 md.set_line_spacing(None);
16700 assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
16701 assert_eq!(word(&md), 2, "the caret came back down with the block");
16702 assert_eq!(md.alignment_at_caret(), None);
16703 assert_eq!(md.status, None);
16704 }
16705
16706 /// The same rule in djot, where the spelling is a `{…}` line *above* the
16707 /// block rather than a wrapper around it: inserting it pushes the block
16708 /// down, re-styling it changes the line's length, and clearing the last key
16709 /// takes the line away again. The caret rides all three.
16710 #[test]
16711 fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
16712 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16713 let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
16714 dj.caret = dj.source.find("brown").unwrap() + 2;
16715
16716 dj.set_alignment(Some(Align::Center));
16717 assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
16718 assert_eq!(word(&dj), 2);
16719 assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
16720
16721 dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16722 assert_eq!(
16723 dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
16724 "a second press edits the line the first wrote"
16725 );
16726 assert_eq!(word(&dj), 2);
16727
16728 dj.set_alignment(None);
16729 assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
16730 assert_eq!(word(&dj), 2);
16731
16732 dj.set_line_spacing(None);
16733 assert_eq!(dj.source, "the quick brown fox\n");
16734 assert_eq!(word(&dj), 2);
16735 assert_eq!(dj.status, None);
16736 }
16737
16738 /// The run gestures with no selection are the block gesture, so they keep
16739 /// the caret the same way — and a heading keeps it inside the heading's own
16740 /// text, past the `# ` its content span starts after. A selection rides
16741 /// along whole: a block gesture is not a run gesture, and what was selected
16742 /// before the press is still selected after it.
16743 #[test]
16744 fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
16745 // No selection: the run gesture goes through the block door.
16746 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16747 md.caret = md.source.find("brown").unwrap() + 2;
16748 md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16749 assert_eq!(
16750 md.source,
16751 "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
16752 );
16753 assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
16754 assert_eq!(
16755 md.font_size_at_caret(),
16756 Some(FontSize::Step(SizeStep::Large))
16757 );
16758 md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
16759 assert_eq!(
16760 md.font_size_at_caret(),
16761 Some(FontSize::Step(SizeStep::Small)),
16762 "the second press reached the same block"
16763 );
16764
16765 // A selection: alignment is the block's whatever is selected, and the
16766 // words stay selected.
16767 let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
16768 let at = sel.source.find("brown").unwrap();
16769 sel.anchor = Some(at);
16770 sel.caret = at + 5;
16771 sel.set_alignment(Some(Align::Center));
16772 let now = sel.source.find("brown").unwrap();
16773 assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
16774
16775 // A heading: the content span starts past the `# `.
16776 let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
16777 h.caret = h.source.find("there").unwrap() + 1;
16778 h.set_alignment(Some(Align::Right));
16779 assert_eq!(
16780 h.source,
16781 "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
16782 );
16783 assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
16784 assert_eq!(h.alignment_at_caret(), Some(Align::Right));
16785 }
16786
16787 /// A djot document open in the rich view, with its map built as
16788 /// [`wysiwyg_doc`] builds a Markdown one's.
16789 fn wysiwyg_djot(body: &str) -> Doc {
16790 let mut d = fmt_doc(body, Format::Djot);
16791 d.view = View::Wysiwyg;
16792 d.build_visual(80);
16793 d
16794 }
16795
16796 /// Backspace at the start of a block whose presentation is spelled as
16797 /// hidden markup before it strips that presentation, the way Backspace at
16798 /// a heading's start strips its `#`. The ordinary delete fused djot's
16799 /// `{.center}` line onto the text and took the blank line a Markdown div
16800 /// needs between its tag and its paragraph.
16801 #[test]
16802 fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
16803 let mut md = wysiwyg_doc(
16804 "wys_attr_bksp",
16805 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16806 );
16807 md.caret = md.source.find("hello").unwrap();
16808 md.backspace();
16809 assert_eq!(
16810 md.source, "above\n\nhello\n\nbelow\n",
16811 "the div is unwrapped"
16812 );
16813 assert_eq!(md.caret, 7, "the caret stays at the start of its text");
16814 md.backspace();
16815 assert_eq!(
16816 md.source, "above\nhello\n\nbelow\n",
16817 "the next press joins the paragraphs, as it always did"
16818 );
16819
16820 let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
16821 dj.caret = dj.source.find("hello").unwrap();
16822 dj.backspace();
16823 assert_eq!(
16824 dj.source, "above\n\nhello\n\nbelow\n",
16825 "the attribute line goes"
16826 );
16827 assert_eq!(dj.caret, 7);
16828
16829 // A heading's own marker is the nearer hidden markup, and goes first;
16830 // the attributes are the next press's.
16831 let mut dj = wysiwyg_djot("{.center}\n# Title\n");
16832 dj.caret = dj.source.find("Title").unwrap();
16833 dj.backspace();
16834 assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
16835 dj.build_visual(80);
16836 dj.backspace();
16837 assert_eq!(dj.source, "Title\n", "then the attributes");
16838 assert_eq!(dj.caret, 0);
16839 }
16840
16841 /// A div around several blocks has no sole child for twig to unwrap, so
16842 /// at its first block the caret steps back to the stop before rather than
16843 /// taking the div apart; a later block has an ordinary paragraph above it
16844 /// and joins as any paragraph does.
16845 #[test]
16846 fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
16847 let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
16848 let mut d = wysiwyg_doc("wys_div_first", src);
16849 d.caret = d.source.find("hello").unwrap();
16850 d.backspace();
16851 assert_eq!(d.source, src, "nothing is deleted");
16852 assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
16853
16854 let mut d = wysiwyg_doc("wys_div_later", src);
16855 d.caret = d.source.find("world").unwrap();
16856 d.backspace();
16857 assert_eq!(
16858 d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
16859 "a later block joins the one above it"
16860 );
16861 }
16862
16863 /// Backspace at the start of the paragraph after a Markdown div joins it
16864 /// into the div's last paragraph — the join any two paragraphs make, with
16865 /// the hidden `</div>` carried past the joined text. The ordinary delete
16866 /// took the newline under the tag, which drew nothing different, and the
16867 /// next press took the `>` and left the div unclosed.
16868 #[test]
16869 fn backspace_after_a_div_joins_the_paragraph_into_it() {
16870 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
16871 let mut d = wysiwyg_doc("wys_div_join", src);
16872 d.caret = d.source.find("below").unwrap();
16873 d.backspace();
16874 assert_eq!(
16875 d.source,
16876 "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
16877 );
16878 assert_eq!(
16879 d.caret,
16880 d.source.find("below").unwrap(),
16881 "the caret stays at the start of the joined text"
16882 );
16883 d.build_visual(80);
16884 assert_eq!(
16885 d.alignment_at_caret(),
16886 Some(Align::Center),
16887 "and is centred now"
16888 );
16889 d.undo();
16890 assert_eq!(d.source, src, "one undo step");
16891
16892 // A list closes the div: the paragraph joins the last item's text,
16893 // under the item's continuation indent, inside the div.
16894 let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
16895 let mut d = wysiwyg_doc("wys_div_list", src);
16896 d.caret = d.source.find("below").unwrap();
16897 d.backspace();
16898 assert_eq!(
16899 d.source, "<div class=\"center\">\n\n- item\n below\n\n</div>\n",
16900 "the paragraph joins the item"
16901 );
16902 assert_eq!(d.caret, d.source.find("below").unwrap());
16903
16904 // And where twig has nothing to join into — a code block above — the
16905 // caret steps back to the stop before, and nothing is deleted.
16906 let src = "```\ncode\n```\n\nbelow\n";
16907 let mut d = wysiwyg_doc("wys_code_then_para", src);
16908 d.caret = d.source.find("below").unwrap();
16909 d.backspace();
16910 assert_eq!(d.source, src, "nothing is deleted");
16911 assert!(
16912 d.caret < d.source.find("below").unwrap(),
16913 "the caret stepped back"
16914 );
16915 }
16916
16917 /// Backspace on a blank line collapses to the stop before it — but not
16918 /// across hidden markup, which that collapse deleted whole: a `</div>`,
16919 /// or a comment between two blocks. There the blank line goes alone, and
16920 /// the caret lands where the collapse would have put it.
16921 #[test]
16922 fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
16923 let mut d = wysiwyg_doc(
16924 "wys_div_blank",
16925 "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
16926 );
16927 d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
16928 assert!(
16929 d.vmap.is_stop(d.caret),
16930 "the empty paragraph is a caret home"
16931 );
16932 d.backspace();
16933 assert_eq!(
16934 d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16935 "the blank line goes and the div stays closed"
16936 );
16937 assert_eq!(
16938 d.caret,
16939 d.source.find("hello").unwrap() + 5,
16940 "onto the end of `hello`"
16941 );
16942
16943 let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
16944 d.caret = d.source.find("below").unwrap() - 2;
16945 assert!(d.vmap.is_stop(d.caret));
16946 d.backspace();
16947 assert_eq!(
16948 d.source, "above\n\n<!-- note -->\n\nbelow\n",
16949 "the comment stays"
16950 );
16951 assert_eq!(d.caret, 5);
16952 }
16953
16954 /// Backspace at the end of an attributed span steps inside its hidden
16955 /// closing tag the way it steps inside a `**`, and takes the span with
16956 /// its last letter. Before, the byte-step took the `>` of `</span>`,
16957 /// which left the paragraph unparseable: it vanished from the rich view,
16958 /// and the Backspace after that joined the next block into the wreck.
16959 #[test]
16960 fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
16961 let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
16962 let mut d = wysiwyg_doc("wys_span_bs", src);
16963 d.caret = d.source.find("a test").unwrap() + 6;
16964 for _ in 0..7 {
16965 d.backspace();
16966 }
16967 assert_eq!(
16968 d.source,
16969 "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
16970 );
16971 d.backspace();
16972 assert_eq!(
16973 d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
16974 "the first Backspace after the tag takes the letter, not the `>`"
16975 );
16976 d.backspace();
16977 assert_eq!(
16978 d.source, "above\n\nThis \n\nTest 2\n",
16979 "the last letter takes the span with it"
16980 );
16981 assert_eq!(d.caret, 12, "the caret is where the letter was");
16982 d.backspace();
16983 assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
16984 assert_eq!(d.caret, 11);
16985 d.build_visual(80);
16986 assert!(
16987 d.vmap
16988 .rows
16989 .iter()
16990 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
16991 "the paragraph is still drawn"
16992 );
16993 }
16994
16995 #[test]
16996 fn backspace_walks_into_a_djot_sized_span_too() {
16997 let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
16998 d.caret = d.source.find("\n\nTest 2").unwrap();
16999 // The caret home at the paragraph's end is inside the span, before
17000 // its `]`: the map offers no stop after `]{…}`.
17001 d.build_visual(80);
17002 assert_eq!(d.vmap.stop_before(31), Some(8));
17003 d.caret = 8;
17004 d.backspace();
17005 assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
17006 d.backspace();
17007 assert_eq!(
17008 d.source, "This \n\nTest 2\n",
17009 "the attribute block outside the span goes with it"
17010 );
17011 d.backspace();
17012 assert_eq!(d.source, "This\n\nTest 2\n");
17013 assert_eq!(d.caret, 4);
17014 }
17015
17016 /// A span that is empty as the file was written has no stop of its own;
17017 /// Backspace reaching it from behind takes it with the character before
17018 /// it, the character the key looked aimed at.
17019 #[test]
17020 fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
17021 let src = "This <span data-size=\"x-large\"></span> a test\n";
17022 let mut d = wysiwyg_doc("wys_span_empty", src);
17023 d.caret = d.source.find(" a test").unwrap();
17024 d.backspace();
17025 assert_eq!(d.source, "This a test\n");
17026 assert_eq!(d.caret, 4);
17027 }
17028
17029 /// The mirror: Delete in front of a span's opening tag takes its first
17030 /// letter, and the span with its last.
17031 #[test]
17032 fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
17033 let src = "This <span data-size=\"x-large\">is</span> a test\n";
17034 let mut d = wysiwyg_doc("wys_span_del", src);
17035 d.caret = 5;
17036 d.delete_forward();
17037 assert_eq!(
17038 d.source,
17039 "This <span data-size=\"x-large\">s</span> a test\n"
17040 );
17041 d.delete_forward();
17042 assert_eq!(d.source, "This a test\n");
17043 assert_eq!(d.caret, 5);
17044 d.delete_forward();
17045 assert_eq!(d.source, "This a test\n");
17046 assert_eq!(d.caret, 5);
17047 }
17048
17049 /// The block version of the span's emptying rule. Centre a one-letter
17050 /// paragraph — Markdown spells that as a `<div>` around it — and
17051 /// Backspace the letter: the div goes with it, leaving a plain blank line
17052 /// the caret is at home on, in the incremental map and the from-scratch
17053 /// one alike. Before, the letter went alone; the emptied div drew a
17054 /// caret home only the stale map had, and the next Backspace collapsed
17055 /// the line and left `<div class="center">\n\n</div>` standing invisibly
17056 /// in the file.
17057 #[test]
17058 fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17059 let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
17060 d.caret = d.source.len() - 1;
17061 d.set_alignment(Some(Align::Center));
17062 assert_eq!(
17063 d.source,
17064 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
17065 );
17066 d.backspace();
17067 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17068 assert_eq!(d.caret, 18, "on the blank line where the letter was");
17069 // The host rebuilds the map after every key; the spliced map and a
17070 // fresh one both give the line a caret home.
17071 d.build_visual_unwrapped();
17072 assert!(d.vmap.is_stop(18));
17073 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17074 d.backspace();
17075 assert_eq!(
17076 d.source, "Try the toolbar.\n",
17077 "then the blank line collapses"
17078 );
17079 assert_eq!(d.caret, 16);
17080
17081 // With a block after the div the blank line keeps a gap each side.
17082 let mut d = wysiwyg_doc(
17083 "wys_div_empty_mid",
17084 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
17085 );
17086 d.caret = d.source.find("T\n").unwrap() + 1;
17087 d.backspace();
17088 assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
17089 assert_eq!(d.caret, 18);
17090 d.build_visual_unwrapped();
17091 assert!(d.vmap.is_stop(18));
17092 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17093 }
17094
17095 /// djot spells the same paragraph as a `{.center}` line above it, and
17096 /// twig has no node at all for that line once the paragraph is gone —
17097 /// so the line goes with the letter too.
17098 #[test]
17099 fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
17100 let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
17101 d.build_visual(80);
17102 d.caret = d.source.len() - 1;
17103 d.set_alignment(Some(Align::Center));
17104 assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
17105 d.backspace();
17106 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17107 assert_eq!(d.caret, 18);
17108 d.build_visual(80);
17109 assert!(d.vmap.is_stop(18));
17110 }
17111
17112 /// The rule is for a block that would be no block: a div holding more
17113 /// keeps its tags, and an emptied heading is still a heading.
17114 #[test]
17115 fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
17116 let mut d = wysiwyg_doc(
17117 "wys_div_more",
17118 "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
17119 );
17120 d.caret = d.source.find("T\n").unwrap() + 1;
17121 d.backspace();
17122 assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
17123 assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
17124
17125 let mut d = wysiwyg_doc(
17126 "wys_div_heading",
17127 "<div class=\"center\">\n\n# T\n\n</div>\n",
17128 );
17129 d.caret = d.source.find("T\n").unwrap() + 1;
17130 d.backspace();
17131 assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
17132 assert_eq!(d.caret, 24);
17133 d.build_visual(80);
17134 assert!(
17135 d.vmap.is_stop(24),
17136 "the empty heading is still a caret home"
17137 );
17138 }
17139
17140 /// The mirror: Delete in front of the letter takes the div with it.
17141 #[test]
17142 fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17143 let mut d = wysiwyg_doc(
17144 "wys_div_empty_del",
17145 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
17146 );
17147 d.caret = d.source.find("T\n").unwrap();
17148 d.delete_forward();
17149 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17150 assert_eq!(d.caret, 18);
17151 d.build_visual(80);
17152 assert!(d.vmap.is_stop(18));
17153
17154 let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
17155 d.build_visual(80);
17156 d.caret = d.source.find("T\n").unwrap();
17157 d.delete_forward();
17158 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17159 assert_eq!(d.caret, 18);
17160 }
17161
17162 /// Backspace at a block's start is twig's join, spelled per format — so
17163 /// the cases the one-newline delete got wrong come out right: a
17164 /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
17165 /// and a quote's prefix is written on the joined line.
17166 #[test]
17167 fn backspace_at_a_block_start_joins_it_the_format_s_way() {
17168 let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
17169 d.caret = d.source.find("below").unwrap();
17170 d.backspace();
17171 assert_eq!(d.source, "# Title below\n", "onto the heading's line");
17172 assert_eq!(d.caret, d.source.find("below").unwrap());
17173 d.undo();
17174 assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
17175
17176 let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
17177 d.caret = d.source.find('b').unwrap();
17178 d.backspace();
17179 assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
17180 assert_eq!(d.caret, d.source.find('b').unwrap());
17181
17182 let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
17183 h.view = View::Wysiwyg;
17184 h.build_visual(80);
17185 h.caret = h.source.find("below").unwrap();
17186 h.backspace();
17187 assert_eq!(
17188 h.source, "<p>above\nbelow</p>\n",
17189 "one paragraph, the tag gone whole"
17190 );
17191 assert_eq!(h.caret, h.source.find("below").unwrap());
17192 }
17193
17194 /// Delete at the end of a block's content is the same join aimed at the
17195 /// block after it, and the caret stays where the joined text now begins.
17196 #[test]
17197 fn delete_at_a_block_end_joins_the_next_block_into_it() {
17198 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17199 let mut d = wysiwyg_doc("wys_del_join", src);
17200 d.caret = d.source.find("hello").unwrap() + 5;
17201 d.delete_forward();
17202 assert_eq!(
17203 d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
17204 "below joins hello inside the div"
17205 );
17206 assert_eq!(
17207 d.caret,
17208 d.source.find("hello").unwrap() + 5,
17209 "the caret stays"
17210 );
17211
17212 let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
17213 d.caret = 5;
17214 d.delete_forward();
17215 assert_eq!(
17216 d.source, "above\nTitle\n",
17217 "the heading's marker goes with the join"
17218 );
17219 assert_eq!(d.caret, 5);
17220
17221 // A code block after the paragraph: nothing to join, the caret steps
17222 // forward onto the next stop and nothing is deleted.
17223 let src = "above\n\n```\ncode\n```\n";
17224 let mut d = wysiwyg_doc("wys_del_code", src);
17225 d.caret = 5;
17226 d.delete_forward();
17227 assert_eq!(d.source, src);
17228 assert!(d.caret > 5, "the caret stepped forward");
17229 }
17230
17231 // ── Text statistics ──────────────────────────────────────────────────────
17232
17233 /// The counts of `body`, from a document open in the WYSIWYG view — the
17234 /// shape every case below starts from.
17235 fn counts_of(name: &str, body: &str) -> TextCounts {
17236 doc_in(View::Wysiwyg, name, body).counts()
17237 }
17238
17239 #[test]
17240 fn counts_tally_plain_prose() {
17241 let c = counts_of(
17242 "counts_prose",
17243 "The quick brown fox jumps over the lazy dog.\n",
17244 );
17245 assert_eq!(
17246 c,
17247 TextCounts {
17248 words: 9,
17249 characters: 44,
17250 characters_without_spaces: 36,
17251 paragraphs: 1,
17252 }
17253 );
17254 }
17255
17256 #[test]
17257 fn counts_read_the_text_and_not_the_markup() {
17258 // The `**` are four bytes of source and no part of the word.
17259 assert_eq!(
17260 counts_of("counts_marks", "a **bold** word\n"),
17261 TextCounts {
17262 words: 3,
17263 characters: 11,
17264 characters_without_spaces: 9,
17265 paragraphs: 1,
17266 }
17267 );
17268 // A link is its label; the destination is plumbing, however long.
17269 assert_eq!(
17270 counts_of(
17271 "counts_link",
17272 "see [the label](https://example.com/a/b/c) here\n"
17273 ),
17274 TextCounts {
17275 words: 4,
17276 characters: 18,
17277 characters_without_spaces: 15,
17278 paragraphs: 1,
17279 }
17280 );
17281 }
17282
17283 #[test]
17284 fn counts_spend_nothing_on_a_picture() {
17285 // A block image renders as a `🖼 alt` placeholder — a picture, not a
17286 // sentence, and not a paragraph either.
17287 assert_eq!(
17288 counts_of("counts_image", "\n"),
17289 TextCounts::default()
17290 );
17291 // And it adds nothing to the prose around it.
17292 assert_eq!(
17293 counts_of("counts_image_prose", "text\n\n\n"),
17294 TextCounts {
17295 words: 1,
17296 characters: 4,
17297 characters_without_spaces: 4,
17298 paragraphs: 1,
17299 }
17300 );
17301 }
17302
17303 #[test]
17304 fn counts_spend_nothing_on_drawn_furniture() {
17305 // A thematic break is drawn, not written, and an empty paragraph has
17306 // nothing in it — neither is a paragraph of the document.
17307 assert_eq!(
17308 counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
17309 TextCounts {
17310 words: 2,
17311 characters: 6,
17312 characters_without_spaces: 6,
17313 paragraphs: 2,
17314 }
17315 );
17316 }
17317
17318 #[test]
17319 fn counts_measure_characters_as_a_reader_does() {
17320 // Four Han characters (each its own word under UAX#29), one ZWJ emoji
17321 // family that is a single grapheme cluster, and two letters.
17322 let c = counts_of(
17323 "counts_graphemes",
17324 "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
17325 );
17326 assert_eq!(
17327 c,
17328 TextCounts {
17329 words: 5,
17330 characters: 9,
17331 characters_without_spaces: 7,
17332 paragraphs: 1,
17333 }
17334 );
17335 }
17336
17337 #[test]
17338 fn counts_take_a_code_block_as_one_paragraph() {
17339 let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
17340 assert_eq!(
17341 c,
17342 TextCounts {
17343 words: 6,
17344 characters: 20,
17345 characters_without_spaces: 14,
17346 paragraphs: 1,
17347 }
17348 );
17349 }
17350
17351 #[test]
17352 fn counts_take_a_table_as_one_paragraph() {
17353 // The box-drawn borders and the column padding are the renderer's, not
17354 // the author's; the cells are what was written.
17355 let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
17356 assert_eq!(
17357 c,
17358 TextCounts {
17359 words: 5,
17360 characters: 6,
17361 characters_without_spaces: 5,
17362 paragraphs: 1,
17363 }
17364 );
17365 }
17366
17367 #[test]
17368 fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
17369 let c = counts_of(
17370 "counts_blocks",
17371 "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
17372 );
17373 assert_eq!(
17374 c,
17375 TextCounts {
17376 words: 7,
17377 characters: 36,
17378 characters_without_spaces: 34,
17379 paragraphs: 5,
17380 }
17381 );
17382 }
17383
17384 #[test]
17385 fn counts_leave_the_frontmatter_out() {
17386 // The WYSIWYG view doesn't render it and a writer didn't write it.
17387 let c = counts_of(
17388 "counts_frontmatter",
17389 "---\ntitle: Hidden\n---\n\nvisible words here\n",
17390 );
17391 assert_eq!(
17392 c,
17393 TextCounts {
17394 words: 3,
17395 characters: 18,
17396 characters_without_spaces: 16,
17397 paragraphs: 1,
17398 }
17399 );
17400 }
17401
17402 #[test]
17403 fn counts_of_an_empty_document_are_all_zero() {
17404 assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
17405 }
17406
17407 /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
17408 /// words. Recorded rather than corrected: it is what the algorithm says,
17409 /// and what every other UAX#29 counter reports.
17410 #[test]
17411 fn counts_split_a_hyphenated_compound_in_two() {
17412 let c = counts_of("counts_hyphen", "well-known example\n");
17413 assert_eq!(c.words, 3);
17414 assert_eq!(c.characters, 18);
17415 // Punctuation on its own is no word, and an apostrophe doesn't split one.
17416 assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
17417 }
17418
17419 #[test]
17420 fn selection_counts_measure_the_selection_and_nothing_without_one() {
17421 let src = "alpha beta\n\ngamma delta\n";
17422 let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
17423 assert_eq!(d.selection_counts(), None, "no selection, no counts");
17424
17425 // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
17426 d.select_range(6, 17);
17427 assert_eq!(
17428 d.selection_counts(),
17429 Some(TextCounts {
17430 words: 2,
17431 characters: 9,
17432 characters_without_spaces: 9,
17433 paragraphs: 2,
17434 })
17435 );
17436 }
17437
17438 /// The count is of the document, not of the window it is shown in — so
17439 /// ⌘E must not move it, and neither must a resize or an edit made with no
17440 /// map built at all.
17441 #[test]
17442 fn counts_agree_across_the_views() {
17443 let src =
17444 "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
17445 let mut d = doc_in(View::Wysiwyg, "counts_views", src);
17446 let wysiwyg = d.counts();
17447 assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
17448
17449 d.toggle_view();
17450 assert_eq!(d.view, View::Source);
17451 d.build_source();
17452 assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
17453
17454 // A narrower measure is a narrower window, not a shorter document.
17455 d.toggle_view();
17456 d.build_visual(24);
17457 assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
17458
17459 // And an edit made in the source view, with the visual map left stale,
17460 // still counts the document as it now stands.
17461 d.toggle_view();
17462 d.caret = d.source.len();
17463 d.insert("\n\ntail words\n");
17464 let after = d.counts();
17465 assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
17466 assert_eq!(after.words, wysiwyg.words + 2);
17467 }
17468
17469 // ── math ─────────────────────────────────────────────────────────────────
17470
17471 #[test]
17472 fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
17473 // The rule the proposal states: a formula's content is its TeX, not
17474 // its picture, so it reveals on the caret's line in *every* mode —
17475 // and nothing else on that line does outside `Full`.
17476 let mut d = doc_in(
17477 View::Wysiwyg,
17478 "math_reveal",
17479 "*one* $x+y$ here\n\ntwo there\n",
17480 );
17481 d.set_inline_pictures(true);
17482 assert_eq!(d.markup_mode(), MarkupMode::None);
17483
17484 // Away from the formula's line: the atom, and no reveal at all.
17485 caret_at(&mut d, "two");
17486 assert!(
17487 drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
17488 "{:?}",
17489 drawn_rows(&d)
17490 );
17491 assert_eq!(d.vmap.math.len(), 1);
17492 assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
17493
17494 // On it: the formula is its source, the emphasis is still resolved.
17495 caret_at(&mut d, "here");
17496 assert!(
17497 drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
17498 "{:?}",
17499 drawn_rows(&d)
17500 );
17501 assert!(d.vmap.math.is_empty());
17502 assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
17503
17504 // Off again, and the picture is back.
17505 caret_at(&mut d, "two");
17506 assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
17507
17508 // The same in Shortcuts; and Full reveals the emphasis too.
17509 d.set_markup_mode(MarkupMode::Shortcuts);
17510 caret_at(&mut d, "here");
17511 assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
17512 d.set_markup_mode(MarkupMode::Full);
17513 caret_at(&mut d, "here");
17514 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
17515 }
17516
17517 #[test]
17518 fn a_formula_closed_by_typing_reveals_at_once() {
17519 // The reveal line is decided from the last build's layout, which
17520 // across an edit is stale: the keystroke that closes a `$…$` asks a
17521 // layout that knew no math. `build_map` asks again once the new
17522 // layout is in, so the formula does not snap to its picture under
17523 // the caret.
17524 let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
17525 d.set_inline_pictures(true);
17526 d.set_markup_mode(MarkupMode::Shortcuts);
17527 d.caret = 4;
17528 for ch in ["$", "x", "$"] {
17529 d.insert(ch);
17530 d.build_visual(80);
17531 }
17532 assert_eq!(d.source, "say $x$\n");
17533 assert_eq!(
17534 drawn_rows(&d)[0],
17535 "say $x$",
17536 "source, not a picture, under the caret"
17537 );
17538 assert!(d.vmap.math.is_empty());
17539 // Leaving the line folds it — there is only one line, so add one.
17540 d.newline();
17541 d.insert("more");
17542 d.build_visual(80);
17543 assert_eq!(drawn_rows(&d)[0], "say ∑");
17544 assert_eq!(d.vmap.math.len(), 1);
17545 // And deleting the formula while revealed drops the reveal with it.
17546 d.caret = 7;
17547 d.build_visual(80);
17548 assert_eq!(drawn_rows(&d)[0], "say $x$");
17549 for _ in 0..3 {
17550 d.backspace();
17551 }
17552 d.build_visual(80);
17553 assert_eq!(d.source, "say \n\nmore\n");
17554 assert_eq!(d.reveal_line(), None);
17555 }
17556
17557 #[test]
17558 fn a_display_block_is_edited_where_it_stands() {
17559 let mut d = doc_in(
17560 View::Wysiwyg,
17561 "math_block",
17562 "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
17563 );
17564 caret_at(&mut d, "end");
17565 assert_eq!(
17566 drawn_rows(&d),
17567 vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
17568 );
17569 // Up from `end` lands on the placeholder, whose glyphs all carry the
17570 // block's start — which is on its `$$` line, so the block reveals.
17571 d.move_up(false);
17572 d.build_visual(80);
17573 assert_eq!(d.caret, 7);
17574 assert_eq!(
17575 drawn_rows(&d),
17576 vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
17577 );
17578 // Down walks the source lines, still revealed; typing edits the TeX.
17579 d.move_down(false);
17580 d.build_visual(80);
17581 assert_eq!(d.caret, 10);
17582 d.move_end(false);
17583 d.insert("^2");
17584 d.build_visual(80);
17585 assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
17586 assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
17587 // Out below, and it folds to the placeholder with the new TeX.
17588 d.move_down(false);
17589 d.move_down(false);
17590 d.move_down(false);
17591 d.build_visual(80);
17592 assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
17593 assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
17594 }
17595
17596 #[test]
17597 fn set_math_rows_reserves_filler_rows_by_tex() {
17598 let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
17599 caret_at(&mut d, "end");
17600 assert_eq!(d.vmap.math[0].rows_span, 0..1);
17601 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
17602 d.build_visual(80);
17603 assert_eq!(d.vmap.math[0].rows_span, 0..3);
17604 assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
17605 // Cheap when nothing changed.
17606 let key = d.visual_key();
17607 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
17608 d.build_visual(80);
17609 assert_eq!(d.visual_key(), key);
17610 }
17611
17612 #[test]
17613 fn a_dollar_typed_in_shortcuts_authors_math() {
17614 // (In `None` the same keystrokes *also* mint a formula for now: twig's
17615 // `insert_literal` does not yet escape `$` under the math extension —
17616 // see `docs/tasks/a-typed-dollar-mints-math-in-the-hidden-mode.md`.)
17617 let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
17618 d.set_markup_mode(MarkupMode::Shortcuts);
17619 d.caret = 0;
17620 d.insert("$x$");
17621 assert_eq!(d.source, "$x$\n");
17622 d.caret = 1;
17623 assert_eq!(d.breadcrumb(), "doc › para › inline_math");
17624 }
17625
17626 #[test]
17627 fn counts_see_a_formula_as_a_picture_however_it_is_written() {
17628 let c = counts_of(
17629 "counts_math",
17630 "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
17631 );
17632 // `the sum … and` is three words; neither formula counts, and the
17633 // display block is not a paragraph of text.
17634 assert_eq!(c.words, 3);
17635 assert_eq!(c.paragraphs, 1);
17636 }
17637}