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/// Where a dragged block would land — the answer to [`Doc::drop_target_at`].
404///
405/// A drop is aimed at a *row* (the one under the pointer) and lands at a
406/// *boundary* (between two blocks), and a frontend needs both halves: the
407/// offset to hand [`Doc::move_block`], and the row to draw the indicator
408/// above — which is not the row aimed at, since a drop on the lower half of a
409/// block lands below it.
410#[derive(Clone, Copy, PartialEq, Eq, Debug)]
411pub struct DropTarget {
412 /// The boundary offset — `move_block`'s `to`.
413 pub offset: usize,
414 /// The rendered row the indicator is drawn above; `rows.len()` for a drop
415 /// below everything.
416 pub row: usize,
417}
418
419/// A selection cited out of the source: the text itself, up to a requested
420/// number of characters either side, and the byte range it came from. See
421/// [`Doc::selection_quote`].
422///
423/// The prefix and suffix are what make the quote *re-findable*: the same text
424/// can occur twice, and a little of what surrounded it is how a later reader —
425/// or the same document after an edit — tells the occurrences apart. The Web
426/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
427/// the name.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct Quote {
430 /// The selected source, verbatim.
431 pub exact: String,
432 /// What immediately preceded it — possibly empty, at the document's start.
433 pub prefix: String,
434 /// What immediately followed it — possibly empty, at the document's end.
435 pub suffix: String,
436 /// Byte offset in the source where the selection begins.
437 pub start: usize,
438 /// Byte offset where it ends (exclusive).
439 pub end: usize,
440}
441
442/// A host-painted range of the source — an annotation's footprint, a search
443/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
444/// glyphs whose source falls inside it) and hands back the `id` when the
445/// reader activates it; what the range *means* is entirely the host's.
446///
447/// Ranges are source bytes, like the caret and the selection, so a host that
448/// anchors quotes against the source ([`Doc::selection_quote`] is the other
449/// half of that loop) can paint what it found without any coordinate
450/// conversion. A range that drifts off the text it meant is the host's to
451/// re-anchor; leaf draws what it is told.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct Highlight {
454 /// Byte offset in the source where the wash begins.
455 pub start: usize,
456 /// Byte offset where it ends (exclusive).
457 pub end: usize,
458 /// The host's name for it, handed back on activation. Opaque to leaf.
459 pub id: String,
460 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
461 /// nothing for the theme's default wash.
462 pub color: Option<String>,
463 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
464 /// a marker gets a small glyph in the margin beside its first line, and
465 /// the glyph — not the wash — is what activates it: the wash is ink, the
466 /// marker is the control, which is what lets a reader put a caret in (or
467 /// copy from) annotated text without a card leaping at them. The name is
468 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
469 /// as a class.
470 pub marker: Option<String>,
471}
472
473impl Highlight {
474 /// The range covering source `offset` in a list [`Doc::set_highlights`]
475 /// sorted, first by start where several overlap — the one place that
476 /// question is answered, for the frontends that paint by asking it as well
477 /// as for [`Doc::highlight_at`].
478 ///
479 /// The list is sorted by `(start, end)`, so the scan can stop at the first
480 /// range starting past `offset` rather than running to the end. A painter
481 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
482 /// one-shot form, for the host asking what the reader just activated.
483 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
484 highlights
485 .iter()
486 .take_while(|h| h.start <= offset)
487 .find(|h| offset < h.end)
488 }
489}
490
491/// [`Highlight::covering`] for a caller walking the document in order — which
492/// is every painter, since a frontend draws rows top to bottom and glyphs left
493/// to right.
494///
495/// The one-shot form is a scan from the front of the list per glyph, and a
496/// document with two hundred search hits pays that two hundred times a row. A
497/// range that ends at or before an offset can never cover that offset *or any
498/// later one*, so the cursor retires those permanently and each glyph costs the
499/// ranges that actually reach it. The answer is identical to
500/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
501/// the part that was being redone dropped, not a cheaper approximation.
502///
503/// Offsets are expected to arrive non-decreasing. One that goes backwards is
504/// still answered correctly: the cursor re-seats to the front, since a painter
505/// that revisits a row is asking a question the retired ranges may own again.
506pub struct HighlightCursor<'a> {
507 highlights: &'a [Highlight],
508 /// The first range not yet retired.
509 at: usize,
510 /// The last offset asked about, to notice a caller going backwards.
511 last: usize,
512}
513
514impl<'a> HighlightCursor<'a> {
515 pub fn new(highlights: &'a [Highlight]) -> Self {
516 HighlightCursor {
517 highlights,
518 at: 0,
519 last: 0,
520 }
521 }
522
523 /// The range covering `offset`, advancing the cursor past every range that
524 /// can no longer cover anything.
525 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
526 if offset < self.last {
527 self.at = 0;
528 }
529 self.last = offset;
530 while self
531 .highlights
532 .get(self.at)
533 .is_some_and(|h| h.end <= offset)
534 {
535 self.at += 1;
536 }
537 Highlight::covering(&self.highlights[self.at..], offset)
538 }
539}
540
541/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
542/// purpose: the only useful question is whether two of them are the same map,
543/// and what is behind it — which `Doc` built it, and the (revision, wrap,
544/// reveal line) it was built from — is core's business.
545///
546/// The document is part of it because the rest is not unique to one: two
547/// documents opened at the same width are both at revision zero with no reveal
548/// line, and a frontend holding one copy of a map across the two would take
549/// the second's key for the first's and paint the wrong document.
550#[derive(Clone, PartialEq, Eq, Debug)]
551pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
552
553pub struct Doc {
554 editor: Editor,
555 pub format: Format,
556 pub path: PathBuf,
557 /// Current source, refreshed from the editor after every successful edit.
558 pub source: String,
559 /// The caret, as a byte offset into `source` (always on a char boundary).
560 pub caret: usize,
561 /// The selection's fixed end, if a selection is active; the moving end is
562 /// the caret. `None` means no selection.
563 pub anchor: Option<usize>,
564 pub dirty: bool,
565 pub status: Option<String>,
566 pub view: View,
567 /// Whether the document refuses to change — a *reading* surface over the
568 /// same rendering, selection, and navigation the editor has.
569 ///
570 /// Enforced here rather than by each frontend hiding its input paths,
571 /// because every mutation funnels through a few doors —
572 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
573 /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
574 /// verbs directly rather than through the splice (a typed literal, a link,
575 /// an image, a rule, a footnote, a cell's line break) — and guarded doors
576 /// are a guarantee where a frontend's suppressed keyboard is a hope. A
577 /// gated door reports exactly like a rolled-back splice, a path every
578 /// caller already handles. `a_read_only_document_refuses_every_door` is
579 /// the list; a new `self.editor.insert_*` call belongs on it.
580 read_only: bool,
581 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
582 /// State like the selection rather than like the text: no edit history,
583 /// no dirty bit, redrawn from whatever the host last set.
584 highlights: Vec<Highlight>,
585 /// How much of the source markup the rich view exposes — a frontend preference (see
586 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
587 /// [`reveal_line`](Self::reveal_line), the editing one by
588 /// [`insert`](Self::insert).
589 markup_mode: MarkupMode,
590 /// Whether soft breaks fold into the reflowed paragraph or render where
591 /// they were written (see [`LineFlow`]) — an independent frontend
592 /// preference the WYSIWYG builder consults when it lays out a block.
593 line_flow: LineFlow,
594 /// The kind of the last edit, for coalescing: twig owns the undo *history*
595 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
596 /// call, so leaf decides when a run continues and tells twig to coalesce.
597 last_edit_kind: Option<EditKind>,
598 /// The inline marks the user has toggled *at a collapsed caret* with no
599 /// selection — "start typing bold here". Held as the XOR delta from the marks
600 /// already in force at [`pending_at`](Self::pending_at): a set bit means
601 /// "flip this kind for the next typed text", so it both turns a mark on where
602 /// none is (type into bold) and off where one already covers the caret (type
603 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
604 /// freshly typed text and then clears it — a mark once realised is carried by
605 /// the caret sitting inside the run, not by this delta.
606 pending_marks: InlineMarks,
607 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
608 /// delta is live only while the caret still stands here with no selection;
609 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
610 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
611 pending_at: Option<usize>,
612 /// The source as of the last open/save — `dirty` is `source != clean_source`,
613 /// so undoing back to the saved state correctly clears the modified flag.
614 clean_source: String,
615 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
616 /// while the document has no file behind it. [`Doc::disk_state`] compares
617 /// the file against this to catch an edit made *outside* leaf before a save
618 /// silently overwrites it — `clean_source` only knows what leaf itself did.
619 ///
620 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
621 /// writes inside one filesystem timestamp tick are indistinguishable, a
622 /// clock that steps backwards (or a writer that restores an mtime) hides a
623 /// real change, and a `touch` invents one. The whole point of the watermark
624 /// is to not clobber someone's work, so it reads the bytes and compares what
625 /// is actually there. That costs a file read per question, which is why the
626 /// question is asked on a user event (focus, save) and not every frame.
627 disk_hash: Option<u64>,
628 /// The "sticky" display column vertical motion aims for, in the active
629 /// view's grid. Set on the first `move_up`/`move_down` of a run and
630 /// reused by every subsequent one in that run, so passing through a
631 /// shorter line doesn't permanently forget the original column. Any
632 /// horizontal motion or edit clears it.
633 ///
634 /// A column, not a character index: dropping down a line of `你好` onto one
635 /// of ASCII has to land under the glyph the caret was drawn beneath, which
636 /// is the only thing the user can see to aim by. Where the goal falls inside
637 /// a wide character on the target line, the mapping resolves it to that
638 /// character — the caret lands on it rather than between its cells.
639 goal_col: Option<usize>,
640 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
641 /// and clicks read it to stay in visible space.
642 pub vmap: VisualMap,
643 /// The syntax map for the source view; empty in the WYSIWYG view, which
644 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
645 /// frontend that never calls it paints raw source unstyled, which is what
646 /// every frontend did before this map existed.
647 pub smap: SourceMap,
648 /// The revision `smap` was built from, or `None` before the first build.
649 /// The map is a pure function of the text alone — no width, no caret, no
650 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
651 /// whole key.
652 smap_key: Option<u64>,
653 /// Everything the map is built from, as one number: bumped whenever the
654 /// document's text changes, and never by a motion, a selection, or a save.
655 /// A frontend can hold work against it — see [`Doc::revision`].
656 revision: u64,
657 /// How many history steps stand behind the caret, and how many ahead of
658 /// it — the answer to a native Edit menu's "may Undo be enabled?", which
659 /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
660 /// the funnel every edit comes through, and moved back and forth by
661 /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
662 /// an exact depth: a coalesced run of typing is one of twig's steps but
663 /// several of these, and twig's own cap on history is not mirrored here.
664 /// Neither error can make `can_undo` false while a step remains, which is
665 /// the only property a menu needs; the one place the bound can be wrong the
666 /// other way — the cap has retired every step — is reconciled the moment
667 /// twig reports nothing to undo.
668 undo_steps: usize,
669 redo_steps: usize,
670 /// What `vmap` was built from, or `None` before the first build. The map is
671 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
672 /// moved, rebuilding it produces the identical map — see
673 /// [`Doc::build_visual`].
674 ///
675 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
676 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
677 /// text and width alone, and a caret motion still rebuilds nothing.
678 vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
679 /// Which `Doc` this is, distinct from every other one built in this
680 /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
681 /// can never be mistaken for another document's — see
682 /// [`Doc::visual_key`]. Nothing else reads it.
683 identity: u64,
684 /// Per-block row cache backing the incremental rebuild: when the text
685 /// changes, only the top-level blocks whose bytes moved are re-rendered and
686 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
687 /// builds; a pure accelerator, so it's never read for correctness.
688 block_cache: wysiwyg::BlockCache,
689 /// What the frontend has said about itself — how tall its pictures came
690 /// out, keyed by destination or by TeX, and whether it paints a picture in
691 /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`] and
692 /// [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
693 /// so this is the only way it learns a height or a capability. Threaded
694 /// into every build; a change drops both caches, since none of it is in a
695 /// block's bytes.
696 surface: wysiwyg::Surface,
697
698 // View geometry the renderer stamps each frame, so mouse events can map a
699 // screen cell back to a byte offset.
700 pub scroll: usize,
701 pub body_origin: (u16, u16),
702 /// Width of the body rectangle last painted by the frontend. Zero means
703 /// unknown (used by tests or a frontend that has not drawn yet).
704 pub body_width: u16,
705 pub body_height: u16,
706 /// The caret as of the last frame drawn, or `None` before the first.
707 ///
708 /// Scrolling is the viewport's business, not the caret's: the view follows
709 /// the caret when the caret *moves*, but a wheel that doesn't touch the
710 /// caret has to be free to scroll away from it — otherwise the view is
711 /// pinned to the caret and stops dead at the edge of the document you can
712 /// see. Comparing against this is what tells the two apart, and it catches a
713 /// caret set by any route, including a frontend assigning the field itself.
714 pub drawn_caret: Option<usize>,
715}
716
717/// The Markdown extensions every leaf document is parsed with — five of them,
718/// each departing from twig's defaults for a reason leaf can state.
719///
720/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
721/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
722/// node the frontends can frame and rasterize instead of opaque `raw_block`
723/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
724/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
725/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
726/// plain tinted container, agnostic of `name`.
727///
728/// `highlight` and `highlight_colors` are the pair that makes Markdown read
729/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
730/// `data-color`. leaf already had somewhere to put both: the
731/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
732/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
733/// from a Djot one it was converted from — the button wrote `==…==` and the
734/// reparse read it straight back as text.
735/// They are on together because a colour is inert without the highlight itself,
736/// and a document that writes `==🔴 x==` means the colour by it.
737///
738/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
739/// a `display_math` one — what djot reads natively and what leaf has
740/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
741/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
742/// twig has already read as an escaped comma, and an author who types a
743/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
744/// twig's own rule that a dollar followed by whitespace never opens math, so
745/// `$5 and $6` stays prose.
746///
747/// Every flag is inert for non-Markdown formats, so it's safe to pass them
748/// unconditionally. Threading this through every constructor (not just `open`)
749/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
750/// way — twig reparses with these same flags after each edit.
751pub(crate) fn parse_extensions() -> MarkdownExtensions {
752 MarkdownExtensions {
753 html_elements: true,
754 directives: true,
755 highlight: true,
756 highlight_colors: true,
757 math: true,
758 }
759}
760
761/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
762/// mapping twig's error into the `anyhow` context every constructor shares.
763fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
764 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
765}
766
767/// Does `format` spell a table as a **pipe table** — the one grid twig's table
768/// editor knows how to emit?
769///
770/// This is the single capability leaf still has to answer for itself, and the
771/// only hand-maintained format list left in this file. Every other gesture is
772/// [`Format::supports`], which is twig's own answer read across the C ABI — but
773/// twig deliberately leaves the table ops out of that query, because they read
774/// no `Syntax` table at all. They rewrite a grid that is already in the source
775/// and refuse on *position*, never on format. Handed a caret inside an HTML
776/// `<table>`, `table_insert_row` therefore re-emits the whole element as
777/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
778/// `dirty` flag, and nothing downstream able to tell it from a good edit.
779///
780/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
781/// wildcard answers "no" for a format leaf has never heard of: a new twig
782/// language that *does* spell pipe tables loses its grid controls until this
783/// line is updated, which shows up as a missing button. The other default hands
784/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
785fn spells_pipe_tables(format: Format) -> bool {
786 matches!(format, Format::Markdown | Format::Djot)
787}
788
789/// Which of leaf's authoring controls this document's format can actually
790/// spell — one flag per toolbar button, resolved once so a frontend can build
791/// its chrome instead of discovering each refusal on a click.
792///
793/// Every field but [`table`](Self::table) is `Format::supports_with` on the
794/// gesture the matching [`Doc`] method calls, so this record cannot drift from
795/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
796/// doesn't export.
797///
798/// `supports_with` rather than `supports` because two of these are facts about
799/// the *parse options* as much as about the format. `Format::supports` answers
800/// for twig's defaults, and leaf never parses with those — it parses with
801/// [`parse_extensions`], and a document's toolbar has to describe the document
802/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
803/// without it would mint bytes its own reparse hands back as plain text, which
804/// is why twig asks before it writes.
805///
806/// **The formats are ragged, and that is the point.** A single per-document
807/// boolean was enough while the two authorable formats were Markdown and djot
808/// and everything else spelled nothing. HTML is neither: it writes seven of the
809/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
810/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
811/// pair, and since 3.5 a quote, a list, a code block, a link and an image
812/// printed as fresh nodes; it spells no task box (a form control there) and
813/// no footnote, and its `<table>` is one twig reads but will not write. So
814/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
815/// table buttons do not, and no one flag can say that. Markdown and djot
816/// differ from each other too:
817/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
819pub struct Capabilities {
820 /// ⌘B — `InlineKind::Strong`.
821 pub bold: bool,
822 /// ⌘I — `InlineKind::Emph`.
823 pub italic: bool,
824 /// Inline code — `InlineKind::Verbatim`.
825 pub code: bool,
826 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
827 /// under the `highlight` extension [`parse_extensions`] turns on: the
828 /// button writes `==text==`, which is what the reparse reads back.
829 pub mark: bool,
830 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
831 pub underline: bool,
832 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
833 /// out of the box, since twig parses it out of the box.
834 pub strike: bool,
835 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
836 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
837 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
838 /// so a toolbar offering the swatches wherever the button lights would offer
839 /// them in a document that cannot write one. Pair with
840 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
841 /// a highlight to colour as much as a format that spells one.
842 pub mark_color: bool,
843 pub superscript: bool,
844 pub subscript: bool,
845 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
846 pub heading: bool,
847 pub blockquote: bool,
848 pub bullet_list: bool,
849 pub ordered_list: bool,
850 /// The checkbox controls: giving an item a box, and ticking one.
851 pub task: bool,
852 pub link: bool,
853 /// Covers [`Doc::insert_media`] too — see the note there on why the three
854 /// media kinds stand or fall together.
855 pub image: bool,
856 /// The horizontal-rule button. HTML spells this one (`<hr>`).
857 pub thematic_break: bool,
858 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
859 /// the pair; HTML has no footnote of its own, so the button goes away rather
860 /// than writing brackets that would render as brackets.
861 pub footnote: bool,
862 /// The code-block button — [`Doc::toggle_code_block`], twig's
863 /// `Gesture::ToggleCodeBlock`. Markdown and djot spell the fence; HTML
864 /// rebuilds the block as `<pre><code>`.
865 pub code_block: bool,
866 /// Setting a fenced block's language — a control only ever offered with the
867 /// caret already in a fence.
868 pub code_language: bool,
869 /// The grid controls: insert/delete/move a row or column, set a column's
870 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
871 /// question — an HTML `<table>` holds the caret and still can't be edited.
872 pub table: bool,
873 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
874 /// idiomatic in-cell break.
875 pub cell_line_break: bool,
876 /// The alignment control — [`Doc::set_alignment`], twig's
877 /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
878 /// block's attributes, Markdown under the `html_elements`
879 /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
880 /// through its `[…]` line.
881 pub alignment: bool,
882 /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
883 /// [`alignment`](Self::alignment) and so the same answer, and its own flag
884 /// because a toolbar dims controls one at a time and the pair may yet
885 /// diverge.
886 pub line_spacing: bool,
887 /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
888 /// over a selection. **Narrower than the block pair**: AsciiDoc's
889 /// `[#id.role]#text#` keeps an id and a role and has no slot for a
890 /// `data-` key, so twig refuses the span there and this is `false` while
891 /// [`alignment`](Self::alignment) is `true`. The block-level form of the
892 /// same property — the caret in a paragraph, no selection — goes through
893 /// `SetBlockAttrs` and still works, which is why the flag describes the
894 /// control rather than the caret.
895 pub font_size: bool,
896 /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
897 /// [`font_size`](Self::font_size) is.
898 pub font_family: bool,
899 /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
900 /// and not to be confused with [`mark_color`](Self::mark_color): that is a
901 /// highlight's background and rides the `mark` node twig already owns,
902 /// this is a run's foreground and rides an attributed span.
903 pub text_color: bool,
904 /// The page-break button — [`Doc::insert_page_break`], twig's
905 /// `Gesture::InsertDirective`. Markdown under the `directives` extension
906 /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
907 /// it as an empty `::: page-break` fence.
908 ///
909 /// **Those two and no others**, though twig spells the gesture in HTML and
910 /// AsciiDoc as well — see [`Capabilities::of`].
911 pub page_break: bool,
912 /// Moving a block — [`Doc::move_block`] and the Alt+↑/↓ pair, twig's
913 /// `Gesture::MoveBlock`. Every format with blocks a caret can name; XML
914 /// has none.
915 pub move_block: bool,
916}
917
918impl Capabilities {
919 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
920 /// twig computes each from a static table — but a frontend that wants to
921 /// hold them can.
922 ///
923 /// The extensions are not a parameter because they are not a choice a
924 /// caller makes: every leaf document is parsed with [`parse_extensions`],
925 /// so the format is the whole of what varies.
926 pub fn of(format: Format) -> Self {
927 let exts = parse_extensions();
928 let supports = |g| format.supports_with(exts, g);
929 let inline = |k| supports(Gesture::ToggleInline(k));
930 let container = |k| supports(Gesture::ToggleBlockContainer(k));
931 Self {
932 bold: inline(InlineKind::Strong),
933 italic: inline(InlineKind::Emph),
934 code: inline(InlineKind::Verbatim),
935 mark: inline(InlineKind::Mark),
936 underline: inline(InlineKind::Insert),
937 strike: inline(InlineKind::Delete),
938 mark_color: supports(Gesture::SetMarkColor),
939 superscript: inline(InlineKind::Superscript),
940 subscript: inline(InlineKind::Subscript),
941 heading: supports(Gesture::SetBlock),
942 blockquote: container(BlockContainerKind::BlockQuote),
943 bullet_list: container(BlockContainerKind::BulletList),
944 ordered_list: container(BlockContainerKind::OrderedList),
945 // Both halves of the checkbox story, and leaf offers no control that
946 // needs only one: the item gesture mints the box, the checked one
947 // ticks it, and a format spelling a `task_marker` spells both.
948 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
949 link: supports(Gesture::InsertLink),
950 image: supports(Gesture::InsertImage),
951 thematic_break: supports(Gesture::InsertThematicBreak),
952 footnote: supports(Gesture::InsertFootnote),
953 code_block: supports(Gesture::ToggleCodeBlock),
954 code_language: supports(Gesture::SetCodeLanguage),
955 table: spells_pipe_tables(format),
956 cell_line_break: supports(Gesture::InsertLineBreak),
957 // The presentation vocabulary, one gesture per level: the two
958 // line-level properties are a block's attributes and the three
959 // run-level ones a span's. They are asked separately because the
960 // formats answer differently — AsciiDoc spells the block and not
961 // the span — and a toolbar that dimmed all five together would dim
962 // three controls that work.
963 alignment: supports(Gesture::SetBlockAttrs),
964 line_spacing: supports(Gesture::SetBlockAttrs),
965 font_size: supports(Gesture::WrapRangeAttrs),
966 font_family: supports(Gesture::WrapRangeAttrs),
967 text_color: supports(Gesture::WrapRangeAttrs),
968 // Narrower than the gesture, on purpose. Twig spells
969 // `InsertDirective` in HTML and AsciiDoc too, and spells it
970 // *differently* there — `<page-break></page-break>` and `<<<` —
971 // and the walker reads only the two spellings above. An HTML page
972 // break draws as nothing at all (no row, no caret home) and an
973 // AsciiDoc one as an empty unlabelled row, so the button would
974 // write a break the author cannot see and cannot get back to.
975 // The proposal claims Markdown and djot, and this is that claim.
976 // Widening it is the walker's work, not this line's — see
977 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
978 page_break: supports(Gesture::InsertDirective)
979 && matches!(format, Format::Markdown | Format::Djot),
980 move_block: supports(Gesture::MoveBlock),
981 }
982 }
983}
984
985/// The source of [`Doc::identity`], one per document ever built.
986static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
987
988impl Doc {
989 #[cfg(feature = "fs")]
990 pub fn open(path: PathBuf) -> Result<Self> {
991 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
992 Self::from_disk_bytes(path, bytes)
993 }
994
995 /// An empty document *named* `path`, for a file that isn't there yet — what
996 /// every other terminal editor gives you when you name a file that doesn't
997 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
998 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
999 /// the header shows the name the user asked for.
1000 ///
1001 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
1002 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
1003 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
1004 /// parse is still an error: a mistyped flag or a stray argument should say
1005 /// so, not open a buffer promising to save somewhere.
1006 ///
1007 /// The watermark is the hash of *no bytes*, not `None`, and that is the
1008 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
1009 /// answering [`DiskState::Untitled`] for a document that has a path and
1010 /// intends to write to it. Hashing `""` instead makes the answers the true
1011 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
1012 /// recreates it, which is exactly what this is for), and
1013 /// [`DiskState::Changed`] if somebody creates it underneath us between
1014 /// launch and save, so the frontend's overwrite prompt guards a new file as
1015 /// it guards an opened one.
1016 ///
1017 /// Nothing is written here. A buffer that is never typed into never touches
1018 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
1019 /// open — the write is where that fails, and it says so then.
1020 #[cfg(feature = "fs")]
1021 pub fn create(path: PathBuf) -> Result<Self> {
1022 Self::from_disk_bytes(path, Vec::new())
1023 }
1024
1025 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1026 /// the call a CLI frontend wants for its path argument.
1027 ///
1028 /// The decision is made from the failed read itself rather than a `exists()`
1029 /// check first, so there is no window between the two for the file to appear
1030 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1031 /// a directory in the way is still an error, because pretending those are
1032 /// "no file yet" would offer to save over something leaf couldn't read.
1033 #[cfg(feature = "fs")]
1034 pub fn open_or_create(path: PathBuf) -> Result<Self> {
1035 match std::fs::read(&path) {
1036 Ok(bytes) => Self::from_disk_bytes(path, bytes),
1037 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1038 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1039 }
1040 }
1041
1042 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1043 /// stand in for) the file at `path`, parsed as the format its extension
1044 /// names. Keeping the two on one path is what makes a new file's document
1045 /// identical in every respect to an opened one but its contents.
1046 #[cfg(feature = "fs")]
1047 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1048 let format = detect_format(&path)?;
1049 let editor = new_editor(&bytes, format)?;
1050 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1051 let disk_hash = Some(hash_bytes(source.as_bytes()));
1052 // Store the document's *absolute* path. A relative one (`leaf README.md`)
1053 // has an empty parent, so a frontend can't resolve a relative image
1054 // destination (``) against the document's directory and the
1055 // picture silently falls back to its text placeholder. `absolute` is
1056 // purely lexical — it prefixes the current directory and normalizes, but
1057 // reads nothing and resolves no symlinks — so `file_name` and save are
1058 // unchanged; it only gives `path.parent()` something to join against.
1059 let path = std::path::absolute(&path).unwrap_or(path);
1060 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1061 }
1062
1063 /// Build a document from an in-memory string, the format named explicitly —
1064 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1065 /// path and sniffs the format from its extension). A wasm or FFI host, which
1066 /// has no path to read, uses this: it hands over bytes it fetched however it
1067 /// could, and later persists [`Doc::source`] however it can (a browser
1068 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1069 ///
1070 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1071 /// true) exactly like a [`Doc::blank`] that has been given content.
1072 pub fn from_source(source: String, format: Format) -> Result<Self> {
1073 let editor = new_editor(source.as_bytes(), format)?;
1074 Ok(Doc::from_parts(
1075 editor,
1076 format,
1077 PathBuf::new(),
1078 source,
1079 None,
1080 ))
1081 }
1082
1083 /// An untitled, empty document — the `+` button and a `leaf` launched with
1084 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1085 ///
1086 /// It is Markdown, because a format has to be chosen before a name exists to
1087 /// read one from: `detect_format` reads the extension and an untitled
1088 /// document has neither. Markdown is what leaf's own files are, what its
1089 /// block markers are already written for (`insert_block_prefix`), and the
1090 /// extension a Save As will overwhelmingly pick — a wrong guess here would
1091 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1092 /// *doesn't* revisit this: see [`Doc::save_as`].
1093 pub fn blank() -> Result<Self> {
1094 let format = Format::Markdown;
1095 let editor = new_editor(b"", format)?;
1096 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1097 // field two frontends already read; making it an `Option` to say this
1098 // would break both). `is_untitled` is the question to ask, not the
1099 // representation to copy.
1100 Ok(Doc::from_parts(
1101 editor,
1102 format,
1103 PathBuf::new(),
1104 String::new(),
1105 None,
1106 ))
1107 }
1108
1109 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1110 /// apart in the ones neither of them has an opinion about.
1111 // `identity` is taken from a counter rather than from the `Doc`'s address,
1112 // which moves — a session that holds one is moved into and out of
1113 // containers freely, and an identity that changed with it would defeat the
1114 // one comparison it exists for.
1115 fn from_parts(
1116 editor: Editor,
1117 format: Format,
1118 path: PathBuf,
1119 source: String,
1120 disk_hash: Option<u64>,
1121 ) -> Self {
1122 Doc {
1123 editor,
1124 format,
1125 path,
1126 disk_hash,
1127 clean_source: source.clone(),
1128 source,
1129 caret: 0,
1130 anchor: None,
1131 dirty: false,
1132 status: None,
1133 read_only: false,
1134 highlights: Vec::new(),
1135 // leaf opens in the rich-text (WYSIWYG) view by default — the
1136 // markup-resolved surface is leaf's differentiator. Frontends can
1137 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1138 // toggles at runtime.
1139 view: View::Wysiwyg,
1140 // `None` by default — the clean surface Diaryx ships, with typed
1141 // syntax kept literal; a markup-fluent frontend can climb the
1142 // ladder to `Shortcuts` or `Full`.
1143 markup_mode: MarkupMode::default(),
1144 // Fold by default — flowing prose that reflows to the viewport, the
1145 // behaviour every frontend had before this preference existed.
1146 line_flow: LineFlow::default(),
1147 last_edit_kind: None,
1148 pending_marks: InlineMarks::empty(),
1149 pending_at: None,
1150 goal_col: None,
1151 vmap: VisualMap::default(),
1152 smap: SourceMap::default(),
1153 // No map yet — the first `build_source` always builds.
1154 smap_key: None,
1155 revision: 0,
1156 undo_steps: 0,
1157 redo_steps: 0,
1158 // No map yet — the first `build_visual` always builds.
1159 vmap_key: None,
1160 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1161 block_cache: wysiwyg::BlockCache::default(),
1162 surface: wysiwyg::Surface::default(),
1163 scroll: 0,
1164 body_origin: (0, 0),
1165 body_width: 0,
1166 body_height: 0,
1167 drawn_caret: None,
1168 }
1169 }
1170
1171 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1172 /// has never been saved. The question a ⌘S handler asks to know it should
1173 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1174 /// header asks to know the name it shows is a placeholder.
1175 pub fn is_untitled(&self) -> bool {
1176 self.path.as_os_str().is_empty()
1177 }
1178
1179 pub fn toggle_view(&mut self) {
1180 self.view = match self.view {
1181 View::Source => View::Wysiwyg,
1182 View::Wysiwyg => View::Source,
1183 };
1184 self.scroll = 0;
1185 self.status = None;
1186 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1187 // lift it to the first rendered offset.
1188 self.clamp_caret();
1189 }
1190
1191 /// The current markup-exposure preference (see [`MarkupMode`]).
1192 pub fn markup_mode(&self) -> MarkupMode {
1193 self.markup_mode
1194 }
1195
1196 /// Set the markup-exposure preference. Both of its axes take effect at
1197 /// once: the editing one on the next [`insert`](Self::insert), and the
1198 /// rendering one on the next build — which is why this drops the cached
1199 /// visual map and the per-block render cache, exactly as
1200 /// [`set_line_flow`](Self::set_line_flow) does.
1201 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1202 if self.markup_mode == mode {
1203 return;
1204 }
1205 self.markup_mode = mode;
1206 // Neither cache is keyed on the mode, and moving between `Full` and the
1207 // hidden modes changes every row the caret's line renders to — so
1208 // invalidate both explicitly.
1209 self.vmap_key = None;
1210 self.block_cache = wysiwyg::BlockCache::default();
1211 }
1212
1213 /// The line the caret sits on, when that line should render something
1214 /// raw — `None` when nothing on it would, which is what the builder reads
1215 /// as "reveal nothing" and what keeps caret motion from costing a build.
1216 ///
1217 /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1218 /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1219 /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1220 /// formula's content is not its picture and hiding the `$` alone would
1221 /// leave nothing to edit; there the line is threaded through only when it
1222 /// meets a block that holds one, which the last build's layout knows
1223 /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1224 /// keeps the `None` it always had, and one with math pays a rebuild only
1225 /// while the caret is in the formula's block.
1226 ///
1227 /// A *source* line (newline to newline), not a visual row: a wrapped
1228 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1229 /// line across several rows, and revealing half a delimiter pair because the
1230 /// other half wrapped would be worse than revealing neither. The range
1231 /// excludes the terminating newline and is empty-but-present on a blank
1232 /// line, which reveals nothing but still keys the caches correctly.
1233 ///
1234 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1235 /// there is nothing there to reveal.
1236 pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1237 if self.view != View::Wysiwyg {
1238 return None;
1239 }
1240 let line = source_line_range(&self.source, self.caret);
1241 if self.markup_mode.reveals_caret_line() {
1242 return Some(Reveal::full(line));
1243 }
1244 self.block_cache
1245 .math_meets(&line)
1246 .then_some(Reveal::math(line))
1247 }
1248
1249 /// The current soft-break flow preference (see [`LineFlow`]).
1250 pub fn line_flow(&self) -> LineFlow {
1251 self.line_flow
1252 }
1253
1254 /// Set the soft-break flow preference. The mode changes how every block lays
1255 /// out, so a change drops the cached visual map and the per-block render
1256 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1257 ///
1258 /// [`build_visual`]: Self::build_visual
1259 pub fn set_line_flow(&mut self, mode: LineFlow) {
1260 if self.line_flow == mode {
1261 return;
1262 }
1263 self.line_flow = mode;
1264 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1265 // so invalidate them explicitly, or the next build would reuse rows laid
1266 // out under the old flow.
1267 self.vmap_key = None;
1268 self.block_cache = wysiwyg::BlockCache::default();
1269 }
1270
1271 pub fn view_name(&self) -> &'static str {
1272 match self.view {
1273 View::Source => "source",
1274 View::Wysiwyg => "wysiwyg",
1275 }
1276 }
1277
1278 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1279 /// (called by the renderer each frame it's in the WYSIWYG view).
1280 /// Build the WYSIWYG map, wrapped at `width` display columns.
1281 ///
1282 /// Cheap to call every frame, which is what both frontends do: the map is a
1283 /// pure function of the document and the wrap width, so a call that would
1284 /// rebuild the same map returns the one already built. Only an edit (or a
1285 /// resize) pays.
1286 ///
1287 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1288 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1289 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1290 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1291 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1292 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1293 pub fn build_visual(&mut self, width: usize) {
1294 self.build_map(Some(width));
1295 }
1296
1297 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1298 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1299 /// than a fixed character column.
1300 pub fn build_visual_unwrapped(&mut self) {
1301 self.build_map(None);
1302 }
1303
1304 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1305 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1306 /// styling for [`View::Wysiwyg`].
1307 ///
1308 /// A frontend calls this before painting raw source. One that doesn't gets
1309 /// an empty map and paints unstyled text, so this is additive: nothing
1310 /// breaks by not calling it.
1311 ///
1312 /// Built at most once per revision, and the revision is the whole key — the
1313 /// map has no width and no caret in it, so it survives every resize, every
1314 /// motion, and every selection change.
1315 ///
1316 /// The builds it does do cost a whole-arena marshal, which is precisely what
1317 /// the WYSIWYG path works to avoid, so this has no incremental path where
1318 /// that one has two. From `cargo run --release -p leaf-core --example
1319 /// bench`, per keystroke, against the WYSIWYG build the source view is
1320 /// *not* doing:
1321 ///
1322 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1323 /// |------:|-------:|--------:|----------------:|-------------------:|
1324 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1325 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1326 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1327 ///
1328 /// Linear, two thirds of it the marshal, and the build itself five to seven
1329 /// times cheaper than the one it stands in for at every size. Comfortable
1330 /// well past any document a person edits in a terminal — a megabyte is where
1331 /// it would want [`Editor::dirty_range`] and the same splice treatment
1332 /// `build_spliced` gives the other map. The door is open; nothing has needed
1333 /// it yet.
1334 pub fn build_source(&mut self) {
1335 if self.smap_key == Some(self.revision) {
1336 return;
1337 }
1338 let nodes = self.nodes();
1339 self.smap = source::build(&nodes, &self.source);
1340 self.smap_key = Some(self.revision);
1341 }
1342
1343 /// Tell the model how many visual rows each block image should reserve, keyed
1344 /// by the image's destination. A terminal frontend calls this once it has
1345 /// decoded and measured its pictures — core does no image I/O, so this is the
1346 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1347 /// placeholder out that tall (the label row plus blank filler rows the
1348 /// frontend paints the raster over). A destination left out of the map falls
1349 /// back to the bare one-row placeholder, which is also what a frontend that
1350 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1351 /// by never calling this.
1352 ///
1353 /// Cheap to call every frame with the same map: only a *change* invalidates
1354 /// the built map (and the block-row cache, since a height isn't part of a
1355 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1356 /// no-op, so a frontend can just hand over its current measurements each frame.
1357 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1358 if self.surface.media_rows == rows {
1359 return;
1360 }
1361 self.surface.media_rows = rows;
1362 self.surface_changed();
1363 }
1364
1365 /// Tell the model how many visual rows each display formula should
1366 /// reserve, keyed by the formula's TeX exactly as the map's
1367 /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1368 /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1369 /// typesets the picture, measures it in cells, and reports back; a
1370 /// frontend that lays formulas out in pixels never calls this and gets
1371 /// the one-row placeholder to paint over.
1372 pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1373 if self.surface.math_rows == rows {
1374 return;
1375 }
1376 self.surface.math_rows = rows;
1377 self.surface_changed();
1378 }
1379
1380 /// Tell the model whether the frontend can paint a picture *inside* a line
1381 /// of text. When it can, an inline formula renders to one atom glyph the
1382 /// frontend draws its typeset picture over — see
1383 /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1384 /// the code-styled TeX it always showed. Off until a frontend says
1385 /// otherwise, so a host that has not caught up sees what it saw.
1386 pub fn set_inline_pictures(&mut self, on: bool) {
1387 if self.surface.inline_pictures == on {
1388 return;
1389 }
1390 self.surface.inline_pictures = on;
1391 self.surface_changed();
1392 }
1393
1394 /// A height or a capability lives outside a block's source bytes, so the
1395 /// content-keyed block cache would hand back the old rows on a hit. Drop
1396 /// it (and the splice layout it carries) so the next build re-renders
1397 /// every block against the new surface, and force that build by clearing
1398 /// the map key.
1399 fn surface_changed(&mut self) {
1400 self.block_cache = wysiwyg::BlockCache::default();
1401 self.vmap_key = None;
1402 }
1403
1404 /// The revision the document's text is at — bumped by every edit, undo,
1405 /// redo, and reload, and by nothing else. A frontend caches against this to
1406 /// tell a repaint that needs new work from one that doesn't.
1407 ///
1408 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1409 /// lands on the same text two revisions later. Work is only ever rebuilt
1410 /// needlessly, never wrongly reused.
1411 pub fn revision(&self) -> u64 {
1412 self.revision
1413 }
1414
1415 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1416 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1417 /// unbuilt map before the first one.
1418 ///
1419 /// This is *not* [`revision`](Self::revision). The revision says where the
1420 /// text is; this says where the map is, and the two part company the moment
1421 /// an edit lands, until something rebuilds. A frontend that keeps its own
1422 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1423 /// under an oversized heading — compares this against the value it held when
1424 /// it took the copy, and learns whether `vmap` is still the map it stashed
1425 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1426 /// map would paint a stale document; restoring nothing hands core's
1427 /// incremental rebuild a map it never built.
1428 ///
1429 /// "Somebody else" includes another document. The key names the `Doc`
1430 /// as well as the build, so a frontend that draws two documents through
1431 /// one stash — a host with several buffers, or one that opens the next
1432 /// document where the last one stood — never has the copy it took of one
1433 /// accepted by the other, however alike their builds are.
1434 pub fn visual_key(&self) -> VisualKey {
1435 VisualKey(self.identity, self.vmap_key.clone())
1436 }
1437
1438 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1439 /// runs on every call: the caret moves without the document changing, and
1440 /// keeping it on a legal stop is this function's job either way.
1441 fn build_map(&mut self, wrap: Option<usize>) {
1442 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1443 // as well as the text, so the line joins the key: moving within a line
1444 // still reuses the map, and crossing into another one rebuilds it. In
1445 // every other mode `reveal_line` is `None` and the key is what it was,
1446 // so caret motion goes on costing nothing.
1447 let reveal = self.reveal_line();
1448 let key = (self.revision, wrap, reveal.clone());
1449 if self.vmap_key.as_ref() != Some(&key) {
1450 self.build_map_with(wrap, reveal);
1451 self.vmap_key = Some(key);
1452 // In a hidden mode the reveal line was decided from the *previous*
1453 // build's layout, whose spans are stale across an edit: the
1454 // keystroke that closes a new `$…$` on the caret's line asked "is
1455 // there math here?" of a layout that had none, and the formula
1456 // would snap to its picture under the caret until the next
1457 // motion. Ask again of the layout just built, and go once more if
1458 // the answer moved. Between edits the first answer is exact and
1459 // this is one comparison.
1460 let again = self.reveal_line();
1461 if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1462 self.build_map_with(wrap, again.clone());
1463 self.vmap_key = Some((self.revision, wrap, again));
1464 }
1465 }
1466 self.clamp_caret();
1467 }
1468
1469 /// One build of the map at `wrap` under `reveal`, incremental where it can
1470 /// be — the body of [`build_map`](Self::build_map), which decides whether
1471 /// to call it.
1472 fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1473 {
1474 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1475 // A subtree is pulled only for the block(s) that actually changed, so
1476 // the FFI marshal shrinks from O(document) to O(edited block).
1477 let top = self.top_blocks();
1478
1479 // Fast path: when twig reports a dirty byte range, try to patch the
1480 // previous map in place — a single-block edit moves the prefix,
1481 // shifts the suffix, and re-renders only one block. `build_spliced`
1482 // returns `None` (and we fall back to the always-correct full rebuild)
1483 // whenever the edit reshaped the block structure, hit a table, or
1484 // there's no previous map to patch.
1485 // Preserve soft breaks as written when the flow preference asks for
1486 // it — the builder renders each as its own visual row instead of
1487 // folding it into the reflowed paragraph.
1488 let preserve_soft = self.line_flow == LineFlow::Preserve;
1489 let spliced = match self.editor.dirty_range() {
1490 Some(dirty) => {
1491 let prev = std::mem::take(&mut self.vmap);
1492 let source = &self.source;
1493 let cache = &mut self.block_cache;
1494 let surface = &self.surface;
1495 let editor = &mut self.editor;
1496 wysiwyg::build_spliced(
1497 prev,
1498 source,
1499 wrap,
1500 preserve_soft,
1501 &top,
1502 dirty,
1503 surface,
1504 reveal.clone(),
1505 cache,
1506 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1507 )
1508 }
1509 None => None,
1510 };
1511 self.vmap = spliced.unwrap_or_else(|| {
1512 let source = &self.source;
1513 let cache = &mut self.block_cache;
1514 let surface = &self.surface;
1515 let editor = &mut self.editor;
1516 wysiwyg::build_cached(
1517 &top,
1518 source,
1519 wrap,
1520 preserve_soft,
1521 surface,
1522 reveal,
1523 cache,
1524 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1525 )
1526 });
1527 // Acknowledge the dirty range so the next edit's range starts fresh.
1528 self.editor.clear_dirty();
1529 }
1530 }
1531
1532 fn nodes(&mut self) -> Vec<FlatNode> {
1533 self.editor.nodes().unwrap_or_default()
1534 }
1535
1536 /// The document's top-level blocks for the incremental render. See
1537 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1538 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1539 wysiwyg::top_blocks(&mut self.editor)
1540 }
1541
1542 pub fn format_name(&self) -> &'static str {
1543 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1544 // required. It also covers `Asciidoc`, which twig parses but cannot
1545 // serialize — leaf never opens a document in it (see `Doc::open`).
1546 match self.format {
1547 Format::Djot => "djot",
1548 Format::Markdown => "markdown",
1549 Format::Xml => "xml",
1550 Format::Html => "html",
1551 _ => "unknown",
1552 }
1553 }
1554
1555 /// Whether this document's format offers *any* door in — `false` only for a
1556 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1557 /// a frontend may as well open the file read-only.
1558 ///
1559 /// This is a much weaker claim than the name suggests, and driving per-button
1560 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1561 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1562 /// while a heading, a quote, a list, a task box, a link and a code fence all
1563 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1564 /// [`supports`](Self::supports) — per control.
1565 pub fn authorable(&self) -> bool {
1566 self.format.is_authorable()
1567 }
1568
1569 /// Whether this document can spell `gesture`, which is twig's own answer
1570 /// rather than a copy of it: `Format::supports_with` reads the same
1571 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1572 /// the very [`parse_extensions`] this document's editor reparses with — so
1573 /// what the toolbar offers and what the splice will accept are one table.
1574 ///
1575 /// It is a fact about the *document*, not about the caret. `true` does not
1576 /// promise the gesture succeeds where it is standing — a link over a table
1577 /// border still fails — only that it will not fail with
1578 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1579 /// will work here".
1580 pub fn supports(&self, gesture: Gesture) -> bool {
1581 self.format.supports_with(parse_extensions(), gesture)
1582 }
1583
1584 /// Every control's enabled state in one read — what a toolbar builds itself
1585 /// from when a document opens or its format changes. See [`Capabilities`].
1586 pub fn capabilities(&self) -> Capabilities {
1587 Capabilities::of(self.format)
1588 }
1589
1590 /// Refuse a gesture this document's format cannot spell, saying so in the
1591 /// status line. `true` means the caller must return without calling twig.
1592 ///
1593 /// Most of these refusals duplicate one twig would make anyway, and they are
1594 /// made here regardless because a message naming the *document's* format
1595 /// reads better than one naming twig's internals. Two of them are not
1596 /// duplicates and are the reason this is a guard rather than an error
1597 /// translation:
1598 ///
1599 /// - The table family (see [`table_op`](Self::table_op)) consults no
1600 /// `Syntax` table, so twig does not refuse it at all.
1601 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1602 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1603 /// could not keep.
1604 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1605 self.refuse_unless(what, self.supports(gesture))
1606 }
1607
1608 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1609 /// answers itself — today only [`spells_pipe_tables`].
1610 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1611 if supported {
1612 return false;
1613 }
1614 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1615 true
1616 }
1617
1618 /// The name to show for this document. An untitled one has no file to name
1619 /// it, and both frontends put this straight on screen — an empty path
1620 /// renders as an empty header, so it says so instead.
1621 pub fn file_name(&self) -> String {
1622 if self.is_untitled() {
1623 return "untitled".into();
1624 }
1625 self.path
1626 .file_name()
1627 .map(|s| s.to_string_lossy().into_owned())
1628 .unwrap_or_else(|| self.path.display().to_string())
1629 }
1630
1631 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1632 /// caret and anchor coincide (an empty selection is no selection).
1633 pub fn selection(&self) -> Option<(usize, usize)> {
1634 self.anchor
1635 .map(|a| (a.min(self.caret), a.max(self.caret)))
1636 .filter(|(s, e)| s != e)
1637 }
1638
1639 /// The selected text, or `None` when there's no selection — the source
1640 /// slice a copy/cut hands to the system clipboard.
1641 pub fn selected_text(&self) -> Option<&str> {
1642 self.selection().map(|(s, e)| &self.source[s..e])
1643 }
1644
1645 /// The selection as a quote with a little of what surrounds it — the shape
1646 /// a host that cites, annotates, or searches for a passage wants, cut from
1647 /// the **source** rather than from anything rendered, so the quote is
1648 /// findable in the document again by plain string search.
1649 ///
1650 /// `context` is a count of characters (not bytes) on each side, clipped at
1651 /// the document's edges; the slices land on char boundaries by
1652 /// construction. `None` when nothing is selected.
1653 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1654 let (start, end) = self.selection()?;
1655 let mut before = start;
1656 for _ in 0..context {
1657 match self.source[..before].chars().next_back() {
1658 Some(c) => before -= c.len_utf8(),
1659 None => break,
1660 }
1661 }
1662 let mut after = end;
1663 for _ in 0..context {
1664 match self.source[after..].chars().next() {
1665 Some(c) => after += c.len_utf8(),
1666 None => break,
1667 }
1668 }
1669 Some(Quote {
1670 exact: self.source[start..end].to_string(),
1671 prefix: self.source[before..start].to_string(),
1672 suffix: self.source[end..after].to_string(),
1673 start,
1674 end,
1675 })
1676 }
1677
1678 /// Words, characters, and paragraphs over the whole document — the numbers
1679 /// a status bar or an inspector puts next to a piece of writing.
1680 ///
1681 /// Counted over the text a **reader** sees, not the markup that spells it:
1682 /// `**bold**` is one word and four characters, a link is its label and not
1683 /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1684 /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1685 /// not render at all — is not writing. [`crate::counts`] states the rules
1686 /// in full; [`TextCounts`] states them per field.
1687 ///
1688 /// The same numbers in both views. They have to be: a word count that fell
1689 /// when you pressed ⌘E would be telling you the view had changed, which
1690 /// you knew already. So this reads neither [`Doc::view`] nor the map the
1691 /// frontend last built — it renders the source afresh, unwrapped, with
1692 /// soft breaks folded and no line revealed, and counts that. A narrower
1693 /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1694 /// source view all give the identical answer, because none of them is an
1695 /// input.
1696 ///
1697 /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1698 /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1699 /// settle and wrong in a paint loop, so a frontend should ask when the
1700 /// typing stops rather than once a keystroke. Caching it against
1701 /// [`revision`](Self::revision) is the obvious next move if that is ever
1702 /// not enough; nothing has needed it yet.
1703 pub fn counts(&self) -> TextCounts {
1704 self.count_over(None)
1705 }
1706
1707 /// The same statistics over the selection alone — `None` when nothing is
1708 /// selected, since an empty selection is no selection.
1709 ///
1710 /// Same rules, over the same rendering, narrowed to the glyphs whose
1711 /// source byte falls inside [`selection`](Self::selection)'s range. A
1712 /// block the selection only clips still counts as one paragraph, and one
1713 /// it enters without catching a visible character counts as none — a
1714 /// selection that starts on a hidden `**` gains no paragraph from it.
1715 pub fn selection_counts(&self) -> Option<TextCounts> {
1716 let (start, end) = self.selection()?;
1717 Some(self.count_over(Some(start..end)))
1718 }
1719
1720 /// The rendering both counters tally, and the tally itself.
1721 ///
1722 /// A fresh parse rather than `self.editor`, because these take `&self` and
1723 /// twig's arena is reached through `&mut`. A document that will not
1724 /// reparse is a "cannot happen" — the source came out of an editor that
1725 /// had already accepted it — and answers zero rather than panicking in
1726 /// what is very likely a paint path.
1727 fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1728 let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1729 return TextCounts::default();
1730 };
1731 let Ok(nodes) = editor.nodes() else {
1732 return TextCounts::default();
1733 };
1734 // A surface that paints pictures in a line, so an inline formula is
1735 // an atom here and never its TeX: a formula is a picture to a reader
1736 // whichever way it is written, and the count says so consistently.
1737 let surface = wysiwyg::Surface {
1738 inline_pictures: true,
1739 ..Default::default()
1740 };
1741 let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1742 counts::tally(&map, range)
1743 }
1744
1745 /// Whether the document refuses to change — see the field.
1746 pub fn read_only(&self) -> bool {
1747 self.read_only
1748 }
1749
1750 /// Turn the read-only gate on or off. A frontend preference like
1751 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1752 /// itself changes, only what may be done to it from here on.
1753 pub fn set_read_only(&mut self, on: bool) {
1754 self.read_only = on;
1755 }
1756
1757 /// The host-painted ranges, sorted by start — see [`Highlight`].
1758 pub fn highlights(&self) -> &[Highlight] {
1759 &self.highlights
1760 }
1761
1762 /// Replace the host-painted ranges wholesale. The whole set each time,
1763 /// rather than add/remove verbs: the host owns the list (it derives it
1764 /// from its own state — annotations, search hits), and a replace can
1765 /// never leave the two disagreeing about what should be on screen.
1766 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1767 highlights.retain(|h| h.start < h.end);
1768 highlights.sort_by_key(|h| (h.start, h.end));
1769 self.highlights = highlights;
1770 }
1771
1772 /// The highlight covering source `offset`, if one does — first by start
1773 /// when several overlap, which makes overlapping washes resolvable rather
1774 /// than undefined. What a frontend asks when the reader activates a spot.
1775 ///
1776 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1777 /// asking the same question per glyph, against a slice they were handed
1778 /// rather than against a `Doc`, and one answer for both is what keeps a
1779 /// wash and an activation agreeing about which range a spot is in.
1780 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1781 Highlight::covering(&self.highlights, offset)
1782 }
1783
1784 /// The AST breadcrumb at the caret (root → deepest), e.g.
1785 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1786 pub fn breadcrumb(&mut self) -> String {
1787 match self.editor.ancestors_at(self.caret) {
1788 Ok(chain) => chain
1789 .iter()
1790 .map(|m| m.kind.as_str())
1791 .collect::<Vec<_>>()
1792 .join(" › "),
1793 Err(_) => String::new(),
1794 }
1795 }
1796
1797 // ── editing ──────────────────────────────────────────────────────────────
1798
1799 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1800 /// after it. The public form of the internal splice — a pixel frontend that
1801 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1802 /// edits through this, the same twig `edit_range` the caret ops use.
1803 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1804 self.splice(start, end, text, EditKind::Other);
1805 }
1806
1807 /// Insert typed `text` at the caret, replacing the selection if there is one.
1808 /// A single typed character coalesces with the run of typing before it; a
1809 /// newline or a multi-character insert is its own undo step.
1810 ///
1811 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1812 pub fn insert(&mut self, text: &str) {
1813 // The read-only gate, up front: the paths below reach twig by several
1814 // verbs, not all of them through the splice — see the field.
1815 if self.read_only {
1816 return;
1817 }
1818 // Typing against a block picture would dissolve it, and typing past a
1819 // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1820 // the text a paragraph first, so what the caret was standing beside
1821 // stays what it was.
1822 self.open_paragraph_at_block_edge(text);
1823 // Armed sticky marks (⌘b with no selection) turn the next typed text
1824 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1825 // the exception: it takes no mark of its own and keeps the delta armed
1826 // for the character behind it — see `insert_space_with_marks`.
1827 let pending = self.pending_here();
1828 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1829 if text.trim().is_empty() {
1830 self.insert_space_with_marks(self.caret, text, pending);
1831 } else {
1832 self.insert_with_marks(self.caret, text, pending);
1833 }
1834 return;
1835 }
1836 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1837 // anything that would open markup, so a Diaryx user never mints
1838 // formatting by keyboard (it comes from commands instead). The other two
1839 // rungs of the ladder author markup from what you type, which is the
1840 // whole difference between them and this one. Only in the rendered view
1841 // (source view is for typing raw markup) and only where the format has a
1842 // literal spelling at all: escaping is a backslash before a byte from the
1843 // format's own alphabet, and a format with no such alphabet (HTML escapes
1844 // with entities, XML spells nothing) would have `\&` written into it,
1845 // which is two literal characters and not an escape. Marks (⌘b) still
1846 // format — that path returned above; and leaf's own structural inserts go
1847 // through `insert_raw`, never here, so a list marker or quote gutter is
1848 // written as the markup it is.
1849 if !self.markup_mode.authors()
1850 && self.view == View::Wysiwyg
1851 && !text.is_empty()
1852 && self.supports(Gesture::InsertLiteral)
1853 {
1854 self.insert_literal_typed(text);
1855 return;
1856 }
1857 self.insert_raw(text);
1858 }
1859
1860 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1861 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1862 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1863 /// markup by design and must not be escaped.
1864 fn insert_raw(&mut self, text: &str) {
1865 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1866 self.splice(s, e, text, typed_edit_kind(text));
1867 }
1868
1869 /// Open a paragraph for text about to be inserted at one of a block media's
1870 /// two caret stops, or at a table's trailing stop, and leave the caret
1871 /// standing in it.
1872 ///
1873 /// A block image is a paragraph whose entire content is the picture, and the
1874 /// caret's only homes on it are in front of it and just past it (see
1875 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1876 /// *that* paragraph — and a paragraph holding anything besides the image is
1877 /// no longer a block image but a line of text with an inline one in it. The
1878 /// frontend that was painting a photo there paints a text run instead; the
1879 /// picture is still in the file, and nothing said a word. Those two offsets
1880 /// are also exactly where a click on the picture lands, so the whole accident
1881 /// is one tap and one keystroke.
1882 ///
1883 /// So the break goes in first and the text lands in the new empty paragraph —
1884 /// what pressing Return before typing would have done, which is a habit no
1885 /// one should have to learn from losing a photo. A no-op everywhere else, and
1886 /// over a selection (which is replaced, not joined into).
1887 ///
1888 /// A picture inside a quote or a list leaves its container, because `\n\n`
1889 /// ends the block. The alternative is worse: the `\n> ` / next-item
1890 /// continuation [`newline`](Self::newline) writes stays in the same
1891 /// *paragraph*, which is the thing being prevented.
1892 ///
1893 /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1894 /// accident from the other side of a different block: the stop sits at the
1895 /// end of the table's last source line, and a line glued under a table is
1896 /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1897 /// break goes in there too, and the text lands under the table.
1898 ///
1899 /// Only in the rendered view. Source view is for typing raw markup, where
1900 /// putting a character against an image is exactly what it looks like.
1901 fn open_paragraph_at_block_edge(&mut self, text: &str) {
1902 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1903 return;
1904 }
1905 if self.selection().is_some() {
1906 return;
1907 }
1908 // The map may be a revision behind (nothing has drawn since the last
1909 // edit), and this asks it about offsets — a stale answer would splice a
1910 // break into the wrong place. Free when it is already current, which it
1911 // is whenever a frontend drew a frame between keystrokes.
1912 self.rebuild_map();
1913 let at = self.caret;
1914 let side = match self.vmap.block_media_stop(at) {
1915 Some((side, _)) => side,
1916 None if self.vmap.table_end_stop(at) => MediaStop::After,
1917 None => return,
1918 };
1919 if !self.splice(at, at, "\n\n", EditKind::Other) {
1920 return;
1921 }
1922 // The break is part of the keystroke, not an edit of its own: leave the
1923 // run marked as typing so the character about to arrive folds into it and
1924 // one undo puts the document back the way it was found. (A paste, or a
1925 // multi-character insert, is `EditKind::Other` and stays its own step —
1926 // as it would have been anywhere else in the document.)
1927 self.last_edit_kind = Some(EditKind::Insert);
1928 if side == MediaStop::Before {
1929 // The break went in above the picture and the caret rode to the end
1930 // of it — which is still hard against the picture. Step back onto the
1931 // blank line it opened, so the text lands above rather than in front.
1932 self.caret = at;
1933 }
1934 }
1935
1936 /// A delete key pressed at one of a block picture's two caret stops, handled
1937 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1938 /// the key was consumed.
1939 ///
1940 /// The caret rests in front of a block image and just past it, never inside
1941 /// its markup — which the rendered view doesn't show. So the byte a delete
1942 /// key nominally takes there is one the writer cannot see, and taking it
1943 /// leaves the picture as broken markup rather than as anything anyone asked
1944 /// for: Backspace at the stop past `` removes the closing paren, and
1945 /// a photo becomes the literal text `
1948 /// prevents from the typing side, and it cost this repository's own test vault
1949 /// a photo before it was found.
1950 ///
1951 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1952 /// when it is behind the caret, Delete when it is in front — which is what
1953 /// every editor does with an embed, and one undo away. The key aimed *away*
1954 /// from it would otherwise delete the paragraph break and merge a neighbour
1955 /// into the picture's own paragraph, which dissolves it just as surely; it
1956 /// steps the caret over the boundary instead and leaves the
1957 /// next press to delete in the block it has reached — the same "first press
1958 /// steps out of the atom, second press deletes" every delete key here gets,
1959 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1960 /// above, and reaches it on the second press rather than taking the break and
1961 /// the picture with it on the first).
1962 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1963 // The map answers about offsets, so it has to be this revision's — see
1964 // the same call in `open_paragraph_at_block_edge`.
1965 self.rebuild_map();
1966 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1967 return false;
1968 };
1969 let aimed_at_it = side
1970 == if forward {
1971 MediaStop::Before
1972 } else {
1973 MediaStop::After
1974 };
1975 if !aimed_at_it {
1976 let over = if forward {
1977 self.vmap.stop_after(self.caret)
1978 } else {
1979 self.vmap.stop_before(self.caret)
1980 };
1981 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1982 self.caret = off;
1983 self.anchor = None;
1984 self.goal_col = None;
1985 }
1986 return true;
1987 }
1988 // Take the break that held the picture apart from its neighbour with it,
1989 // so the delete doesn't leave a blank paragraph standing where the
1990 // picture was. The last arm is a picture that is the whole document.
1991 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1992 (span.start - 2, span.end)
1993 } else if self.source[span.end..].starts_with("\n\n") {
1994 (span.start, span.end + 2)
1995 } else {
1996 (span.start, span.end)
1997 };
1998 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1999 true
2000 }
2001
2002 /// The Hidden-mode typing path: replace any selection, then insert `text`
2003 /// escaped so it stays literal. When it replaces a selection the two edits
2004 /// fold into one undo step, so an overwrite undoes atomically (and restores
2005 /// the selection) exactly as a plain one does.
2006 fn insert_literal_typed(&mut self, text: &str) {
2007 let kind = typed_edit_kind(text);
2008 match self.selection() {
2009 Some((s, e)) => {
2010 if !self.splice(s, e, "", EditKind::Other) {
2011 return;
2012 }
2013 // Typing over a whole marked run takes its delimiters with it
2014 // (the empty content couldn't hold them — see
2015 // `repair_mark_edges`) and leaves its marks armed at the caret.
2016 // The text taking the run's place inherits them, exactly as it
2017 // would have by landing inside a run that survived.
2018 let pending = self.pending_here();
2019 if !pending.is_empty() && !text.trim().is_empty() {
2020 self.insert_with_marks(self.caret, text, pending);
2021 return;
2022 }
2023 self.insert_literal_at(self.caret, text, kind, true);
2024 }
2025 None => {
2026 self.insert_literal_at(self.caret, text, kind, false);
2027 }
2028 }
2029 }
2030
2031 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2032 /// at a collapsed caret, but only while the caret still stands where they
2033 /// were armed and nothing is selected. Empty otherwise, so a stale delta
2034 /// never styles text it wasn't meant for.
2035 fn pending_here(&self) -> InlineMarks {
2036 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2037 self.pending_marks
2038 } else {
2039 InlineMarks::empty()
2040 }
2041 }
2042
2043 /// Drop the armed sticky marks — any caret motion, selection, or edit does
2044 /// this, so "start bold here" only ever applies at the exact spot it was
2045 /// asked for.
2046 fn clear_pending(&mut self) {
2047 self.pending_marks = InlineMarks::empty();
2048 self.pending_at = None;
2049 }
2050
2051 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2052 /// force is wrapped around the freshly typed text; a mark the caret already
2053 /// stands inside is *shed* — the text is inserted past the run's end so it
2054 /// lands unmarked ("type normally again"). The caret comes to rest inside any
2055 /// added runs, so continued typing inherits the marks with no re-wrapping,
2056 /// and the delta is cleared: the marks now live in the document, not here.
2057 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2058 let base = self.mark_spans_at(at);
2059 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2060 // Nothing to shed, and a run of exactly these marks standing just behind
2061 // the caret: carry on writing *that* run rather than opening a second
2062 // one beside it.
2063 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2064 return;
2065 }
2066 // Shed the marks we're turning off: step the insertion point past the
2067 // end of each run the caret sits in, so the new text falls outside it.
2068 let mut ins_at = at;
2069 for (kind, span) in &base {
2070 if marks.contains(*kind) {
2071 ins_at = ins_at.max(span.end);
2072 }
2073 }
2074 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2075 return;
2076 }
2077 // The plain splice inserted exactly `text` at `ins_at`; that byte range
2078 // is the content every added mark wraps.
2079 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2080 for kind in marks.iter() {
2081 if !base_set.contains(kind) {
2082 let (ncs, nce) = self.wrap_span(cs, ce, kind);
2083 cs = ncs;
2084 ce = nce;
2085 }
2086 }
2087 self.caret = ce.min(self.source.len());
2088 self.anchor = None;
2089 self.last_edit_kind = None;
2090 // Realised: the marks are in the document now, and the caret sits inside
2091 // them, so there is no delta left to carry. Arm nothing, but remember the
2092 // spot so a *further* toggle before typing starts a clean delta here.
2093 self.pending_marks = InlineMarks::empty();
2094 self.pending_at = Some(self.caret);
2095 self.clamp_caret();
2096 self.record_caret();
2097 }
2098
2099 /// Carry on the marked run just behind `at` — moving its closing delimiters
2100 /// out past the new text — instead of opening a second run of the same marks
2101 /// beside it. Returns whether it did.
2102 ///
2103 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2104 /// A space typed after a bold word steps the caret out of the run, because
2105 /// `**bold **` is not bold; the next character has to step back *in*, or the
2106 /// writer who typed one bold phrase is left with `**bold** **and**` — two
2107 /// runs that read the same to a reader but spell the file in a way nobody
2108 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2109 /// words it isn't marking), and the marks behind it must be exactly the ones
2110 /// armed — a run of *some* other kind is a neighbour, not this phrase.
2111 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2112 if text.is_empty() || text.trim() != text {
2113 return false;
2114 }
2115 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2116 // Walk in through the delimiters stacked at that point, innermost last:
2117 // `***both*** ` closes two runs with one `***`, and rejoining means
2118 // getting behind all of them.
2119 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2120 while let Some((kind, content_end)) = self
2121 .editor
2122 .ancestors_at(prev_boundary(&self.source, cut))
2123 .unwrap_or_default()
2124 .into_iter()
2125 .filter(|m| m.span.end == cut)
2126 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2127 {
2128 if content_end >= cut {
2129 break; // a mark with no closing delimiter to step behind
2130 }
2131 kinds.insert(kind);
2132 cut = content_end;
2133 }
2134 if cut == gap_at || kinds != marks {
2135 return false;
2136 }
2137 // Re-spell the tail: the gap, then the new text, then the delimiters that
2138 // used to close in front of them — read out of the document rather than
2139 // written from a table, so whatever twig spells them with is what moves.
2140 let tail = format!(
2141 "{}{text}{}",
2142 &self.source[gap_at..at],
2143 &self.source[cut..gap_at]
2144 );
2145 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2146 return false;
2147 }
2148 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2149 self.anchor = None;
2150 self.last_edit_kind = None;
2151 self.pending_marks = InlineMarks::empty();
2152 self.pending_at = Some(self.caret);
2153 self.clamp_caret();
2154 self.record_caret();
2155 true
2156 }
2157
2158 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2159 /// never itself wrapped: a mark around a space draws nothing a reader can
2160 /// see, and in Markdown and Djot it draws its own delimiters instead
2161 /// (`** **`). So the space goes in unmarked — outside any run the armed
2162 /// marks are shedding — and the marks stay armed for the character after it,
2163 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2164 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2165 let base = self.mark_spans_at(at);
2166 // What the *next* character carries: the armed delta resolved against the
2167 // marks in force here, which the space must not quietly drop.
2168 let want = base
2169 .iter()
2170 .map(|(k, _)| *k)
2171 .collect::<InlineMarks>()
2172 .xor(marks);
2173 let mut ins_at = at;
2174 for (kind, span) in &base {
2175 if marks.contains(*kind) {
2176 ins_at = ins_at.max(span.end);
2177 }
2178 }
2179 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2180 return;
2181 }
2182 self.rearm(want);
2183 self.record_caret();
2184 }
2185
2186 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2187 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2188 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2189 /// added split evenly around the content — half the growth on each side.
2190 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2191 // The read-only gate — this door reaches twig without the splice.
2192 if self.read_only {
2193 return (s, e);
2194 }
2195 match self.editor.toggle_inline(s, e, kind) {
2196 Ok(change) => {
2197 self.last_edit_kind = None;
2198 self.refresh();
2199 self.dirty = self.source != self.clean_source;
2200 let added = (change.new.end - change.new.start).saturating_sub(e - s);
2201 let half = added / 2;
2202 (change.new.start + half, change.new.end - half)
2203 }
2204 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2205 // rather than lose the keystroke.
2206 Err(e2) => {
2207 self.status = Some(format!("{kind:?}: {e2}"));
2208 (s, e)
2209 }
2210 }
2211 }
2212
2213 /// The safe offset to splice a block-level break at, given a caret that may
2214 /// sit exactly between an inline mark's content and its own closing
2215 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2216 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2217 /// with nothing following it on the line: the closing `**` renders no
2218 /// glyph of its own, so the caret's "end of line" offset lands right
2219 /// before it). Splicing a paragraph/list/quote break at `off` itself would
2220 /// sever the delimiter from its content, stranding it alone on the new
2221 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2222 /// nested marks closing at the same point (`**_x_**`) all clear together.
2223 /// A no-op everywhere else — mid-run, or past real trailing content, no
2224 /// mark's `content_span` ends exactly at `off`.
2225 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2226 let off = off.min(self.source.len());
2227 let runs = self.run_span_ids();
2228 self.editor
2229 .ancestors_at(off)
2230 .unwrap_or_default()
2231 .into_iter()
2232 .filter(|m| hides_delims(m, &runs))
2233 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2234 .map(|m| m.span.end)
2235 .max()
2236 .unwrap_or(off)
2237 }
2238
2239 /// The offset a *delete* aimed at the character before `off` should stop at,
2240 /// when `off` is the start of a run's text and the bytes behind it are that
2241 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2242 /// byte behind the caret at the start of a bold word is not a character the
2243 /// writer can see, let alone one they aimed Backspace at: taking it leaves
2244 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2245 /// delete steps over the whole delimiter to the visible character in front of
2246 /// it instead. Walks out to the *outermost* mark opening there, so
2247 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2248 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2249 let off = off.min(self.source.len());
2250 let runs = self.run_span_ids();
2251 self.editor
2252 .ancestors_at(off)
2253 .unwrap_or_default()
2254 .into_iter()
2255 .filter(|m| hides_delims(m, &runs))
2256 .filter(|m| {
2257 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2258 })
2259 .map(|m| m.span.start)
2260 .min()
2261 .unwrap_or(off)
2262 }
2263
2264 /// `off` moved *inside* the run whose closing delimiters end there — the
2265 /// other offset the rich view draws in the same place, since a `**` renders
2266 /// no glyph of its own. `**bold**` has a caret home on each side of its
2267 /// closing delimiter, one column apart on screen and eight bytes and a whole
2268 /// run apart in the file, and a plain ← lands on the outer one whenever a
2269 /// space follows the phrase. The inner one is what the writer is pointing at
2270 /// there: the end of their bold word. Walks in through every mark closing at
2271 /// that point, innermost last, so `***both***` lands inside both. A no-op
2272 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2273 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2274 let mut off = off.min(self.source.len());
2275 let runs = self.run_span_ids();
2276 loop {
2277 let inner = self
2278 .editor
2279 .ancestors_at(prev_boundary(&self.source, off))
2280 .unwrap_or_default()
2281 .into_iter()
2282 .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2283 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2284 .filter(|&end| end < off)
2285 .max();
2286 match inner {
2287 Some(end) => off = end,
2288 None => return off,
2289 }
2290 }
2291 }
2292
2293 /// The mirror at the opening edge: `off` moved inside the run whose
2294 /// delimiters *start* there, onto the first character of its text. See
2295 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2296 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2297 let mut off = off.min(self.source.len());
2298 let runs = self.run_span_ids();
2299 loop {
2300 let inner = self
2301 .editor
2302 .ancestors_at(off)
2303 .unwrap_or_default()
2304 .into_iter()
2305 .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2306 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2307 .filter(|&start| start > off)
2308 .min();
2309 match inner {
2310 Some(start) => off = start,
2311 None => return off,
2312 }
2313 }
2314 }
2315
2316 /// The ids of the document's attributed run spans — the inline
2317 /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2318 /// which sees an ancestor chain and so only a kind. Read once per gesture,
2319 /// not once per step of a walk.
2320 fn run_span_ids(&mut self) -> Vec<NodeId> {
2321 self.nodes()
2322 .iter()
2323 .filter(|n| wysiwyg::is_run_span(n))
2324 .map(|n| n.id)
2325 .collect()
2326 }
2327
2328 /// The attributed span whose text is exactly `content` — the whole of
2329 /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2330 /// and sits between the tags of `<span …></span>` — as the whole range
2331 /// spelling the span: the node's span, widened to its attribute block
2332 /// where the format writes that outside the node, as djot's
2333 /// `[i]{data-size="large"}` does. `None` for any other range, including
2334 /// part of a span's text.
2335 ///
2336 /// An empty span has an interior of no bytes, or no known interior at
2337 /// all: twig gives Markdown's `<span …></span>` the first and djot's
2338 /// `[]{…}` the second, and the chain already says the offset is inside.
2339 fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2340 let runs = self.run_span_ids();
2341 let m = self
2342 .editor
2343 .ancestors_at(content.start)
2344 .unwrap_or_default()
2345 .into_iter()
2346 .filter(|m| runs.contains(&NodeId(m.node_id)))
2347 .find(|m| match &m.content_span {
2348 Some(c) => *c == content,
2349 None => content.is_empty(),
2350 })?;
2351 let mut range = m.span;
2352 if let Some(attrs) = self
2353 .editor
2354 .document()
2355 .ok()
2356 .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2357 {
2358 range.start = range.start.min(attrs.start);
2359 range.end = range.end.max(attrs.end);
2360 }
2361 Some(range)
2362 }
2363
2364 /// The attributed block whose whole text is exactly `content` — the `T`
2365 /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2366 /// `{.center}\nT` — as the range a delete that takes that text takes with
2367 /// it: the whole `<div>` when the block is all the div holds, or the
2368 /// `{…}` line down to the end of the text. The block version of
2369 /// [`run_span_of_content`](Self::run_span_of_content), for the same
2370 /// reason: a paragraph with no text is no block, so the div would stand
2371 /// around nothing and the `{…}` line above nothing, and a from-scratch
2372 /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2373 /// `None` for a block with more text, a div holding more, a heading (an
2374 /// empty `# ` is still a heading), and a format whose attributes are the
2375 /// block's own tag (HTML's `<p class="center"></p>` is still a
2376 /// paragraph).
2377 fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2378 if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2379 return None;
2380 }
2381 let nodes = self.nodes();
2382 let block = nodes
2383 .iter()
2384 .filter(|n| n.kind == Kind::Para)
2385 .find(|n| n.content_span.as_ref() == Some(&content))?;
2386 match self.format {
2387 Format::Djot => {
2388 let attrs = self
2389 .editor
2390 .document()
2391 .ok()
2392 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2393 (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2394 }
2395 _ => {
2396 let div = block
2397 .parent
2398 .and_then(|p| nodes.iter().find(|n| n.id == p))
2399 .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2400 let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2401 alone.then(|| div.span.clone())
2402 }
2403 }
2404 }
2405
2406 /// The inline mark kinds whose span covers `off`, each with that span — the
2407 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2408 /// ids instead. Used to shed a mark by stepping past the end of its run.
2409 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2410 let off = off.min(self.source.len());
2411 self.editor
2412 .ancestors_at(off)
2413 .unwrap_or_default()
2414 .into_iter()
2415 .filter(|m| off < m.span.end)
2416 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2417 .collect()
2418 }
2419
2420 /// Insert clipboard `text` at the caret, replacing the selection if there is
2421 /// one — always its own undo step, whatever its length.
2422 ///
2423 /// Provenance is the whole point, and only the caller has it. `insert` reads
2424 /// a lone character as a keystroke and folds it into the run around it,
2425 /// which is right for typing and wrong for a one-character paste: that paste
2426 /// would vanish mid-run on an undo it was never part of, and the characters
2427 /// the user actually typed would go with it. Length can't tell the two
2428 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2429 /// caller comes through is what says which happened.
2430 pub fn paste(&mut self, text: &str) {
2431 // Pasting against a block picture or a table's end joins the block
2432 // exactly as typing does, and for the same reason — see
2433 // `open_paragraph_at_block_edge`.
2434 self.open_paragraph_at_block_edge(text);
2435 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2436 self.splice(s, e, text, EditKind::Other);
2437 }
2438
2439 /// Replace `[start, end)` with `text` as one step of an IME composition —
2440 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2441 /// folds into a single undo.
2442 ///
2443 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2444 /// dozen calls here, each replacing the last one's provisional bytes, and an
2445 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2446 /// unspools backwards through kana — the intermediate states were never text
2447 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2448 /// look like any other edit), so the door the caller comes through is what
2449 /// says so, exactly as it is for [`paste`](Self::paste) versus
2450 /// [`insert`](Self::insert).
2451 ///
2452 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2453 /// composition folds into this one.
2454 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2455 self.splice(start, end, text, EditKind::Compose);
2456 }
2457
2458 /// Close the open composition run, so the next one is its own undo step.
2459 /// Call when the IME commits or withdraws a composition.
2460 ///
2461 /// Only clears a *composition* run: a frontend that reports an end it never
2462 /// began (some IMEs unmark unprompted) would otherwise split the run of
2463 /// typing around it into two undo steps for no reason the user can see.
2464 pub fn end_composition(&mut self) {
2465 if self.last_edit_kind == Some(EditKind::Compose) {
2466 self.last_edit_kind = None;
2467 }
2468 }
2469
2470 // ── the clipboard's rich flavor ──────────────────────────────────────────
2471
2472 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2473 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2474 /// nothing is selected, or when the selection doesn't render (the caller
2475 /// still has [`selected_text`](Self::selected_text), which is what to publish
2476 /// as `text/plain` either way).
2477 ///
2478 /// **The fragment is a source substring, and that is the honest limit here.**
2479 /// It's parsed standalone, so a selection whose meaning depends on its
2480 /// surroundings converts as what it literally says rather than what it looks
2481 /// like on screen: half a list item is a paragraph, a row torn out of a table
2482 /// is the text of a row, the `**` of a bold run selected without its closing
2483 /// `**` is two asterisks. Every one of those still *renders* — there's no
2484 /// error to report — it just renders as the fragment and not as the document.
2485 /// Widening the range to whole blocks would publish text the user didn't
2486 /// select, which is a worse lie than a fragment being a fragment; the plain
2487 /// flavor has the same substring, so the two flavors at least agree.
2488 pub fn selection_html(&mut self) -> Option<String> {
2489 let (start, end) = self.selection()?;
2490 let inline = self.selection_is_inline(start, end);
2491 let html = html::render_fragment(&self.source[start..end], self.format)?;
2492 Some(match inline {
2493 true => html::strip_sole_paragraph(html),
2494 false => html,
2495 })
2496 }
2497
2498 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2499 /// format first. Its own undo step, like any [`paste`](Self::paste).
2500 ///
2501 /// Returns whether it landed. `false` means the HTML didn't convert to
2502 /// anything worth pasting — the caller should fall back to the plain flavor
2503 /// rather than treat it as an error. The `html` module has the full list of
2504 /// what that covers: a table twig won't build, markup it doesn't recognise,
2505 /// an empty result.
2506 pub fn paste_html(&mut self, html: &str) -> bool {
2507 match html::parse_fragment(html, self.format) {
2508 Some(source) => {
2509 self.paste(&source);
2510 true
2511 }
2512 None => false,
2513 }
2514 }
2515
2516 /// Does the selection live *inside* a single top-level block?
2517 ///
2518 /// The question [`selection_html`](Self::selection_html) needs and the
2519 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2520 /// whether the user selected one word of a sentence or a whole paragraph, and
2521 /// only the document knows which. Selecting a word and pasting into Docs
2522 /// should extend the line you paste into; selecting the paragraph should make
2523 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2524 /// is an artifact of standalone parsing), and one that covers a whole block —
2525 /// or spans two — keeps its structure.
2526 ///
2527 /// Reads the block from twig rather than guessing from the bytes:
2528 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2529 /// block containing an offset, and two ends inside the same one cannot have
2530 /// crossed a block boundary.
2531 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2532 // The last *character*, not `end - 1`: the selection's end is exclusive
2533 // and may sit mid-codepoint's-worth of bytes past the last char.
2534 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2535 return false;
2536 };
2537 let (Some(head), Some(tail)) =
2538 (self.top_block_span(start), self.top_block_span(start + off))
2539 else {
2540 return false;
2541 };
2542 head == tail && !(start <= head.start && end >= head.end)
2543 }
2544
2545 /// The byte span of the top-level block containing `offset`, or `None` at an
2546 /// offset that belongs to no block (the blank line between two of them).
2547 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2548 self.editor
2549 .ancestors_at(offset)
2550 .ok()?
2551 .get(1)
2552 .map(|m| m.span.clone())
2553 }
2554
2555 // ── indentation ──────────────────────────────────────────────────────────
2556
2557 /// One indent level.
2558 ///
2559 /// Two spaces, not the four both frontends type for Tab today, because in a
2560 /// markdown document four columns isn't a width — it's a *meaning*. Four
2561 /// spaces at the head of a line is markdown's indented-code-block marker, so
2562 /// one Tab on a paragraph would reparse it into code and style it as such;
2563 /// two cannot, and the line stays the prose it was. Two is also exactly
2564 /// where a `- ` bullet's content starts, so an indented line lands under its
2565 /// parent item's text instead of beside it — the column a list-aware indent
2566 /// has to hit anyway, which keeps this width from being relitigated later.
2567 const INDENT: &'static str = " ";
2568
2569 /// Indent the selected lines — or the caret's line, with no selection — by
2570 /// one level (Tab).
2571 pub fn indent(&mut self) {
2572 self.reindent(true);
2573 // Nesting changes an ordered list's numbering (the nested item restarts,
2574 // its old siblings resume) — keep the source markers in step.
2575 self.renumber_here();
2576 // Nesting an empty `-` item under a text line reparses that text as a
2577 // setext heading; swap the dash for a `*` before it can (a no-op unless
2578 // the collapse actually happened).
2579 self.avoid_setext_collapse();
2580 }
2581
2582 /// Take one indent level back off the selected lines, or the caret's line
2583 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2584 ///
2585 /// A line with *less* than a full level gives back what it has rather than
2586 /// refusing: outdent's job is to walk a line left, and real documents — hand
2587 /// written, or reflowed by some other editor — are full of indentation that
2588 /// was never a clean multiple of anything. Refusing there would strand the
2589 /// line at a depth Shift+Tab couldn't undo.
2590 pub fn outdent(&mut self) {
2591 self.reindent(false);
2592 self.renumber_here();
2593 }
2594
2595 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2596 ///
2597 /// One splice across the whole line range, never one per line: a Tab is one
2598 /// thing the user did, so it has to be one undo step and one reparse. Per
2599 /// line, twig would reparse the document once per line and leave a stack of
2600 /// steps that Shift+⌘Z walks back one line at a time.
2601 fn reindent(&mut self, add: bool) {
2602 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2603 let start = source_line_range(&self.source, sel_start).start;
2604 let end = source_line_range(&self.source, sel_end).end;
2605 let region = self.source[start..end].to_string();
2606 let lines: Vec<&str> = region.split('\n').collect();
2607 // A blank line has no text to move, and padding it would leave nothing
2608 // but trailing whitespace — but Tab on a blank line *is* a request for
2609 // indentation to type into, so the skip only applies where the op has
2610 // other lines to do real work on.
2611 let skip_blank = add && lines.len() > 1;
2612
2613 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2614 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2615 let mut line_off = start;
2616 for (i, full) in lines.iter().enumerate() {
2617 if i > 0 {
2618 out.push('\n');
2619 }
2620 // A list item moves by having its whole leading prefix *replaced*,
2621 // never by having spaces pushed in front of the line. twig spells
2622 // both prefixes, so the quote markers, the parent's indent and an
2623 // ordered marker's extra column all come out right without leaf
2624 // measuring any of them — and a line that only looks like an item
2625 // (a Djot continuation) reports no marker and is left to the plain
2626 // path, where a Tab is just a Tab.
2627 let marker = self.list_marker_on_line(line_off);
2628 let own = marker
2629 .as_ref()
2630 .map(|m| m.marker_start - m.line_start)
2631 .unwrap_or(0);
2632 let delta = if add {
2633 if skip_blank && full.trim().is_empty() {
2634 out.push_str(full);
2635 0
2636 } else if marker.is_some() && self.first_item_of_list(line_off) {
2637 // The first item of a list has no preceding sibling to nest
2638 // under, so a Tab here can't spell a sub-list — twig would
2639 // reparse the shoved-over marker as the same list, only
2640 // indented, which Shift+Tab then can't cleanly undo. Leave the
2641 // item where it is, the way every list editor refuses to
2642 // over-indent a list's first line.
2643 out.push_str(full);
2644 0
2645 } else if marker.is_some() {
2646 // Nesting means standing where a *continuation* of this line
2647 // would stand: past the parent's marker, inside its content
2648 // column. That is `continuation_prefix`, less a checkbox.
2649 let new = self.nesting_prefix_at(line_off);
2650 let delta = new.len() as isize - own as isize;
2651 out.push_str(&new);
2652 out.push_str(&full[own..]);
2653 delta
2654 } else {
2655 out.push_str(Self::INDENT);
2656 out.push_str(full);
2657 Self::INDENT.len() as isize
2658 }
2659 } else if marker.is_some() {
2660 // Unnesting is the mirror: stand where the parent item's own
2661 // line starts, which drops exactly the level it contributed.
2662 let new = self.outdent_prefix_at(line_off);
2663 let delta = new.len() as isize - own as isize;
2664 out.push_str(&new);
2665 out.push_str(&full[own..]);
2666 delta
2667 } else {
2668 // A plain line gives back the ordinary step.
2669 let strip = outdent_width(full, Self::INDENT.len());
2670 out.push_str(&full[strip..]);
2671 -(strip as isize)
2672 };
2673 deltas.push(delta);
2674 line_off += full.len() + 1;
2675 }
2676 // Nothing to give back. Returning before the splice keeps an outdent at
2677 // column zero from spending an undo step on a document it never changed.
2678 if deltas.iter().all(|d| *d == 0) {
2679 return;
2680 }
2681
2682 // Every line's text keeps its offset *within the line*, so the caret is
2683 // remapped by its column, not by its byte offset — which the prefixes on
2684 // the lines above it have already invalidated.
2685 let remap = |off: usize| -> usize {
2686 let (mut old_ls, mut new_ls) = (start, start);
2687 for (line, delta) in lines.iter().zip(&deltas) {
2688 let old_le = old_ls + line.len();
2689 let new_len = (line.len() as isize + delta) as usize;
2690 if off <= old_le {
2691 let col = (off - old_ls) as isize;
2692 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2693 }
2694 old_ls = old_le + 1;
2695 new_ls += new_len + 1;
2696 }
2697 start + out.len()
2698 };
2699 let placed = match self.selection() {
2700 // Keep the rewritten region selected, the way a container toggle
2701 // keeps its own: it leaves a second Tab aimed at the same lines
2702 // rather than at whatever the shifted offsets now happen to cover.
2703 Some(_) => (start + out.len(), Some(start)),
2704 None => (remap(self.caret), None),
2705 };
2706
2707 // A rolled-back splice leaves the old source in place, where every offset
2708 // computed above addresses text that was never written.
2709 if !self.splice(start, end, &out, EditKind::Other) {
2710 return;
2711 }
2712 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2713 // rewrite is the last line's end — nowhere the caret was. Place it, then
2714 // re-record the caret so this is the state redo restores, not the one
2715 // `splice` left behind from the `Change`.
2716 self.caret = placed.0.min(self.source.len());
2717 self.anchor = placed.1;
2718 self.clamp_caret();
2719 self.record_caret();
2720 }
2721
2722 /// The Enter key.
2723 ///
2724 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2725 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2726 /// caret is in decides what actually gets written.
2727 ///
2728 /// - paragraph → twig's [`Editor::split_block`], which parts the
2729 /// block at the caret and reopens its container
2730 /// - list item → likewise: the next item, its indent, quote
2731 /// prefix and `[ ]` box all reproduced by twig —
2732 /// except an *empty* item, which exits the list
2733 /// - block quote → likewise: a new paragraph inside the quote
2734 /// - heading → a new *paragraph*, not another heading
2735 /// - code block → a literal newline (stay in the block)
2736 /// - blank line → a literal newline (one Backspace undoes it)
2737 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2738 /// visible line
2739 ///
2740 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2741 /// and it is better at it: it drops the whitespace the caret was sitting in
2742 /// front of instead of stranding it at the head of the second half, and it
2743 /// knows continuations leaf's marker scan never covered — a checklist item
2744 /// continues as an *unchecked* checklist item rather than a plain bullet.
2745 ///
2746 /// The exceptions above are exceptions because `split_block` is either wrong
2747 /// there or refuses: parting a fence yields two fences with the code split
2748 /// between them, parting a heading yields a second heading where every editor
2749 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2750 /// table all report an error rather than a split.
2751 pub fn newline(&mut self) {
2752 if self.view == View::Source {
2753 self.insert_raw("\n");
2754 return;
2755 }
2756 // Enter over a selection replaces it with a paragraph break.
2757 if let Some((s, e)) = self.selection() {
2758 self.splice(s, e, "\n\n", EditKind::Other);
2759 return;
2760 }
2761 // A caret resting exactly between an inline mark's content and its own
2762 // closing delimiter (`**bold**` with nothing after it on the line —
2763 // the WYSIWYG caret's natural end-of-line position) must not splice a
2764 // block break there: every path below eventually does via
2765 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2766 // delimiter would strand it alone on the new line.
2767 self.caret = self.skip_trailing_close_delims(self.caret);
2768 // The block the caret is in. `block_offset_for_caret` nudges off a line
2769 // end (where the caret sits at the doc level); on a bare line (e.g. an
2770 // empty list item) fall back to the caret so the enclosing list/quote is
2771 // still visible in the ancestors.
2772 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2773 let kinds: Vec<Kind> = self
2774 .editor
2775 .ancestors_at(off)
2776 .map(|c| c.into_iter().map(|m| m.kind).collect())
2777 .unwrap_or_default();
2778 let has = |k: Kind| kinds.contains(&k);
2779
2780 if has(Kind::CodeBlock) {
2781 self.insert_raw("\n");
2782 return;
2783 }
2784 // An *empty* list item exits the list — the standard double-Enter — which
2785 // `split_block` reports as an error rather than a split (there is no
2786 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2787 // the AST gate — it answers from the tree, so a `- ` that reads as a
2788 // marker byte-for-byte but opens no item (a setext underline, a Djot
2789 // continuation line) never reaches here.
2790 if let Some(marker) = self.list_marker_on_line(self.caret)
2791 && self.item_is_empty(&marker)
2792 {
2793 self.exit_list(&marker);
2794 return;
2795 }
2796 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2797 // not another full paragraph break — so it moves down one line and one
2798 // Backspace undoes it, not two. (`split_block` errors here too.)
2799 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2800 let line_end = self.source[self.caret..]
2801 .find('\n')
2802 .map_or(self.source.len(), |i| self.caret + i);
2803 if self.source[line_start..line_end].trim().is_empty() {
2804 self.insert_raw("\n");
2805 return;
2806 }
2807 // In `Preserve` flow a soft break is a *visible* line the author means to
2808 // make, so Enter writes a single `\n` and typing continues the same
2809 // paragraph on the next line — the behaviour of an ordinary text editor.
2810 // A second Enter then lands on the blank line above and takes the
2811 // empty-line branch, so double-Enter still promotes to a full paragraph
2812 // break; and Backspace, which deletes a lone `\n` over a soft break,
2813 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2814 // render as an invisible space, so Enter keeps making the paragraph break
2815 // that actually shows.
2816 //
2817 // Only in running prose. A list or a quote has a continuation of its own
2818 // to write, and a `\n` there is not a soft line but a lost container.
2819 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2820 if self.line_flow == LineFlow::Preserve && !in_container {
2821 self.insert_raw("\n");
2822 return;
2823 }
2824 // A heading gets a *paragraph*, never a second heading: Enter at the end
2825 // of a title is how every editor is asked for the body under it, and
2826 // `split_block` would repeat the `#` instead. Whitespace at the split
2827 // point goes with the break rather than opening the new paragraph, which
2828 // is what `split_block` does everywhere else.
2829 if has(Kind::Heading) {
2830 let mut end = self.caret;
2831 while self.source.as_bytes().get(end) == Some(&b' ') {
2832 end += 1;
2833 }
2834 self.splice(self.caret, end, "\n\n", EditKind::Other);
2835 return;
2836 }
2837 self.split_block_here();
2838 }
2839
2840 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2841 /// the caret in the second half.
2842 ///
2843 /// twig reopens whatever the first half was inside of — the bullet with its
2844 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2845 /// reason this replaced the markup leaf used to spell from the line's bytes.
2846 /// It renumbers nothing, though: a new item mid-list is written with its
2847 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2848 /// behind it, folded into the same undo step.
2849 ///
2850 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2851 /// shape still moves the caret down rather than swallowing the keystroke.
2852 fn split_block_here(&mut self) {
2853 // The read-only gate — this door reaches twig without the splice.
2854 if self.read_only {
2855 return;
2856 }
2857 match self.editor.split_block(self.caret) {
2858 Ok(change) => {
2859 self.last_edit_kind = None;
2860 self.refresh();
2861 self.anchor = None;
2862 self.caret = change.new.end;
2863 self.dirty = self.source != self.clean_source;
2864 self.status = None;
2865 self.clamp_caret();
2866 self.record_caret();
2867 // Aimed at the new block's *start*: the caret twig leaves is one
2868 // past the marker it wrote, where there is no list in reach.
2869 self.renumber_at(change.new.start);
2870 }
2871 Err(_) => self.insert_raw("\n\n"),
2872 }
2873 }
2874
2875 /// Whether the item on the marker's line carries no content — the shape
2876 /// double-Enter reads as "I'm done with this list."
2877 fn item_is_empty(&self, line: &ListMarker) -> bool {
2878 let content_start = line.content_start().min(self.source.len());
2879 let line_end = self.source[self.caret..]
2880 .find('\n')
2881 .map(|i| self.caret + i)
2882 .unwrap_or(self.source.len());
2883 self.source[content_start..line_end.max(content_start)]
2884 .trim()
2885 .is_empty()
2886 }
2887
2888 /// Leave the list: replace the empty item's marker with a blank line, so the
2889 /// caret lands in a fresh paragraph below it.
2890 ///
2891 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2892 /// quote), and the caret's new line keeps the `> ` it was already behind —
2893 /// leaving the list without also leaving the quote.
2894 fn exit_list(&mut self, line: &ListMarker) {
2895 let prefix = self.quote_prefix_at(line.marker_start);
2896 let blank = prefix.trim_end();
2897 self.splice(
2898 line.line_start,
2899 self.caret,
2900 &format!("{blank}\n{prefix}"),
2901 EditKind::Other,
2902 );
2903 }
2904
2905 /// What a line continuing the containers at `off` has to open with — the
2906 /// quote markers reproduced, each enclosing item's marker as its width in
2907 /// spaces. Also the column a nested item's marker stands in, which is what
2908 /// makes it Tab's answer.
2909 fn continuation_prefix_at(&mut self, off: usize) -> String {
2910 self.editor
2911 .document()
2912 .and_then(|mut d| d.continuation_prefix(off))
2913 .map(|p| p.text)
2914 .unwrap_or_default()
2915 }
2916
2917 /// The column a *nested list* may open at inside the item at `off` — which
2918 /// is not always where the item's own text continues.
2919 ///
2920 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2921 /// it is markup a rich view hides, and the item's own wrapped text does
2922 /// stand past it. But a nested list may only open at the *list* marker's
2923 /// column, and four columns further in is an indented continuation of the
2924 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2925 /// So the box's own width goes back.
2926 ///
2927 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2928 /// reports the list marker's column apart from the box; `checked` is what
2929 /// says a box is there at all, so only its width is being measured here.
2930 fn nesting_prefix_at(&mut self, off: usize) -> String {
2931 let cont = self.continuation_prefix_at(off);
2932 let Some(item) = self.innermost_list_item(off) else {
2933 return cont;
2934 };
2935 if item.checked.is_none() {
2936 return cont;
2937 }
2938 let box_width = item
2939 .marker_span
2940 .and_then(|m| self.source.get(m))
2941 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2942 .unwrap_or(0);
2943 // The trailing columns are the ones the item's own marker contributed,
2944 // so trimming from the end leaves any quote prefix standing.
2945 cont[..cont.len().saturating_sub(box_width)].to_string()
2946 }
2947
2948 /// Where the line of the item *containing* the item at `off` begins — the
2949 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2950 /// parent contributed. The quote prefix alone for a top-level item, which
2951 /// has no level left to give.
2952 fn outdent_prefix_at(&mut self, off: usize) -> String {
2953 let items: Vec<usize> = self
2954 .editor
2955 .document()
2956 .and_then(|mut d| d.ancestors_at_caret(off))
2957 .map(|c| {
2958 c.into_iter()
2959 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2960 .map(|m| m.span.start)
2961 .collect()
2962 })
2963 .unwrap_or_default();
2964 // The second-innermost item is the parent; its own line's indent is the
2965 // target. `list_marker_on_line` gives that line's prefix directly.
2966 let parent = items.len().checked_sub(2).map(|i| items[i]);
2967 match parent.and_then(|p| self.list_marker_on_line(p)) {
2968 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2969 None => self.quote_prefix_at(off),
2970 }
2971 }
2972
2973 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2974 /// inside one, `"> > "` inside two.
2975 ///
2976 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2977 /// the `>` and the space after it are twig's spelling rather than leaf's.
2978 /// The whole line prefix can't answer this: it also carries the indent of
2979 /// whatever the quote holds, which a blank separator line must *not* repeat.
2980 fn quote_prefix_at(&mut self, off: usize) -> String {
2981 let Ok(chain) = self
2982 .editor
2983 .document()
2984 .and_then(|mut d| d.ancestors_at_caret(off))
2985 else {
2986 return String::new();
2987 };
2988 let quotes: Vec<usize> = chain
2989 .iter()
2990 .filter(|m| m.kind == Kind::BlockQuote)
2991 .map(|m| m.node_id as usize)
2992 .collect();
2993 let Ok(nodes) = self.editor.nodes() else {
2994 return String::new();
2995 };
2996 quotes
2997 .iter()
2998 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2999 .filter_map(|s| self.source.get(s))
3000 .collect()
3001 }
3002
3003 /// Whether the item at `off` sits inside another one — the test Backspace
3004 /// uses to choose between outdenting and dropping the marker.
3005 ///
3006 /// Counted from the AST rather than from the line's leading whitespace,
3007 /// which is indentation in Markdown and, in Djot, may be nothing at all.
3008 fn item_is_nested(&mut self, off: usize) -> bool {
3009 self.editor
3010 .document()
3011 .and_then(|mut d| d.ancestors_at_caret(off))
3012 .map(|c| {
3013 c.into_iter()
3014 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3015 .count()
3016 > 1
3017 })
3018 .unwrap_or(false)
3019 }
3020
3021 /// The innermost list item containing `probe`, under twig's **caret**
3022 /// containment rule — a block's end is inside it.
3023 ///
3024 /// Half-open containment can't answer this. An empty item's span is exactly
3025 /// its marker, so the caret sitting after `- ` is one past the end and the
3026 /// item it is plainly in tests as out of reach; that is the shape
3027 /// double-Enter has to recognise to leave the list.
3028 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3029 let chain = self
3030 .editor
3031 .document()
3032 .and_then(|mut d| d.ancestors_at_caret(probe))
3033 .ok()?;
3034 let id = chain
3035 .iter()
3036 .rev()
3037 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3038 .node_id as usize;
3039 self.editor.nodes().ok()?.get(id).cloned()
3040 }
3041
3042 /// The list marker opening `off`'s line, per twig — `None` when that line
3043 /// opens no list item.
3044 ///
3045 /// [`Document::line_prefix`] is the whole hidden run from the line start:
3046 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
3047 /// and it is `None` on a *continuation* line, which opens nothing. That last
3048 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
3049 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3050 /// a paragraph and ` - b` is literal text — identical bytes, and only the
3051 /// parser knows which document it is looking at.
3052 ///
3053 /// The item's own marker is separated out via its
3054 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3055 /// the containers around it contribute and what the item does.
3056 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3057 let off = off.min(self.source.len());
3058 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3059 // The prefix belongs to a list only when an item's marker closes it —
3060 // a heading's `# ` or a bare quote's `> ` is a prefix too.
3061 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3062 let marker = item.marker_span.clone()?;
3063 if marker.end != prefix.end {
3064 return None;
3065 }
3066 Some(ListMarker {
3067 line_start: prefix.start,
3068 marker_start: marker.start,
3069 text: self.source.get(prefix)?.to_string(),
3070 })
3071 }
3072
3073 /// Whether the list item on `line_start`'s line is the **first item** of its
3074 /// list — the one Tab must not nest, because nesting needs a preceding
3075 /// sibling to become the new parent and a first item has none. `false` for a
3076 /// line that isn't a list item, and for an item with a sibling above it (the
3077 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3078 /// the same in a setext underline that opens no list at all.
3079 fn first_item_of_list(&mut self, line_start: usize) -> bool {
3080 let Some(marker) = self.list_marker_on_line(line_start) else {
3081 return false;
3082 };
3083 // Probe just inside the marker, where the item's own node is in reach —
3084 // the marker offset itself can resolve to the enclosing list, not the
3085 // `list_item`, whose span starts at the marker.
3086 let probe = marker.content_start().min(self.source.len());
3087 let Some(item) = self.innermost_list_item(probe) else {
3088 return false;
3089 };
3090 let Ok(nodes) = self.editor.nodes() else {
3091 return false;
3092 };
3093 match item.parent {
3094 // First when the parent list opens with this very item.
3095 Some(pid) => nodes
3096 .get(pid.0 as usize)
3097 .is_some_and(|p| p.first_child == Some(item.id)),
3098 // A parentless item is trivially the first (and only) one.
3099 None => true,
3100 }
3101 }
3102
3103 pub fn backspace(&mut self) {
3104 if let Some((s, e)) = self.selection() {
3105 self.splice(s, e, "", EditKind::Other);
3106 return;
3107 }
3108 // WYSIWYG: Backspace at the very start of a list item's content is a
3109 // structural key, not a character delete — it walks the "un-indent, then
3110 // un-list" ladder every list editor gives that keystroke (outdent a
3111 // nested item, strip a top-level one's marker to a paragraph). In source
3112 // view the `- ` is visible text the user is deleting a byte of, so it
3113 // keeps its literal meaning there, like Enter does.
3114 if self.view != View::Source && self.backspace_list_start() {
3115 return;
3116 }
3117 // WYSIWYG: and the same at the start of a heading's content — the `# `
3118 // there is markup the rich view hides, not text the user typed.
3119 if self.view != View::Source && self.backspace_heading_start() {
3120 return;
3121 }
3122 // WYSIWYG: and at the start of a block whose presentation is spelled
3123 // as hidden markup before it — djot's `{.center}` line, Markdown's
3124 // `<div class="center">` — Backspace takes that markup, the way it
3125 // takes a heading's `#`, rather than a byte out of it.
3126 if self.view != View::Source && self.backspace_attributed_block_start() {
3127 return;
3128 }
3129 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3130 // the markup apart under a caret that cannot see it — see
3131 // `delete_around_block_media`.
3132 if self.view != View::Source && self.delete_around_block_media(false) {
3133 return;
3134 }
3135 // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3136 // cell rather than taking the byte behind the caret — the row's closing
3137 // `|`, which the rich view never drew, so the key would have looked like
3138 // it did nothing. The stop before is the last cell's end.
3139 if self.view != View::Source && self.backspace_at_table_end() {
3140 return;
3141 }
3142 // WYSIWYG: at the start of a block's content, the byte behind the caret
3143 // is a block boundary, and Backspace over one is a join — twig's, so
3144 // that what a join is in each format is not this file's to know. After
3145 // the picture and table cases, which are block starts with their own
3146 // answers.
3147 if self.view != View::Source && self.backspace_joins_block() {
3148 return;
3149 }
3150 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3151 // stop, not a single newline. On a line with no text of its own, the byte
3152 // before the caret is a `\n` that spells part of a block boundary — the gap
3153 // between two blocks, drawn but never a caret home. Removing just it strands
3154 // the caret in that gap and leaves an odd blank line the eye reads as one
3155 // separator but the caret can't land on: the "extra newline" left behind
3156 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3157 // Deleting to the previous stop instead collapses the whole break at once,
3158 // landing the caret at the end of the block above. Two blank lines in a row
3159 // are one stop apart, so this still removes exactly one — the lone-Enter /
3160 // lone-Backspace symmetry the empty-line case is built on is untouched.
3161 if self.view != View::Source
3162 && self.caret > self.caret_floor()
3163 && self.caret_on_blank_line()
3164 && let Some(stop) = self.vmap.stop_before(self.caret)
3165 {
3166 let stop = stop.max(self.caret_floor());
3167 if stop < self.caret {
3168 if self.source[stop..self.caret].trim().is_empty() {
3169 self.splice(stop, self.caret, "", EditKind::Delete);
3170 } else {
3171 // Hidden markup stands between the stop and the caret — a
3172 // `</div>`, a comment, a link reference definition — and
3173 // collapsing to the stop would delete it. Take the blank
3174 // line alone, with the newline that opened it, and land
3175 // the caret where the collapse would have.
3176 self.delete_blank_line_to(stop);
3177 }
3178 return;
3179 }
3180 }
3181 if self.caret > self.caret_floor() {
3182 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3183 // takes the whole tag — a single-byte step would leave a broken `<br`
3184 // showing in the cell. Rich view only (source view edits the literal).
3185 if self.view != View::Source
3186 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3187 {
3188 let start = start.max(self.caret_floor());
3189 if start < end {
3190 self.splice(start, end, "", EditKind::Delete);
3191 return;
3192 }
3193 }
3194 // Aim the delete at the character the writer can *see* behind the
3195 // caret, never at a delimiter the rich view drew nothing for. Two
3196 // steps, and either can apply: from the far side of a run's closing
3197 // `**` step back into the run (the caret is drawn at the end of its
3198 // word), and at the start of a run's text step out past its opening
3199 // `**` to the character in front of it, leaving the run standing.
3200 // Without them a plain Backspace unspells the phrase it is editing
3201 // and leaves a literal asterisk on screen.
3202 let end = if self.view == View::Source {
3203 self.caret
3204 } else {
3205 let inside = self.step_inside_close_delims(self.caret);
3206 // An attributed span with no text — `<span …></span>` as the
3207 // file was written — is hidden markup around nothing, and a
3208 // byte-step here would take its `>`. Backspace takes the span
3209 // whole, with the character before it: the character the key
3210 // looks aimed at, since the span draws nothing.
3211 if let Some(span) = self.run_span_of_content(inside..inside) {
3212 let from = if self.source[..span.start].ends_with('\n') {
3213 span.start
3214 } else {
3215 prev_boundary(&self.source, span.start)
3216 };
3217 let from = from.max(self.caret_floor());
3218 self.splice(from, span.end, "", EditKind::Delete);
3219 return;
3220 }
3221 self.skip_leading_open_delims(inside)
3222 .max(self.caret_floor())
3223 };
3224 // Never delete back across the floor — that would eat hidden
3225 // frontmatter the WYSIWYG caret can't even see.
3226 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3227 // Take a hidden escape backslash with the char it escapes: the rich
3228 // view draws `\*` as a single `*`, so Backspace over it must delete
3229 // both bytes, never strand the `\` as a lone visible backslash (the
3230 // mirror of the Hidden-mode typing that wrote the escape). Source view
3231 // shows the `\`, so there it is an ordinary character.
3232 if self.view != View::Source
3233 && prev > self.caret_floor()
3234 && self.is_hidden_escape(prev - 1)
3235 {
3236 prev -= 1;
3237 }
3238 // The delete that takes the last of a span's text takes the span
3239 // with it, in the same edit: `<span …>i</span>` losing its `i`
3240 // would leave an empty span the map has no stop inside, so the
3241 // caret would draw at the next stop — a line away — until a
3242 // further key removed the span. Landing on the span's start is
3243 // where the letter was.
3244 if self.view != View::Source
3245 && let Some(span) = self.run_span_of_content(prev..end)
3246 {
3247 self.splice(span.start, span.end, "", EditKind::Delete);
3248 return;
3249 }
3250 // And the same for a block: the letter that was all of a centred
3251 // paragraph's text goes with the `<div>` around it, or the `{…}`
3252 // line above it, leaving a plain blank line where the letter was.
3253 // A paragraph with no text is no block, so the markup would stand
3254 // around nothing, the map would give it no caret home, and the
3255 // next key would take the tag apart.
3256 if self.view != View::Source
3257 && let Some(block) = self.attributed_block_of_content(prev..end)
3258 {
3259 self.splice(block.start, block.end, "", EditKind::Delete);
3260 return;
3261 }
3262 if prev < end {
3263 self.splice(prev, end, "", EditKind::Delete);
3264 }
3265 }
3266 }
3267
3268 /// Remove the blank line the caret is on — its own newline and the one
3269 /// that ended the line before it — and put the caret on `stop`, the caret
3270 /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3271 /// blank line that hidden markup separates from the block above: the
3272 /// navigable blank row after a `</div>` is always one of at least three
3273 /// newlines under the tag (the drawn separators either side of it), so
3274 /// taking two leaves the blank line the tag needs under it.
3275 fn delete_blank_line_to(&mut self, stop: usize) {
3276 let caret = self.caret;
3277 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3278 let line_end = self.source[caret..]
3279 .find('\n')
3280 .map_or(self.source.len(), |i| caret + i);
3281 let from = line_start.saturating_sub(1).max(stop);
3282 let to = (line_end + 1).min(self.source.len());
3283 self.splice(from, to, "", EditKind::Delete);
3284 self.caret = stop;
3285 self.anchor = None;
3286 self.goal_col = None;
3287 self.record_caret();
3288 }
3289
3290 /// Move the caret to the stop before it and consume the key — what
3291 /// Backspace does where the byte behind the caret is hidden markup it
3292 /// has no structural answer for, rather than take that markup apart.
3293 fn step_back_to_stop(&mut self) {
3294 // The map answers about offsets, so it has to be this revision's — see
3295 // `open_paragraph_at_block_edge`.
3296 self.rebuild_map();
3297 if let Some(off) = self
3298 .vmap
3299 .stop_before(self.caret)
3300 .filter(|&o| o >= self.caret_floor())
3301 {
3302 self.caret = off;
3303 self.anchor = None;
3304 self.goal_col = None;
3305 }
3306 }
3307
3308 /// Backspace's presentation behaviour: with the caret exactly at the start
3309 /// of a block's content, and that block's attributes spelled as hidden
3310 /// markup before it, strip the attributes. The peer of
3311 /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3312 /// reasoning: the `{.center}` line above a djot block and the
3313 /// `<div class="center">` around a Markdown one are what the byte behind
3314 /// the caret belongs to, and the rich view draws neither. The ordinary
3315 /// delete took the newline out of `{.center}\nhello` and left
3316 /// `{.center}hello` — the attribute line fused onto the text as prose —
3317 /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3318 ///
3319 /// The whole attribute set goes, the way the whole `#` marker does — the
3320 /// press is over the line that spells it, not over one key of it — and
3321 /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3322 /// djot line and unwraps the Markdown div. Where the block is the first of
3323 /// several in a div, twig has no sole child to unwrap and answers with a
3324 /// no-op, so the caret steps back to the stop before instead, as it does
3325 /// at a table's end. A later child of the div has an ordinary paragraph
3326 /// above it and is not this rule's.
3327 ///
3328 /// Returns whether it acted; `false` leaves Backspace its character delete.
3329 fn backspace_attributed_block_start(&mut self) -> bool {
3330 if !matches!(self.format, Format::Markdown | Format::Djot) {
3331 return false;
3332 }
3333 let caret = self.caret;
3334 let nodes = self.nodes();
3335 let Some(block) = nodes
3336 .iter()
3337 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3338 .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3339 else {
3340 return false;
3341 };
3342 match self.format {
3343 Format::Djot => {
3344 // twig records where the `{…}` block was written, so this is
3345 // the parser's own answer and not a scan for a `{` above the
3346 // block; `None` (a synthesized or merged set) is not a line
3347 // the caret is standing after.
3348 let spelled = self
3349 .editor
3350 .document()
3351 .ok()
3352 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3353 .is_some_and(|s| s.end <= caret);
3354 if !spelled {
3355 return false;
3356 }
3357 }
3358 _ => {
3359 let Some(div) = block
3360 .parent
3361 .and_then(|p| nodes.iter().find(|n| n.id == p))
3362 .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3363 else {
3364 return false;
3365 };
3366 let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3367 if kids.clone().any(|k| k.span.start < block.span.start) {
3368 return false;
3369 }
3370 if kids.nth(1).is_some() {
3371 self.step_back_to_stop();
3372 return true;
3373 }
3374 }
3375 }
3376 self.write_block_attrs("block attributes", Vec::new());
3377 true
3378 }
3379
3380 /// Backspace at the start of a block's content: join the block into the
3381 /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3382 /// the split Enter makes, spelled the format's way. Two paragraphs join
3383 /// on a soft break; a paragraph under a marker heading joins onto the
3384 /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3385 /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3386 /// taken as one; a quote's or an item's continuation prefix is written.
3387 /// The joined text takes the block above's presentation and containers,
3388 /// which is the rule every editor with a centred paragraph follows.
3389 ///
3390 /// Leaf used to join by deleting the one newline behind the caret, which
3391 /// is the right bytes for two Markdown paragraphs and nothing else: under
3392 /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3393 /// after a div it took the newline under the hidden `</div>`, which drew
3394 /// nothing different and took the tag apart on the next press. What a
3395 /// join is in each format is twig's to know, and now it does.
3396 ///
3397 /// Where twig refuses — the block above is a code block, a table or a
3398 /// rule with no text to join into, or the caret's block would have to
3399 /// leave a div that holds more after it — the caret steps back to the
3400 /// stop before instead, as it does at a table's end: the key moves the
3401 /// caret and takes no markup apart. Where nothing precedes the block, or
3402 /// the format cannot join at all, Backspace keeps its character delete.
3403 ///
3404 /// Returns whether it acted.
3405 fn backspace_joins_block(&mut self) -> bool {
3406 let caret = self.caret;
3407 if caret <= self.caret_floor() {
3408 return false;
3409 }
3410 let Some(text) = self.text_block_opening_at(caret) else {
3411 return false;
3412 };
3413 match self.join_blocks(caret) {
3414 Ok(change) => {
3415 // The caret keeps its place at the start of the text it stood
3416 // on, wherever the join put that text — after a soft break,
3417 // a space, or a quote's prefix. Found by the bytes, as
3418 // `block_content_in` finds a re-spelled block.
3419 let region = &self.source[change.new.clone()];
3420 let at = region
3421 .find(&text)
3422 .map_or(change.new.start, |i| change.new.start + i);
3423 self.land_after_join(at);
3424 true
3425 }
3426 Err(twig::Error::NotEditable) => {
3427 self.step_back_to_stop();
3428 true
3429 }
3430 Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3431 Err(e) => {
3432 self.status = Some(format!("join: {e}"));
3433 true
3434 }
3435 }
3436 }
3437
3438 /// Delete at the end of a block's content: join the block after it into
3439 /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3440 /// mirror, and the same twig gesture aimed at the next block. The caret
3441 /// stays where it was, which is where the joined text now begins after
3442 /// the separator. Where twig refuses, the caret steps forward to the next
3443 /// stop instead; where no block follows, Delete keeps its character
3444 /// delete.
3445 fn delete_forward_joins_block(&mut self) -> bool {
3446 let caret = self.caret;
3447 let at_end = self
3448 .nodes()
3449 .iter()
3450 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3451 .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3452 if !at_end {
3453 return false;
3454 }
3455 // The next stop, across a line end, in a block: what Delete at a
3456 // block's end points at. On the same line it is a hidden delimiter's
3457 // far side, which the ordinary delete handles; on a blank line it is
3458 // the empty paragraph the byte delete has always closed.
3459 self.rebuild_map();
3460 let Some(stop) = self.vmap.stop_after(caret) else {
3461 return false;
3462 };
3463 if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3464 return false;
3465 }
3466 match self.join_blocks(stop) {
3467 Ok(change) => {
3468 self.land_after_join(change.old.start);
3469 true
3470 }
3471 Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3472 self.caret = stop;
3473 self.anchor = None;
3474 self.goal_col = None;
3475 true
3476 }
3477 Err(twig::Error::UnsupportedFormat) => false,
3478 Err(e) => {
3479 self.status = Some(format!("join: {e}"));
3480 true
3481 }
3482 }
3483 }
3484
3485 /// The content bytes of the paragraph or heading whose content opens
3486 /// exactly at `off` — the block a Backspace there is at the start of.
3487 fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3488 self.nodes()
3489 .into_iter()
3490 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3491 .find_map(|n| {
3492 let c = n.content_span?;
3493 (c.start == off).then(|| self.source[c].to_string())
3494 })
3495 }
3496
3497 /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3498 /// plumbing every structural gesture has; the caret is the caller's to
3499 /// place from the change, via [`land_after_join`](Self::land_after_join).
3500 fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3501 if self.read_only {
3502 return Err(twig::Error::NotEditable);
3503 }
3504 self.record_caret();
3505 let change = self.editor.join_blocks(offset)?;
3506 self.last_edit_kind = None; // structural edit is its own undo step
3507 self.refresh();
3508 Ok(change)
3509 }
3510
3511 /// Finish a join: the caret at `at`, no selection, the map this
3512 /// revision's before the clamp — see `write_block_attrs` for why.
3513 fn land_after_join(&mut self, at: usize) {
3514 self.caret = at;
3515 self.anchor = None;
3516 self.goal_col = None;
3517 self.dirty = self.source != self.clean_source;
3518 self.status = None;
3519 self.rebuild_map();
3520 self.clamp_caret();
3521 self.record_caret();
3522 }
3523
3524 // ── moving a block ─────────────────────────────────────────────────────────
3525
3526 /// The block a move picks up at `offset` — the same one
3527 /// [`select_block_at`](Self::select_block_at) selects (the deepest node
3528 /// that is neither inline nor a multi-block container), widened to the
3529 /// list item when it is the item's first block, because that is what
3530 /// twig's `move_block` moves: a bullet's text is the bullet, and dragging
3531 /// it takes the item and everything under it. A block later in an item's
3532 /// tail moves alone. `None` on a blank line, and past the source.
3533 fn movable_block_at(&mut self, offset: usize) -> Option<FlatNode> {
3534 let off = offset.min(self.source.len());
3535 let chain = self.editor.ancestors_at(off).ok()?;
3536 let block = chain
3537 .iter()
3538 .rev()
3539 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
3540 let nodes = self.nodes();
3541 let block = nodes.get(block.node_id as usize)?.clone();
3542 let item = block
3543 .parent
3544 .and_then(|p| nodes.get(p.0 as usize))
3545 .filter(|p| matches!(p.kind, Kind::ListItem | Kind::TaskListItem))
3546 .filter(|p| p.first_child == Some(block.id));
3547 Some(item.cloned().unwrap_or(block))
3548 }
3549
3550 /// The source range of the block a move would pick up at `offset` — for
3551 /// the outline of the block being carried, without moving the caret. The
3552 /// range [`select_block_at`](Self::select_block_at) would select, except
3553 /// that it is the whole item for a bullet's text, as
3554 /// [`move_block`](Self::move_block) is.
3555 pub fn block_range_at(&mut self, offset: usize) -> Option<Range<usize>> {
3556 self.movable_block_at(offset).map(|b| b.span)
3557 }
3558
3559 /// Move the block at `from` to the boundary `to` — twig's `move_block`,
3560 /// with the undo plumbing every structural gesture has and the caret
3561 /// riding the block to its new place. `from` is any offset inside the
3562 /// block (the one [`block_range_at`](Self::block_range_at) finds); `to`
3563 /// is a position *between* blocks — a block's first byte lands before it,
3564 /// its last after it, a blank line is itself a boundary, and the source's
3565 /// length is the document's end. The block takes the line prefixes of
3566 /// the container the boundary is inside — a `> ` on the way into a quote,
3567 /// none on the way out — and the blank lines a person would have typed
3568 /// are written and removed; twig spells all of that.
3569 ///
3570 /// A move that would move nothing — `to` inside the block, or on the
3571 /// boundary it already sits on — is a quiet no-op rather than an error,
3572 /// since it is what a block dropped back where it was asks for. Any other
3573 /// refusal reaches the status line: a `to` interior to a fence or a table,
3574 /// or a format with no blocks a caret could name.
3575 ///
3576 /// In WYSIWYG the frontmatter is hidden, and a boundary above it is not
3577 /// one a person can see: `to` is raised to the first rendered offset.
3578 ///
3579 /// `true` when the document changed. A move twig accepts that rewrites
3580 /// nothing — a one-item list sent past a paragraph is still a one-item
3581 /// list past that paragraph — is taken back rather than left as an undo
3582 /// step with no difference in it, and is `false` too.
3583 pub fn move_block(&mut self, from: usize, to: usize) -> bool {
3584 if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3585 return false;
3586 }
3587 let len = self.source.len();
3588 let from = from.min(len);
3589 let to = to.clamp(self.caret_floor().min(len), len);
3590 let Some(block) = self.movable_block_at(from) else {
3591 self.status = Some("move block: no block here".into());
3592 return false;
3593 };
3594 // Where the caret stands in the block, as (line within the block,
3595 // bytes back from that line's end) — the shape that survives a
3596 // prefix being added or stripped on the way through a container.
3597 let caret = self.caret.clamp(block.span.start, block.span.end);
3598 let block_line = self.source[block.span.start..caret].matches('\n').count();
3599 let line_end = self.source[caret..block.span.end]
3600 .find('\n')
3601 .map_or(block.span.end, |i| caret + i);
3602 let tail = line_end - caret;
3603 let block_lines = self.source[block.span.start..block.span.end]
3604 .matches('\n')
3605 .count()
3606 + 1;
3607 let upward = to <= block.span.start;
3608 self.record_caret();
3609 match self.editor.move_block(from, to) {
3610 Ok(_) if self.editor.source_str().ok().as_deref() == Some(self.source.as_str()) => {
3611 let _ = self.editor.undo();
3612 self.status = None;
3613 false
3614 }
3615 Ok(change) => {
3616 self.last_edit_kind = None; // structural edit is its own undo step
3617 self.refresh();
3618 let at = self.moved_block_caret(&change.new, upward, block_lines, block_line, tail);
3619 self.caret = at;
3620 self.anchor = None;
3621 self.goal_col = None;
3622 self.dirty = self.source != self.clean_source;
3623 self.status = None;
3624 self.rebuild_map();
3625 self.clamp_caret();
3626 self.record_caret();
3627 true
3628 }
3629 // Nothing to move: the block dropped back onto its own boundary.
3630 Err(twig::Error::InvalidArgument) => {
3631 self.status = None;
3632 false
3633 }
3634 Err(e) => {
3635 self.status = Some(format!("move block: {e}"));
3636 false
3637 }
3638 }
3639 }
3640
3641 /// The caret's place in the rewritten region after a move: the moved
3642 /// block's lines open the region when it went up (below the blank line it
3643 /// was dropped on, when it was) and close it (above the separator twig
3644 /// wrote after it) when it went down, and within them the
3645 /// caret keeps its line and its distance from that line's end. Clamped
3646 /// into the region rather than trusted, since a block can lose a line on
3647 /// the way — a quote it was the only content of goes with it.
3648 fn moved_block_caret(
3649 &self,
3650 new: &Range<usize>,
3651 upward: bool,
3652 block_lines: usize,
3653 block_line: usize,
3654 tail: usize,
3655 ) -> usize {
3656 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3657 let mut lines: Vec<(usize, usize)> = Vec::new();
3658 let mut start = 0;
3659 loop {
3660 match region[start..].find('\n') {
3661 Some(i) => {
3662 lines.push((start, start + i));
3663 start += i + 1;
3664 }
3665 None => {
3666 lines.push((start, region.len()));
3667 break;
3668 }
3669 }
3670 }
3671 // A separator line is blank, or a quote's bare `>`; the region can
3672 // open with one (the blank line the block was dropped on) or close
3673 // with one (the one twig wrote after it).
3674 let separator = |&(s, e): &(usize, usize)| {
3675 region[s..e]
3676 .trim()
3677 .trim_start_matches('>')
3678 .trim()
3679 .is_empty()
3680 };
3681 let first = if upward {
3682 lines.iter().position(|l| !separator(l)).unwrap_or(0)
3683 } else {
3684 let filled = lines
3685 .iter()
3686 .rposition(|l| !separator(l))
3687 .map_or(0, |i| i + 1);
3688 filled.saturating_sub(block_lines)
3689 };
3690 let (s, e) = lines[(first + block_line).min(lines.len() - 1)];
3691 new.start + e.saturating_sub(tail).max(s)
3692 }
3693
3694 /// Move the caret's block one place up — Alt+↑: above the block before it,
3695 /// and out of its container, to just above it, when it is the first block
3696 /// there. Nothing above the document's first block, and nothing to do for
3697 /// a caret on a blank line; both say so in the status line.
3698 pub fn move_block_up(&mut self) {
3699 self.move_block_by(true);
3700 }
3701
3702 /// Move the caret's block one place down — Alt+↓, the mirror of
3703 /// [`move_block_up`](Self::move_block_up): below the block after it, and
3704 /// out of its container when it is the last block there.
3705 pub fn move_block_down(&mut self) {
3706 self.move_block_by(false);
3707 }
3708
3709 fn move_block_by(&mut self, up: bool) {
3710 if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3711 return;
3712 }
3713 let Some(from) = self.block_offset_for_caret() else {
3714 self.status = Some("move block: no block here".into());
3715 return;
3716 };
3717 let Some(block) = self.movable_block_at(from) else {
3718 self.status = Some("move block: no block here".into());
3719 return;
3720 };
3721 let nodes = self.nodes();
3722 let to = if up {
3723 self.boundary_above(&nodes, &block)
3724 } else {
3725 self.boundary_below(&nodes, &block)
3726 };
3727 if to.is_none_or(|to| !self.move_block(from, to)) && self.status.is_none() {
3728 self.status = Some(if up {
3729 "move block: nothing above".into()
3730 } else {
3731 "move block: nothing below".into()
3732 });
3733 }
3734 }
3735
3736 /// The boundary one step above `block`: before its previous sibling, or
3737 /// — for the first block in a container — before the container itself.
3738 /// `None` for the document's first block.
3739 fn boundary_above(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3740 let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3741 let previous = nodes
3742 .iter()
3743 .filter(|n| n.parent == Some(parent.id))
3744 .take_while(|n| n.id != block.id)
3745 .last();
3746 match previous {
3747 Some(p) => self.before(p),
3748 None if parent.kind == Kind::Doc => None,
3749 // An item leaving a nested list upward goes before the item
3750 // holding that list, as an item of the outer one; the list's own
3751 // first byte is inside the holding item's tail.
3752 None if matches!(
3753 parent.kind,
3754 Kind::BulletList | Kind::OrderedList | Kind::TaskList
3755 ) =>
3756 {
3757 match parent.parent.and_then(|p| nodes.get(p.0 as usize)) {
3758 Some(item) if matches!(item.kind, Kind::ListItem | Kind::TaskListItem) => {
3759 self.before(item)
3760 }
3761 _ => self.before(parent),
3762 }
3763 }
3764 None => self.before(parent),
3765 }
3766 }
3767
3768 /// The boundary one step below `block`: after its next sibling, or — for
3769 /// the last block in a container — just past the container, at its
3770 /// parent's level ([`exit_below`](Self::exit_below)). `None` for the
3771 /// document's last block.
3772 fn boundary_below(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3773 let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3774 if let Some(n) = block.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
3775 return Some(self.after(n));
3776 }
3777 match parent.kind {
3778 Kind::Doc => None,
3779 _ => self.exit_below(nodes, parent),
3780 }
3781 }
3782
3783 /// The boundary just past `container` at its parent's level: before its
3784 /// next sibling, or past its parent when it is the last thing there —
3785 /// the document's end at the top. Leaving a list item is the exception
3786 /// that keeps a block in the list: the next item's tail, which is what
3787 /// [`after`](Self::after) an item names.
3788 fn exit_below(&self, nodes: &[FlatNode], container: &FlatNode) -> Option<usize> {
3789 let item = matches!(container.kind, Kind::ListItem | Kind::TaskListItem);
3790 match container.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
3791 Some(n) if item => Some(self.after(n)),
3792 Some(n) => self.before(n),
3793 None => {
3794 let parent = container.parent.and_then(|p| nodes.get(p.0 as usize))?;
3795 match parent.kind {
3796 Kind::Doc => Some(self.source.len()),
3797 _ => self.exit_below(nodes, parent),
3798 }
3799 }
3800 }
3801 }
3802
3803 /// The boundary before `node`: its first byte. twig reads a container's
3804 /// opening as before it — a quote's marker, a fence's first byte — so the
3805 /// span's start is the boundary at the parent's level for every kind.
3806 fn before(&self, node: &FlatNode) -> Option<usize> {
3807 Some(node.span.start)
3808 }
3809
3810 /// The boundary after `node`: its own end, which for a container proper
3811 /// (a quote, a list, a fenced or tagged container) is the end of its last
3812 /// line — after its last block, still inside it, where a block arriving
3813 /// from outside joins it. Past the container is the next line's start: a
3814 /// blank line, the next block, or the document's end.
3815 fn after(&self, node: &FlatNode) -> usize {
3816 let container = is_block_container(&node.kind)
3817 && !matches!(node.kind, Kind::ListItem | Kind::TaskListItem);
3818 let end = node.span.end.min(self.source.len());
3819 if container && self.source.as_bytes().get(end) == Some(&b'\n') {
3820 end + 1
3821 } else {
3822 end
3823 }
3824 }
3825
3826 /// Where a block dragged over rendered row `row` would land — the
3827 /// boundary before the row's block when the row is in its upper half, the
3828 /// boundary after it otherwise, and the document's end for a row below
3829 /// everything. `None` for a row that holds no block (a decoration row is
3830 /// resolved to the block under it, so this is a table's bottom rule with
3831 /// nothing after it, or a map with no rows). The frontend draws its
3832 /// indicator above [`DropTarget::row`] and hands
3833 /// [`DropTarget::offset`] to [`move_block`](Self::move_block).
3834 ///
3835 /// The block is the deepest one, not the widened item: a drop on a
3836 /// bullet's text lands before or after that bullet, and its nested
3837 /// children — rows of their own — answer for themselves.
3838 pub fn drop_target_at(&mut self, row: usize) -> Option<DropTarget> {
3839 let rows = self.vmap.rows.len();
3840 if row >= rows {
3841 return Some(DropTarget {
3842 offset: self.source.len(),
3843 row: rows,
3844 });
3845 }
3846 // A gap or a rule is not a block; the block under it is the one meant.
3847 let row = (row..rows).find(|&r| self.vmap.row_is_navigable(r))?;
3848 let start = self.vmap.row_start(row)?;
3849 let chain = self.editor.ancestors_at(start).ok()?;
3850 let block = chain
3851 .iter()
3852 .rev()
3853 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
3854 let span = block.span.clone();
3855 let (first, last) = self.vmap.row_range_for(span.clone());
3856 if row <= usize::midpoint(first, last) {
3857 Some(DropTarget {
3858 offset: span.start,
3859 row: first,
3860 })
3861 } else {
3862 Some(DropTarget {
3863 offset: span.end.min(self.source.len()),
3864 row: last + 1,
3865 })
3866 }
3867 }
3868
3869 /// Backspace at a table's trailing stop: move onto the stop before it (the
3870 /// last cell's end) and consume the key. `false` anywhere else. See
3871 /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3872 /// not one to delete.
3873 fn backspace_at_table_end(&mut self) -> bool {
3874 // The map answers about offsets, so it has to be this revision's — see
3875 // `open_paragraph_at_block_edge`.
3876 self.rebuild_map();
3877 if !self.vmap.table_end_stop(self.caret) {
3878 return false;
3879 }
3880 if let Some(off) = self
3881 .vmap
3882 .stop_before(self.caret)
3883 .filter(|&o| o >= self.caret_floor())
3884 {
3885 self.caret = off;
3886 self.anchor = None;
3887 self.goal_col = None;
3888 }
3889 true
3890 }
3891
3892 /// Whether the caret's own source line holds nothing but whitespace — an
3893 /// empty paragraph, or the blank line a block boundary is spelled with. The
3894 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3895 /// no text of its own, so the newline before the caret belongs to the gap
3896 /// between blocks rather than to any word the caret is editing.
3897 fn caret_on_blank_line(&self) -> bool {
3898 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3899 let line_end = self.source[self.caret..]
3900 .find('\n')
3901 .map_or(self.source.len(), |i| self.caret + i);
3902 self.source[line_start..line_end].trim().is_empty()
3903 }
3904
3905 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3906 /// `edge` side — the byte range to delete whole. A table row is one source
3907 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3908 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3909 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3910 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3911 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3912 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
3913 /// apart — no ancestor walk needed. Rich view only; source view shows the
3914 /// literal tag and deletes it a byte at a time.
3915 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3916 let caret = self.caret;
3917 let nodes = self.nodes();
3918 let src = self.source.as_bytes();
3919 nodes
3920 .iter()
3921 .find(|n| {
3922 n.kind == Kind::HardBreak
3923 && n.span.start < n.span.end
3924 && src.get(n.span.start) == Some(&b'<')
3925 && match edge {
3926 BreakEdge::Backward => n.span.end == caret,
3927 BreakEdge::Forward => n.span.start == caret,
3928 }
3929 })
3930 .map(|n| (n.span.start, n.span.end))
3931 }
3932
3933 /// Whether the source byte at `off` is a backslash twig consumed as an escape
3934 /// (hidden in the rich view), as against a literal backslash (drawn). A
3935 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3936 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3937 /// round-trip needed.
3938 fn is_hidden_escape(&self, off: usize) -> bool {
3939 let b = self.source.as_bytes();
3940 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3941 }
3942
3943 /// Backspace's list behaviour: when the caret sits exactly at the start of a
3944 /// list item's content (right after its marker), outdent the item if it's
3945 /// nested, else strip the marker so it becomes a paragraph. Returns whether
3946 /// it acted — `false` leaves Backspace its ordinary character delete.
3947 fn backspace_list_start(&mut self) -> bool {
3948 let Some(marker) = self.list_marker_on_line(self.caret) else {
3949 return false;
3950 };
3951 // Only right after the marker. That the line opens a real item is
3952 // already settled: `list_marker_on_line` answers from the tree.
3953 if self.caret != marker.content_start() {
3954 return false;
3955 }
3956 if self.item_is_nested(marker.marker_start) {
3957 // Nested: give back one level, keeping the marker and carrying the
3958 // caret with it.
3959 self.outdent();
3960 } else {
3961 // Top level: drop the marker, leaving a paragraph, then renumber the
3962 // siblings the removed item was counted among. Only the marker goes —
3963 // a quote prefix in front of it still has a quote to hold up.
3964 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3965 self.renumber_here();
3966 }
3967 true
3968 }
3969
3970 /// Backspace's heading behaviour: with the caret exactly at the start of an
3971 /// ATX heading's content — right after the `#` marker the rich view hides —
3972 /// strip the marker so the line becomes a paragraph. The peer of
3973 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3974 /// reasoning: hidden block markup is structure, so the keystroke over it is
3975 /// structural.
3976 ///
3977 /// Without this the ordinary delete takes the space out of `# Title` and
3978 /// leaves `#Title`, which is no longer a heading at all — the hash the view
3979 /// had been hiding surfaces as literal text the user has to delete a second
3980 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3981 /// other end) goes with the marker for the same reason.
3982 ///
3983 /// Returns whether it acted; `false` leaves Backspace its character delete.
3984 fn backspace_heading_start(&mut self) -> bool {
3985 let caret = self.caret;
3986 // The heading whose content opens exactly at the caret. A bare `#` has no
3987 // content span at all — its content starts (and ends) where the line does.
3988 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3989 let (start, end) = match &n.content_span {
3990 Some(c) => (c.start, c.end),
3991 None => (n.span.end, n.span.end),
3992 };
3993 (n.kind == Kind::Heading && start == caret)
3994 .then(|| (n.span.clone(), end, n.marker_span.clone()))
3995 }) else {
3996 return false;
3997 };
3998 // twig reports the marker's own extent, so there is nothing to walk back
3999 // over and no `#` in this file. A setext heading has no marker — its
4000 // content opens the line — so it falls through to the ordinary delete,
4001 // as does anything else sitting at a content start.
4002 // `m.end == caret` is what excludes a setext heading, whose marker is the
4003 // underline *after* the content rather than a prefix before it.
4004 let Some(marker) = marker.filter(|m| m.end == caret) else {
4005 return false;
4006 };
4007 let start = marker.start;
4008 // A closing `#` sequence is hidden too, so it can't be left behind. Only
4009 // when the tail really is one: trailing spaces alone are nothing to strip.
4010 let tail = &self.source[content_end..span.end];
4011 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
4012 let kept = self.source[caret..content_end].to_string();
4013 self.splice(start, span.end, &kept, EditKind::Other);
4014 // The splice leaves the caret past the text it re-wrote; the caret
4015 // belongs where the content now starts, which is where it already was.
4016 self.caret = start;
4017 self.record_caret();
4018 } else {
4019 self.splice(start, caret, "", EditKind::Other);
4020 }
4021 true
4022 }
4023
4024 pub fn delete_forward(&mut self) {
4025 if let Some((s, e)) = self.selection() {
4026 self.splice(s, e, "", EditKind::Other);
4027 } else if self.caret < self.source.len() {
4028 // The mirror of Backspace's: forward-delete in front of a picture
4029 // would eat the `!` off its markup and leave a link where a photo was.
4030 if self.view != View::Source && self.delete_around_block_media(true) {
4031 return;
4032 }
4033 // And of Backspace's join: at the end of a block's content, Delete
4034 // joins the next block into this one.
4035 if self.view != View::Source && self.delete_forward_joins_block() {
4036 return;
4037 }
4038 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
4039 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
4040 // strands a broken `<br` in the cell.
4041 if self.view != View::Source
4042 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
4043 {
4044 self.splice(start, end, "", EditKind::Delete);
4045 return;
4046 }
4047 // The mirror of Backspace's two steps: from in front of a run's
4048 // opening `**` step into it, onto the first letter of its text, and
4049 // at the end of a run's text step out past its closing `**` to the
4050 // character beyond. Either way Delete takes the character it looks
4051 // like it is pointing at, and never a delimiter drawn as nothing.
4052 // The caret then settles back inside the run it was standing in —
4053 // see `settle_inside_close_delims`.
4054 let from = if self.view == View::Source {
4055 self.caret
4056 } else {
4057 let inside = self.step_inside_open_delims(self.caret);
4058 // The mirror of Backspace's empty-span rule: an attributed
4059 // span with no text goes whole, with the character after it.
4060 if let Some(span) = self.run_span_of_content(inside..inside) {
4061 let to = if self.source[span.end..].starts_with('\n') {
4062 span.end
4063 } else {
4064 next_boundary(&self.source, span.end)
4065 };
4066 self.splice(span.start, to, "", EditKind::Delete);
4067 return;
4068 }
4069 self.skip_trailing_close_delims(inside)
4070 };
4071 let next = next_boundary(&self.source, from);
4072 // And of its emptying rules: the span goes with its last letter,
4073 // and so does the block's div or `{…}` line.
4074 if self.view != View::Source
4075 && let Some(span) = self.run_span_of_content(from..next)
4076 {
4077 self.splice(span.start, span.end, "", EditKind::Delete);
4078 return;
4079 }
4080 if self.view != View::Source
4081 && let Some(block) = self.attributed_block_of_content(from..next)
4082 {
4083 self.splice(block.start, block.end, "", EditKind::Delete);
4084 return;
4085 }
4086 if from < next {
4087 self.splice(from, next, "", EditKind::Delete);
4088 }
4089 }
4090 }
4091
4092 /// Delete from the caret back to the start of the previous word (⌥⌫ /
4093 /// Ctrl+⌫). Deletes the selection instead when one is active.
4094 pub fn delete_word_back(&mut self) {
4095 if let Some((s, e)) = self.selection() {
4096 self.splice(s, e, "", EditKind::Other);
4097 } else {
4098 // A word back from just past a picture is a word *of its markup*, and
4099 // a word back from in front of one runs through the paragraph break
4100 // into the prose above — dissolving the picture either way. See
4101 // `delete_around_block_media`.
4102 if self.view != View::Source && self.delete_around_block_media(false) {
4103 return;
4104 }
4105 let start = self.word_left_from(self.caret).max(self.caret_floor());
4106 if start < self.caret {
4107 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4108 self.splice(s, e, "", EditKind::Delete);
4109 }
4110 }
4111 }
4112
4113 /// Delete from the caret forward to the end of the next word (⌥⌦ /
4114 /// Ctrl+Del). Deletes the selection instead when one is active.
4115 pub fn delete_word_forward(&mut self) {
4116 if let Some((s, e)) = self.selection() {
4117 self.splice(s, e, "", EditKind::Other);
4118 } else {
4119 // The mirror: a word forward from in front of a picture is its markup.
4120 if self.view != View::Source && self.delete_around_block_media(true) {
4121 return;
4122 }
4123 let end = self.word_right_from(self.caret);
4124 if end > self.caret {
4125 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4126 self.splice(s, e, "", EditKind::Delete);
4127 }
4128 }
4129 }
4130
4131 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
4132 /// selection instead when one is active, as every other delete here does.
4133 ///
4134 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
4135 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
4136 /// stops at the first character and this takes the indentation with it, the
4137 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
4138 /// leave an indent behind that nothing can then ask to delete, where a caret
4139 /// left at column 0 is one press of Home away from either.
4140 pub fn delete_to_line_start(&mut self) {
4141 if let Some((s, e)) = self.selection() {
4142 self.splice(s, e, "", EditKind::Other);
4143 return;
4144 }
4145 // Never back across the floor: hidden frontmatter isn't on this line, or
4146 // on any line the WYSIWYG caret can see.
4147 let (start, _) = self.line_span();
4148 let start = start.max(self.caret_floor());
4149 if start < self.caret {
4150 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4151 self.splice(s, e, "", EditKind::Delete);
4152 }
4153 }
4154
4155 /// Kill from the caret to the end of its line (^K). Deletes the selection
4156 /// instead when one is active.
4157 ///
4158 /// At the end of the line it does nothing, rather than pulling the line
4159 /// below up into this one. Joining has no meaning to give it in both views
4160 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
4161 /// there is nothing there to delete, while the newline a *source* line ends
4162 /// with is only half of the blank line that separates two paragraphs —
4163 /// deleting one leaves a soft break, which is not the join it looks like.
4164 /// The views agreeing is worth more than emacs' second press, and Delete is
4165 /// already the key that joins.
4166 pub fn delete_to_line_end(&mut self) {
4167 if let Some((s, e)) = self.selection() {
4168 self.splice(s, e, "", EditKind::Other);
4169 return;
4170 }
4171 let (_, end) = self.line_span();
4172 if end > self.caret {
4173 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4174 self.splice(s, e, "", EditKind::Delete);
4175 }
4176 }
4177
4178 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
4179 ///
4180 /// A glyph-space range covers what the user can see, which for `**bold**` is
4181 /// the word and never the delimiters around it — so deleting the word on its
4182 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
4183 /// word, and the styling was the word's; the two go together. Only the
4184 /// node's delimiters are taken, and those are hidden here anyway, so nothing
4185 /// visible outside the range is lost.
4186 ///
4187 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
4188 /// the strong, and only then is the strong empty too.
4189 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
4190 if self.view == View::Source {
4191 return (start, end);
4192 }
4193 let nodes = self.nodes();
4194 let (mut s, mut e) = (start, end);
4195 loop {
4196 let mut grew = false;
4197 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
4198 let Some(text) = inline_content_span(n, &self.source) else {
4199 continue;
4200 };
4201 // Some of its text survives, so the node still has a job.
4202 if text.start < s || text.end > e {
4203 continue;
4204 }
4205 if n.span.start < s || n.span.end > e {
4206 s = s.min(n.span.start);
4207 e = e.max(n.span.end);
4208 grew = true;
4209 }
4210 }
4211 if !grew {
4212 return (s, e);
4213 }
4214 }
4215 }
4216
4217 /// One splice of document text, keeping the **mark-edge rule**: an inline
4218 /// mark's content never begins or ends with whitespace. In Markdown and Djot
4219 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
4220 /// is four literal asterisks around a word, and a rich view drawing the
4221 /// document faithfully has no choice but to show them. That is correct
4222 /// rendering of what the file says, and nobody typing a space after a bold
4223 /// word meant to say it.
4224 ///
4225 /// So the space goes *outside* the run instead — `**bold** ` — which is the
4226 /// same document to a reader and a live one to a parser. The caret follows it
4227 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
4228 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
4229 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
4230 ///
4231 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
4232 /// through here, so the rule holds however the whitespace arrives at the
4233 /// edge. The repair is decided *after* the plain edit, by asking whether the
4234 /// mark actually died: a code span's backticks aren't whitespace-sensitive
4235 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
4236 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4237 let fix = self.mark_edge_fix(start, end, text);
4238 if !self.splice_exact(start, end, text, kind) {
4239 return false;
4240 }
4241 if let Some(fix) = fix {
4242 self.repair_mark_edges(fix);
4243 }
4244 if text.is_empty() && end > start {
4245 self.settle_inside_close_delims();
4246 }
4247 true
4248 }
4249
4250 /// After a delete, take a caret left standing past a run's closing delimiters
4251 /// back inside the run.
4252 ///
4253 /// A delete leaves the caret where the deleted bytes began, and when those
4254 /// bytes were the last thing after a marked phrase — the space the mark-edge
4255 /// rule pushed out of `**bold** `, say — that spot is the far side of the
4256 /// closing `**`. The rich view has nothing to draw there: the delimiters are
4257 /// hidden, so the caret shows at the end of the word either way, and the two
4258 /// offsets are one place on screen with two different meanings. Typing at the
4259 /// outer one lands past the run, so the writer who backspaced a space out of
4260 /// their bold phrase watches the next character come out plain, and the
4261 /// toolbar button go dark, with the caret never appearing to move.
4262 ///
4263 /// The end of the run's text is the caret's home there — a delete that took
4264 /// away everything after a phrase leaves the caret at the end of that phrase,
4265 /// which is inside it — so it settles onto that
4266 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
4267 /// walk, through every mark closing at the point): the word stays bold, the
4268 /// button stays lit, and the next character carries on the phrase.
4269 ///
4270 /// Rich view only, and only where a mark really closes at the caret — mid-run
4271 /// or in plain prose no span ends there and the caret stays put. The opening
4272 /// edge is left alone on purpose: a caret in front of a run inherits from the
4273 /// text on its left, which is the plain text outside.
4274 fn settle_inside_close_delims(&mut self) {
4275 if self.view != View::Wysiwyg {
4276 return;
4277 }
4278 let at = self.step_inside_close_delims(self.caret);
4279 if at != self.caret {
4280 self.caret = at;
4281 self.clear_pending();
4282 self.record_caret();
4283 }
4284 }
4285
4286 /// The splice exactly as asked, with no mark-edge repair — for the callers
4287 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
4288 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
4289 /// the bytes they inserted.
4290 ///
4291 /// One `edit_range` through twig, then re-anchor the caret from the returned
4292 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
4293 /// Markdown/Djot) leaves the document untouched and reports.
4294 ///
4295 /// Returns whether the edit landed — for a caller that has offsets of its
4296 /// own to place afterwards, which a rolled-back splice would leave pointing
4297 /// into text that never came to exist.
4298 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4299 // The read-only gate, for every edit at once — see the field.
4300 if self.read_only {
4301 return false;
4302 }
4303 // twig records an undo step for every edit; when this one continues a
4304 // run of the same kind (typing, deleting), tell twig to fold it into the
4305 // step before it so the whole run undoes at once.
4306 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
4307 // Hand twig the pre-edit caret before the splice, so the undo step it
4308 // retires carries where the caret was standing.
4309 self.record_caret();
4310 match self.editor.edit_range(start, end, text) {
4311 Ok(change) => {
4312 if coalesce {
4313 let _ = self.editor.coalesce_last_undo();
4314 }
4315 self.last_edit_kind = Some(kind);
4316 self.refresh();
4317 self.caret = change.new.end;
4318 self.anchor = None;
4319 self.goal_col = None;
4320 self.clear_pending();
4321 self.dirty = self.source != self.clean_source;
4322 self.status = None;
4323 // And the post-edit caret, so a later redo restores it.
4324 self.record_caret();
4325 true
4326 }
4327 // The edit was rolled back, so twig's history did not move and
4328 // neither may ours: pushing here would leave a step with no edit
4329 // under it and shift every later undo onto the wrong caret.
4330 Err(e) => {
4331 self.status = Some(format!("edit: {e}"));
4332 false
4333 }
4334 }
4335 }
4336
4337 /// The re-spelling that would keep the mark-edge rule for the edit
4338 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
4339 /// against a delimiter and the plain splice is already right. Computed
4340 /// *before* the edit, while the run's spans and delimiters can still be read
4341 /// off the document; applied afterwards, and only if the mark really died —
4342 /// see [`repair_mark_edges`](Self::repair_mark_edges).
4343 ///
4344 /// Rich view only. Source view is for typing raw markup, where a space put
4345 /// against a `**` is exactly the character it looks like.
4346 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
4347 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
4348 return None;
4349 }
4350 // Every inline mark standing over the edit, outermost first, with the
4351 // content span that says where its delimiters are.
4352 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
4353 .editor
4354 .ancestors_at(start)
4355 .unwrap_or_default()
4356 .into_iter()
4357 .filter_map(|m| {
4358 let kind = inline_kind(&m.kind)?;
4359 let content = m.content_span.clone()?;
4360 Some((kind, m.span.clone(), content))
4361 })
4362 .collect();
4363 // The innermost run whose *content* holds the whole edit: the one whose
4364 // text is being changed, rather than one the edit merely sits under.
4365 let (kind, span, content) = chain
4366 .iter()
4367 .rev()
4368 .find(|(_, _, c)| c.start <= start && end <= c.end)?
4369 .clone();
4370 // What that content becomes. Whitespace at either end of it is what
4371 // would put out the mark.
4372 let body = format!(
4373 "{}{text}{}",
4374 &self.source[content.start..start],
4375 &self.source[end..content.end]
4376 );
4377 let (lead, trail) = if body.trim().is_empty() {
4378 // Nothing but whitespace left: there is no content to mark at all,
4379 // and the delimiters go with it rather than closing on a space.
4380 (body.len(), 0)
4381 } else {
4382 (
4383 body.len() - body.trim_start().len(),
4384 body.len() - body.trim_end().len(),
4385 )
4386 };
4387 // Nothing against a delimiter, and something still between them: the
4388 // plain edit stands. An emptied run is broken just as surely (`**b**`
4389 // with the `b` deleted is the literal `****`) and is re-spelt as the
4390 // nothing it now says.
4391 if lead == 0 && trail == 0 && !body.is_empty() {
4392 return None;
4393 }
4394 // Marks that open or close exactly where this one does — `***both***` is
4395 // two runs sharing an edge — spell their delimiters as one run of bytes,
4396 // so the whitespace has to clear all of them together.
4397 let (mut open_at, mut close_at) = (span.start, span.end);
4398 for _ in 0..chain.len() {
4399 match chain.iter().find(|(_, _, c)| c.start == open_at) {
4400 Some((_, s, _)) => open_at = s.start,
4401 None => break,
4402 }
4403 }
4404 for _ in 0..chain.len() {
4405 match chain.iter().find(|(_, _, c)| c.end == close_at) {
4406 Some((_, s, _)) => close_at = s.end,
4407 None => break,
4408 }
4409 }
4410 let open = &self.source[open_at..content.start];
4411 let close = &self.source[content.end..close_at];
4412 let core = &body[lead..body.len() - trail];
4413 let respelt = if core.is_empty() {
4414 body.clone()
4415 } else {
4416 format!(
4417 "{}{open}{core}{close}{}",
4418 &body[..lead],
4419 &body[body.len() - trail..]
4420 )
4421 };
4422 // The caret sits just past the inserted text within the new content —
4423 // which, when that lands in the whitespace, is now outside the delimiters.
4424 let pos = (start - content.start) + text.len();
4425 let caret = if core.is_empty() || pos <= lead {
4426 open_at + pos
4427 } else if pos >= lead + core.len() {
4428 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4429 } else {
4430 open_at + lead + open.len() + (pos - lead)
4431 };
4432 Some(MarkEdgeFix {
4433 kind,
4434 probe: content.start,
4435 start: open_at,
4436 end: close_at + text.len() - (end - start),
4437 text: respelt,
4438 caret,
4439 // The marks in force here, resolved against any armed sticky delta —
4440 // what the writer is typing in, and so what has to still be true on
4441 // the far side of the delimiter the caret just stepped over.
4442 want: chain
4443 .iter()
4444 .filter(|(_, s, _)| start < s.end)
4445 .map(|(k, _, _)| *k)
4446 .collect::<InlineMarks>()
4447 .xor(self.pending_here()),
4448 })
4449 }
4450
4451 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4452 /// did break the mark. Whether whitespace at a delimiter is fatal is the
4453 /// format's business, not leaf's: `**bold **` is no longer strong, while
4454 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4455 /// don't care either. Asking the parser afterwards settles it for every kind
4456 /// and format at once, and costs a re-spelling only where one is due.
4457 ///
4458 /// The repair rides along with the edit that caused it — one undo step puts
4459 /// back what the writer typed, not a delimiter shuffle they never saw.
4460 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4461 if fix.end > self.source.len() {
4462 return;
4463 }
4464 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4465 return; // still a mark: these delimiters don't mind the whitespace
4466 }
4467 let resumed = self.last_edit_kind;
4468 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4469 return;
4470 }
4471 let _ = self.editor.coalesce_last_undo();
4472 // The keystroke owns the undo step, so the run of typing it belongs to
4473 // keeps coalescing over the repair rather than breaking in two here.
4474 self.last_edit_kind = resumed;
4475 self.caret = fix.caret.min(self.source.len());
4476 self.anchor = None;
4477 self.goal_col = None;
4478 self.rearm(fix.want);
4479 self.clamp_caret();
4480 self.record_caret();
4481 }
4482
4483 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4484 /// writer is typing in, carried across an edit that moved the caret out of
4485 /// the run holding them. Arms nothing when the caret already stands in
4486 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4487 /// a clean delta here (see [`toggle`](Self::toggle)).
4488 fn rearm(&mut self, want: InlineMarks) {
4489 let here: InlineMarks = self
4490 .marks_at(self.caret)
4491 .into_iter()
4492 .map(|(k, _)| k)
4493 .collect();
4494 self.pending_marks = want.xor(here);
4495 self.pending_at = Some(self.caret);
4496 }
4497
4498 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4499 /// which backslash-escapes any character that would otherwise open markup in
4500 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4501 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4502 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4503 /// a collapsed point — a selection is deleted by the caller first, since
4504 /// `insert_literal` inserts rather than replaces.
4505 fn insert_literal_at(
4506 &mut self,
4507 at: usize,
4508 text: &str,
4509 kind: EditKind,
4510 force_coalesce: bool,
4511 ) -> bool {
4512 // The read-only gate: this door goes to twig directly, not through
4513 // `splice_exact`, so it guards itself — see the field.
4514 if self.read_only {
4515 return false;
4516 }
4517 // `force_coalesce` folds this into the immediately preceding edit (the
4518 // selection-delete of an overwrite) so the pair is one undo step; else it
4519 // coalesces only when it continues a run of the same-kind typing.
4520 let coalesce =
4521 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4522 // The mark-edge rule holds for typed text however it is spelled — see
4523 // `splice`. Only an insert twig passed through unchanged can use it,
4524 // since a fix is measured in the bytes that actually land, and an escape
4525 // adds bytes this couldn't have counted.
4526 let fix = self.mark_edge_fix(at, at, text);
4527 self.record_caret();
4528 match self.editor.insert_literal(at, text) {
4529 Ok(change) => {
4530 if coalesce {
4531 let _ = self.editor.coalesce_last_undo();
4532 }
4533 self.last_edit_kind = Some(kind);
4534 self.refresh();
4535 self.caret = change.new.end;
4536 self.anchor = None;
4537 self.goal_col = None;
4538 self.clear_pending();
4539 self.dirty = self.source != self.clean_source;
4540 self.status = None;
4541 self.record_caret();
4542 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4543 self.repair_mark_edges(fix);
4544 }
4545 true
4546 }
4547 Err(e) => {
4548 self.status = Some(format!("edit: {e}"));
4549 false
4550 }
4551 }
4552 }
4553
4554 /// After a structural list edit (a new item, a nest/unnest), renumber the
4555 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4556 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4557 /// renumber as its own edit; fold it into the edit that triggered it so the
4558 /// two undo as one, and only when it actually changed the source (a no-op or
4559 /// a caret outside any ordered list must not coalesce the real edit into the
4560 /// step before it).
4561 fn renumber_here(&mut self) {
4562 self.renumber_at(self.caret);
4563 }
4564
4565 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4566 /// caret — for an edit that leaves the caret one past the item it just wrote,
4567 /// where twig resolves no list to renumber.
4568 fn renumber_at(&mut self, off: usize) {
4569 // The read-only gate — this door reaches twig without the splice.
4570 if self.read_only {
4571 return;
4572 }
4573 let before = self.source.clone();
4574 if self.editor.renumber_ordered_lists(off).is_err() {
4575 return; // not inside an ordered list — nothing to renumber
4576 }
4577 self.refresh();
4578 if self.source != before {
4579 let _ = self.editor.coalesce_last_undo();
4580 self.dirty = self.source != self.clean_source;
4581 self.clamp_caret();
4582 self.record_caret();
4583 }
4584 }
4585
4586 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4587 /// sub-item written directly beneath a text line reparses that text as a
4588 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
4589 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4590 /// bullets can't underline anything, so swap the dash for a `*`: the item
4591 /// stays an empty nested bullet, the parent stays prose, and the source
4592 /// round-trips instead of hiding a heading the user never asked for. Folded
4593 /// into the triggering edit's undo step, the way renumbering is.
4594 ///
4595 /// Gated on the collapse having actually happened (the swapped dash was
4596 /// swallowed into a `heading`), so a real setext heading the author wrote —
4597 /// or a `- x` with content, which can't underline anything — is never
4598 /// touched. This has to live in the *edit*, not the renderer: leaving the
4599 /// hazardous bytes on disk and only painting over them would ship a file
4600 /// every other CommonMark tool reads as a heading.
4601 ///
4602 /// This one keeps its own byte scan, and has to: the hazard is precisely
4603 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4604 /// which asks twig which lines open an item — reports nothing here. There is
4605 /// no node to ask about. It is also the last Markdown spelling leaf writes on
4606 /// purpose rather than for want of an answer; once twig spells continuations
4607 /// itself, avoiding the trap becomes twig's, and this goes.
4608 ///
4609 /// [`list_marker_on_line`]: Self::list_marker_on_line
4610 fn avoid_setext_collapse(&mut self) {
4611 let caret = self.caret.min(self.source.len());
4612 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4613 let bytes = self.source.as_bytes();
4614 let mut dash = line_start;
4615 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4616 dash += 1;
4617 }
4618 // A dash bullet is the only marker that doubles as a setext underline.
4619 if bytes.get(dash) != Some(&b'-') {
4620 return;
4621 }
4622 // Only an *empty* item is a bare underline; `- x` carries content and
4623 // can't fold the line above into a heading.
4624 let line_end = self.source[dash..]
4625 .find('\n')
4626 .map_or(self.source.len(), |i| dash + i);
4627 if !self.source[dash + 1..line_end].trim().is_empty() {
4628 return;
4629 }
4630 // The tell: that dash was swallowed into a `heading`. A properly nested
4631 // empty item sits under a `list_item`, with no heading in reach. Probe
4632 // the dash byte itself (well inside the heading), not the caret, whose
4633 // end-of-line offset can fall on the half-open span boundary.
4634 let collapsed = self
4635 .editor
4636 .ancestors_at(dash)
4637 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4638 .unwrap_or(false);
4639 if !collapsed {
4640 return;
4641 }
4642 let caret = self.caret;
4643 if self.splice(dash, dash + 1, "*", EditKind::Other) {
4644 // Same width, so the caret keeps its column; fold into the edit that
4645 // triggered this so Tab stays one undo step.
4646 let _ = self.editor.coalesce_last_undo();
4647 self.caret = caret.min(self.source.len());
4648 self.clamp_caret();
4649 self.record_caret();
4650 }
4651 }
4652
4653 fn snapshot(&self) -> CaretState {
4654 CaretState {
4655 caret: self.caret,
4656 anchor: self.anchor,
4657 }
4658 }
4659
4660 /// Hand twig the current caret and selection as the blob for the live
4661 /// document state. Called before an edit — so the step twig retires records
4662 /// where the caret was, and undo can restore it — and again once the op has
4663 /// placed the caret, so redo restores where the edit left it.
4664 ///
4665 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4666 /// caret through its own history, so coalescing falls out for free (folding
4667 /// two twig steps into one drops the intermediate blob, keeping the run's
4668 /// first) and the parallel stacks that had to march in lockstep — and could
4669 /// silently drift out of it — are gone.
4670 fn record_caret(&mut self) {
4671 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4672 }
4673
4674 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4675 /// the toggled region selected so a second press cleanly reverses it.
4676 pub fn toggle(&mut self, kind: InlineKind) {
4677 // The read-only gate — this door reaches twig without the splice.
4678 if self.read_only {
4679 return;
4680 }
4681 // Ahead of the no-selection branch below: arming a mark for text not yet
4682 // typed is a promise `insert` cannot keep in a format with no delimiters
4683 // to spell it with. Per *kind*, not per format — Markdown spells five
4684 // of the eight marks (highlight among them, under the `highlight`
4685 // extension leaf parses with), djot all eight, HTML seven.
4686 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4687 return;
4688 }
4689 let Some((s, e)) = self.selection() else {
4690 // No selection: arm the mark for the next text typed here, the way a
4691 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4692 // in the flow of typing without ever selecting anything — the delta
4693 // is realised onto the freshly typed text by `insert`. A fresh caret
4694 // position starts the delta over from the marks actually in force.
4695 if self.pending_at != Some(self.caret) {
4696 self.pending_marks = InlineMarks::empty();
4697 self.pending_at = Some(self.caret);
4698 }
4699 self.pending_marks.flip(kind);
4700 self.status = None;
4701 return;
4702 };
4703 // Whitespace at the edge of a selection is not part of what was chosen —
4704 // a double-click takes the space after the word with it — and a mark
4705 // cannot close against one anyway: `**word **` is four literal asterisks
4706 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4707 let picked = &self.source[s..e];
4708 let (s, e) = (
4709 s + (picked.len() - picked.trim_start().len()),
4710 e - (picked.len() - picked.trim_end().len()),
4711 );
4712 if s >= e {
4713 self.status = Some(format!("{kind:?}: nothing selected to mark"));
4714 return;
4715 }
4716 // Styling a selection is a one-shot act, not a sticky mode.
4717 self.clear_pending();
4718 self.record_caret();
4719 match self.editor.toggle_inline(s, e, kind) {
4720 Ok(change) => {
4721 self.last_edit_kind = None; // structural edit is its own undo step
4722 self.refresh();
4723 self.anchor = Some(change.new.start);
4724 self.caret = change.new.end;
4725 self.dirty = self.source != self.clean_source;
4726 self.status = None;
4727 self.record_caret();
4728 }
4729 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4730 }
4731 }
4732
4733 /// Whether the caret stands in a highlight — what a frontend asks to enable
4734 /// or disable its highlight-colour controls, the way
4735 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4736 ///
4737 /// A fact about the *caret*, and the other half of
4738 /// [`Capabilities::mark_color`], which is the fact about the format. A
4739 /// frontend needs both: djot spells a highlight and no colour for it, so a
4740 /// caret standing in `{=word=}` answers `true` here and still has no palette
4741 /// to offer.
4742 ///
4743 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4744 /// the palette appears exactly where the Highlight button is lit — with one
4745 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4746 /// into lights the button and answers `false` here, because there is no node
4747 /// to colour until the text exists.
4748 pub fn caret_in_mark(&mut self) -> bool {
4749 self.mark_offset().is_some()
4750 }
4751
4752 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4753 /// standing in the highlight the gesture means — or `None` when neither end
4754 /// of what is selected is in one.
4755 ///
4756 /// The caret first, and the selection's *start* after it, because of what
4757 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4758 /// whole, with the caret at its far edge, one past the closing `==` and so
4759 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4760 /// and colour it — the two presses a coloured highlight is made of — would
4761 /// otherwise refuse on the second, having just written the highlight the
4762 /// author is pointing at.
4763 fn mark_offset(&mut self) -> Option<usize> {
4764 let in_mark = |d: &mut Self, off: usize| {
4765 d.marks_at(off)
4766 .into_iter()
4767 .any(|(k, _)| k == InlineKind::Mark)
4768 .then_some(off)
4769 };
4770 let caret = self.caret.min(self.source.len());
4771 in_mark(self, caret).or_else(|| {
4772 let start = self.selection()?.0;
4773 in_mark(self, start)
4774 })
4775 }
4776
4777 /// The colour of the highlight at the caret — `None` both when the caret is
4778 /// in no highlight and when the highlight it is in names no colour, which
4779 /// are the same answer to "which swatch is lit".
4780 ///
4781 /// The innermost mark, by span, for the same reason
4782 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4783 /// what the caret is *in* is the deepest node containing it. A `data-color`
4784 /// naming a colour this build has no variant for reads as `None` — the
4785 /// renderer already draws that as a plain highlight rather than guessing,
4786 /// and the toolbar agrees with the renderer.
4787 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4788 let at = self.mark_offset()?;
4789 self.mark_color_at(at)
4790 }
4791
4792 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4793 /// the innermost `mark` covering it, and the colour it names.
4794 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4795 self.nodes()
4796 .into_iter()
4797 .filter(|n| n.kind == Kind::Mark)
4798 .filter(|n| n.span.start <= off && off < n.span.end)
4799 .min_by_key(|n| n.span.end - n.span.start)
4800 .and_then(|n| MarkColor::from_attrs(&n.attrs))
4801 }
4802
4803 /// Colour the highlight at the caret, or clear its colour with `None` — the
4804 /// palette behind a toolbar's Highlight button.
4805 ///
4806 /// Markdown only, and the one gesture whose availability is a fact about the
4807 /// *parse extensions* rather than about the format alone: the colour is
4808 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4809 /// records as the mark's `data-color`, and only an editor parsing with
4810 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4811 /// document) reads it back that way. Djot spells the highlight and no colour
4812 /// for it, so this refuses there — see [`Capabilities::mark_color`].
4813 ///
4814 /// **A colour is a property of a highlight that already exists.** There is
4815 /// no "highlight this in red" here, because that is two splices and would be
4816 /// two undo steps under one press; a frontend that wants it calls
4817 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4818 /// order the two buttons already sit in. With no highlight at the caret this
4819 /// says so in the status line and writes nothing.
4820 ///
4821 /// The caret keeps its place in the *text*: the splice is entirely in the
4822 /// prefix between the opening `==` and the first word, so an offset past it
4823 /// rides the emoji's width, and one standing on the prefix itself lands
4824 /// where the prefix now ends.
4825 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4826 // The read-only gate — this door reaches twig without the splice.
4827 if self.read_only {
4828 return;
4829 }
4830 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4831 return;
4832 }
4833 let Some(at) = self.mark_offset() else {
4834 self.status = Some("highlight colour: no highlight at the caret".into());
4835 return;
4836 };
4837 // Clearing a colour a highlight hasn't got is twig's one *successful*
4838 // no-op, and the `Change` it hands back then describes whatever edit came
4839 // before it — a stale span that would drag the caret somewhere it never
4840 // was. Answer it here, where the question is cheap, rather than trusting
4841 // a change that isn't one.
4842 if color.is_none() && self.mark_color_at(at).is_none() {
4843 self.status = None;
4844 return;
4845 }
4846 self.record_caret();
4847 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4848 Ok(change) => {
4849 // Re-anchored from the offsets as they were, *before* `refresh`
4850 // sees the new bytes: the caret it clamps is one standing inside
4851 // a prefix that didn't exist a moment ago, and walking it back to
4852 // a char boundary of the emoji loses the place this is restoring.
4853 let caret = reanchor(self.caret, &change);
4854 let anchor = self.anchor.map(|a| reanchor(a, &change));
4855 self.last_edit_kind = None; // structural edit is its own undo step
4856 self.refresh();
4857 self.caret = caret;
4858 self.anchor = anchor;
4859 self.dirty = self.source != self.clean_source;
4860 self.status = None;
4861 self.clamp_caret();
4862 self.record_caret();
4863 }
4864 Err(e) => self.status = Some(format!("highlight colour: {e}")),
4865 }
4866 }
4867
4868 /// One press of a colour swatch: colour the highlight at the caret, or —
4869 /// over a selection that isn't highlighted yet — highlight it and colour it,
4870 /// as **one** undo step.
4871 ///
4872 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4873 /// one splice; this is the compound every toolbar actually presses, and it
4874 /// lives here rather than in each frontend because the rule it encodes —
4875 /// what a swatch means when there is no highlight under it yet — is one
4876 /// answer, not one per frontend. The two splices are folded into a single
4877 /// history step, so the press that made a red highlight is taken back by a
4878 /// single undo rather than leaving an uncoloured one behind.
4879 ///
4880 /// `None` clears the colour, and over an unhighlighted selection means
4881 /// simply "highlight this" — the same thing the Highlight button does.
4882 /// A bare caret in no highlight is left alone with a status line, because
4883 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4884 /// colour cannot be armed with it.
4885 pub fn highlight(&mut self, color: Option<MarkColor>) {
4886 if self.caret_in_mark() || self.selection().is_none() {
4887 self.set_mark_color(color);
4888 return;
4889 }
4890 self.toggle(InlineKind::Mark);
4891 // The format may not spell a highlight at all (`toggle` said so), and
4892 // there is nothing to colour if it doesn't.
4893 if self.status.is_some() {
4894 return;
4895 }
4896 let before = self.revision;
4897 self.set_mark_color(color);
4898 // Only fold when the colour really spliced. `highlight(None)` over a
4899 // fresh highlight is a no-op by design, and coalescing there would eat
4900 // the *previous* edit into the toggle instead.
4901 if self.revision != before {
4902 let _ = self.editor.coalesce_last_undo();
4903 }
4904 }
4905
4906 // ── the presentation vocabulary ─────────────────────────────────────────
4907 //
4908 // Six gestures and five queries over twig's two attribute ops. Each gesture
4909 // edits **one key and keeps the rest**: it reads the node's attributes,
4910 // removes its own key (and, for alignment, its own tokens out of `class`),
4911 // adds the new value or nothing, and passes the list back whole — twig's
4912 // contract is replace-not-merge, so the read is the caller's job. A
4913 // paragraph that came in as `class="lead center" id="intro"
4914 // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4915 // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4916 // touched, which is what lets a document from elsewhere pass through the
4917 // editor unharmed.
4918 //
4919 // Clearing is the same gesture with `None`: the key goes, and an empty list
4920 // at the end unwraps the span or the Markdown div, which twig does.
4921
4922 /// Set — or with `None` clear — the alignment of the block the caret is in.
4923 ///
4924 /// A block property, so the gesture is `set_block_attrs` on the caret's
4925 /// block **whatever is selected**: a line is a block's, and "centre this"
4926 /// with three words selected means the paragraph, not the words. The
4927 /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4928 /// same `class` are kept.
4929 ///
4930 /// In Markdown the attributes live on a `<div>` around the block — twig has
4931 /// no paragraph attribute syntax to write — and this reads them back off
4932 /// that div when the block is its sole child, so a second press rewrites
4933 /// the div rather than nesting a second one.
4934 pub fn set_alignment(&mut self, align: Option<Align>) {
4935 let attrs = self.block_attrs_at_caret();
4936 if align.is_none()
4937 && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4938 {
4939 return;
4940 }
4941 let attrs = with_class_token(
4942 &attrs,
4943 |t| Align::from_token(t).is_some(),
4944 align.map(Align::name),
4945 );
4946 self.write_block_attrs("alignment", attrs);
4947 }
4948
4949 /// Set — or with `None` clear — the line spacing of the block the caret is
4950 /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4951 /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4952 /// three names or an exact ratio, written in its canonical spelling.
4953 pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4954 let attrs = self.block_attrs_at_caret();
4955 if spacing.is_none()
4956 && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4957 LineHeight::from_attrs(a).is_some()
4958 })
4959 {
4960 return;
4961 }
4962 let spelling = spacing.map(LineHeight::name);
4963 let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4964 self.write_block_attrs("line spacing", attrs);
4965 }
4966
4967 /// Set — or with `None` clear — the size of the selected run, or of the
4968 /// caret's whole block when nothing is selected.
4969 ///
4970 /// Size, face and colour are the *run's*, and the block's when no run is
4971 /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4972 /// the range in an attributed span or re-styles the span it already lies in
4973 /// (never nesting a second, and unwrapping it when the last key goes). With
4974 /// no selection it is `set_block_attrs` on the caret's block, so that "make
4975 /// this paragraph larger" is a click with the caret in it rather than a
4976 /// select-all first.
4977 ///
4978 /// The walker reads the key at both levels with the nearer winning, so a
4979 /// span's `data-size` inside a block carrying its own applies to the span.
4980 ///
4981 /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4982 /// what a menu offers first because a step reads as a step up under every
4983 /// theme, or the point size an author asked for, which is exact and is all
4984 /// it is. Either is written in its canonical spelling, so a size set twice
4985 /// from the same field writes the same bytes both times.
4986 pub fn set_font_size(&mut self, size: Option<FontSize>) {
4987 let spelling = size.map(FontSize::name);
4988 self.set_run_attr("size", "data-size", spelling.as_deref());
4989 }
4990
4991 /// Set — or with `None` clear — the face of the selected run, or of the
4992 /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4993 /// [`FontFace`] under `data-font` — one of the four generics, or the family
4994 /// the author named, which the frontends resolve through the platform's
4995 /// font registry and fall back to the body face without.
4996 pub fn set_font_family(&mut self, font: Option<FontFace>) {
4997 let spelling = font.as_ref().map(FontFace::name);
4998 self.set_run_attr("font", "data-font", spelling.as_deref());
4999 }
5000
5001 /// Set — or with `None` clear — the *text* colour of the selected run, or of
5002 /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
5003 /// with [`TextColor`] under `data-color` — one of the seven names, whose
5004 /// two inks the theme owns, or the triple the author picked, which is
5005 /// painted as written in both appearances.
5006 ///
5007 /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
5008 /// writes, and a different thing: that one colours a highlight's
5009 /// *background* and rides the `mark` node twig owns the spelling of, this
5010 /// one colours the letters and rides an attributed span. The two never
5011 /// collide, because a `mark` is a `mark` and a span is a span — and they
5012 /// share a vocabulary on purpose, so that a frontend with a red for a
5013 /// highlight has a red for text and both are *that* red.
5014 pub fn set_text_color(&mut self, color: Option<TextColor>) {
5015 let spelling = color.map(TextColor::name);
5016 self.set_run_attr("text colour", "data-color", spelling.as_deref());
5017 }
5018
5019 /// Insert a page break at the caret — `::page-break`, a leaf directive with
5020 /// no label and no attributes, which twig spells in every format that names
5021 /// a leaf container (Markdown under the `directives` extension
5022 /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
5023 /// page-break` fence).
5024 ///
5025 /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
5026 /// places a rule, and for the same reason: a directive is a block, so twig
5027 /// alone has nowhere to put one mid-paragraph and lands it after the
5028 /// caret's whole block. A bare paragraph is therefore parted at the caret
5029 /// first and the break aimed at the *first* half. See that method for the
5030 /// whole of the rule, including why a code block, a list item, a table and
5031 /// a setext heading are left unsplit.
5032 ///
5033 /// The frontends that paginate read the row's
5034 /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
5035 /// the ones that do not draw the `⧉ page-break` placeholder every leaf
5036 /// directive gets.
5037 pub fn insert_page_break(&mut self) {
5038 if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
5039 return;
5040 }
5041 self.caret = self.skip_trailing_close_delims(self.caret);
5042 // A selection is replaced by the break, as a rule replaces one.
5043 if let Some((s, e)) = self.selection() {
5044 self.splice(s, e, "", EditKind::Other);
5045 }
5046 self.anchor = None;
5047 self.record_caret();
5048 let at = self.caret;
5049 if self.caret_parts_bare_paragraph() {
5050 // A failure here is not fatal: the break still lands after the
5051 // block, which is what this call was trying to improve on.
5052 let _ = self.editor.split_block(at);
5053 }
5054 match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
5055 Ok(change) => {
5056 self.last_edit_kind = None;
5057 self.refresh();
5058 self.anchor = None;
5059 self.caret = change.new.end;
5060 self.dirty = self.source != self.clean_source;
5061 self.status = None;
5062 self.clamp_caret();
5063 self.record_caret();
5064 }
5065 Err(e) => self.status = Some(format!("page break: {e}")),
5066 }
5067 }
5068
5069 /// The alignment in force at the caret, or `None` for the theme's default —
5070 /// which swatch of an alignment control is lit.
5071 ///
5072 /// Read off the nearest node that names one: the block the caret is in, and
5073 /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
5074 /// shape, one property along.
5075 pub fn alignment_at_caret(&mut self) -> Option<Align> {
5076 self.presentation_chain()
5077 .iter()
5078 .find_map(|attrs| Align::from_attrs(attrs))
5079 }
5080
5081 /// The line spacing in force at the caret, or `None` for the theme's own.
5082 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
5083 pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
5084 self.presentation_chain()
5085 .iter()
5086 .find_map(|attrs| LineHeight::from_attrs(attrs))
5087 }
5088
5089 /// The size in force at the caret, or `None` for the theme's own — the
5090 /// entry a size menu shows ticked.
5091 ///
5092 /// Run-level, so the chain starts one node deeper: the attributed span the
5093 /// caret stands in, then its block, then the `div`s around it. The nearest
5094 /// wins, which is the rule the walker draws by.
5095 ///
5096 /// A name or a value, whichever the nearest node wrote. A `data-size` the
5097 /// grammar does not cover — a `huge` from elsewhere — is not a size this
5098 /// can answer, so the answer is `None` and the menu ticks *Default*, the
5099 /// same thing it did before the vocabulary opened.
5100 pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
5101 self.presentation_chain()
5102 .iter()
5103 .find_map(|attrs| FontSize::from_attrs(attrs))
5104 }
5105
5106 /// The face in force at the caret, or `None` for the theme's body face.
5107 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
5108 pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
5109 self.presentation_chain()
5110 .iter()
5111 .find_map(|attrs| FontFace::from_attrs(attrs))
5112 }
5113
5114 /// The *text* colour in force at the caret, or `None` for the theme's.
5115 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
5116 /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
5117 /// highlight's background off a `mark`, and a `mark` is never in this chain.
5118 pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
5119 self.presentation_chain()
5120 .iter()
5121 .find_map(|attrs| TextColor::from_attrs(attrs))
5122 }
5123
5124 /// The selection-or-caret half of the three run-level gestures: a span over
5125 /// a real selection, the caret's block over none.
5126 fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
5127 match self.selection() {
5128 Some((start, end)) => {
5129 let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
5130 self.write_run_attrs(what, start, end, attrs);
5131 }
5132 None => {
5133 let own = self.block_attrs_at_caret();
5134 if value.is_none()
5135 && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
5136 {
5137 return;
5138 }
5139 let attrs = with_attr(&own, key, value);
5140 self.write_block_attrs(what, attrs);
5141 }
5142 }
5143 }
5144
5145 /// A clear this gesture cannot carry out, said out loud instead of written:
5146 /// the node it rewrites — the caret's block, or the `<div>` around it that
5147 /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
5148 /// — does not name the property at all, and a `div` further out does.
5149 ///
5150 /// Handing twig the block's attributes with the key already absent changes
5151 /// no byte, and the query goes on answering `Some` off the div: the menu
5152 /// entry the author pressed stays unticked, and nothing says why. Twig's
5153 /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
5154 /// write on a node it is not rewriting — the honest answer is the status
5155 /// line, in the voice the other refusals use.
5156 ///
5157 /// `names` is the property's own reading of an attribute list, because
5158 /// alignment lives in a `class` token rather than a key of its own. Spans
5159 /// are skipped: one inside the block is not what a *block* gesture writes
5160 /// either, but neither is it "the div around the block", and the run-level
5161 /// gestures reach it through a selection.
5162 fn refuse_clear_from_div(
5163 &mut self,
5164 what: &str,
5165 own: &Attrs,
5166 names: impl Fn(&Attrs) -> bool,
5167 ) -> bool {
5168 if names(own) {
5169 return false;
5170 }
5171 let caret = self.caret.min(self.source.len());
5172 if !self
5173 .attr_chain_at(caret)
5174 .iter()
5175 .any(|(span, attrs)| !span && names(attrs))
5176 {
5177 return false;
5178 }
5179 self.status = Some(format!("{what}: set on the div around the block"));
5180 true
5181 }
5182
5183 /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
5184 /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
5185 /// has.
5186 ///
5187 /// **The caret keeps its place in the text, not its byte offset.** How a
5188 /// format spells a block's attributes is markup written *around* the block
5189 /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
5190 /// it in Markdown, a longer opening tag in HTML — and every one of those
5191 /// grows or shrinks above the author's own bytes. Where twig's change
5192 /// rewrites the block whole (Markdown's div is spliced as one region, block
5193 /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
5194 /// and parks the caret at the end of the splice, past the closing `</div>`:
5195 /// the caret is then in no block at all, so a second press of the same menu
5196 /// answers "no block at the caret" and the toolbar's queries read nothing.
5197 /// [`reanchor_in_block`] is what carries it across instead — the block's
5198 /// content span before and after, which is the one thing the respelling
5199 /// leaves alone.
5200 ///
5201 /// Read *before* the splice and applied *after* `refresh`, because both
5202 /// halves of that mapping are facts about a tree twig is between: the
5203 /// block's old bytes are gone once the edit lands, and its new ones are not
5204 /// in `self.source` until the refresh puts them there.
5205 fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
5206 if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
5207 return;
5208 }
5209 // A blank line has no block to carry an attribute, and twig answers
5210 // `NotFound` there — say so in leaf's own words instead.
5211 let Some(at) = self.block_offset_for_caret() else {
5212 self.status = Some(format!("{what}: no block at the caret"));
5213 return;
5214 };
5215 self.record_caret();
5216 let pairs = attr_pairs(&attrs);
5217 let was = self.block_content_at(at);
5218 let text = was.clone().map(|s| self.source[s].to_string());
5219 match self.editor.set_block_attrs(at, &pairs) {
5220 Ok(change) => {
5221 let (caret, anchor) = (self.caret, self.anchor);
5222 self.last_edit_kind = None; // structural edit is its own undo step
5223 self.refresh();
5224 let now = self.block_content_in(&change.new, text.as_deref());
5225 // A block the two halves cannot both name — a code block, a
5226 // caret in a list's marker — takes the plain arithmetic, which
5227 // is what it had before.
5228 let block = was.as_ref().zip(now.as_ref());
5229 self.caret = reanchor_in_block(caret, &change, block);
5230 self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
5231 self.dirty = self.source != self.clean_source;
5232 self.status = None;
5233 // The clamp reads the caret floor off the map, and this edit
5234 // can move the floor: taking the `{…}` line off a djot
5235 // document's first block moves the first rendered offset to 0,
5236 // and a floor read from the old map stood the caret past the
5237 // block's text. So the map is this revision's before the clamp
5238 // — see `open_paragraph_at_block_edge`.
5239 self.rebuild_map();
5240 self.clamp_caret();
5241 self.record_caret();
5242 }
5243 Err(e) => self.status = Some(format!("{what}: {e}")),
5244 }
5245 }
5246
5247 /// The content span of the innermost paragraph or heading covering `off` —
5248 /// the author's own bytes, without the `# ` or the `<p>` that spells the
5249 /// block around them.
5250 ///
5251 /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
5252 /// reads, so that what a gesture re-anchors by is the block it wrote to.
5253 fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
5254 self.nodes()
5255 .into_iter()
5256 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5257 .filter(|n| n.span.start <= off && off <= n.span.end)
5258 .min_by_key(|n| n.span.end - n.span.start)
5259 .map(|n| n.content_span.unwrap_or(n.span))
5260 }
5261
5262 /// [`block_content_at`](Self::block_content_at)'s other half: the content
5263 /// span of the block `region` holds now, found by the bytes it held before.
5264 ///
5265 /// Matched on the text rather than taken as the first block in the region,
5266 /// because a rewritten region is markup and all — `<div class="center">`
5267 /// carries words of its own — and because the block this gesture moved is
5268 /// the one whose content the respelling did not touch. `None` where the
5269 /// region holds no block at all, which is djot's every case: the `{…}` line
5270 /// is spliced above the block and the block itself never moves through the
5271 /// change at all, only past it.
5272 fn block_content_in(
5273 &mut self,
5274 region: &Range<usize>,
5275 text: Option<&str>,
5276 ) -> Option<Range<usize>> {
5277 let text = text?;
5278 let spans: Vec<Range<usize>> = self
5279 .nodes()
5280 .into_iter()
5281 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5282 .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
5283 .map(|n| n.content_span.unwrap_or(n.span))
5284 .collect();
5285 spans
5286 .into_iter()
5287 .find(|s| self.source.get(s.clone()) == Some(text))
5288 }
5289
5290 /// Hand `attrs` to twig as the attribute set of the span over `[start,
5291 /// end)` — wrapping one, or re-styling the one the range already lies in,
5292 /// or unwrapping it when `attrs` is empty.
5293 ///
5294 /// What the splice leaves selected is the span's **content** — the author's
5295 /// words — and not the whole of `change.new`, which is markup and all:
5296 /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
5297 /// selection reaching past the node's own span lies in no span at all, so a
5298 /// second press of the menu would nest a fresh one instead of re-styling
5299 /// the one just written.
5300 fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
5301 if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
5302 return;
5303 }
5304 self.record_caret();
5305 let pairs = attr_pairs(&attrs);
5306 match self.editor.wrap_range_attrs(start, end, &pairs) {
5307 Ok(change) => {
5308 self.last_edit_kind = None;
5309 self.refresh();
5310 let content = self.span_content_in(&change.new);
5311 self.anchor = Some(content.start);
5312 self.caret = content.end;
5313 self.dirty = self.source != self.clean_source;
5314 self.status = None;
5315 self.clamp_caret();
5316 self.record_caret();
5317 }
5318 Err(e) => self.status = Some(format!("{what}: {e}")),
5319 }
5320 }
5321
5322 /// The content range of the attributed span `spliced` now holds — the
5323 /// outermost one inside it, since that is the one just written — or
5324 /// `spliced` itself where the splice left no span, which is what an unwrap
5325 /// leaves behind.
5326 fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
5327 self.nodes()
5328 .into_iter()
5329 .filter(wysiwyg::is_run_span)
5330 .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
5331 .max_by_key(|n| n.span.end - n.span.start)
5332 .and_then(|n| n.content_span)
5333 .unwrap_or_else(|| spliced.clone())
5334 }
5335
5336 /// The attribute set `set_block_attrs` is about to **replace** at the caret
5337 /// — which is the block's own, except in Markdown, where twig writes a
5338 /// block's attributes onto a `<div>` around it and rewrites that div when
5339 /// the block is its sole child. Reading the paragraph there would hand back
5340 /// an empty list and quietly drop everything the div said.
5341 ///
5342 /// Empty when the caret is in no block at all, which is the same list a
5343 /// block carrying no attributes gives — and the right one either way, since
5344 /// the gesture then refuses on its own.
5345 fn block_attrs_at_caret(&mut self) -> Attrs {
5346 let Some(off) = self.block_offset_for_caret() else {
5347 return Vec::new();
5348 };
5349 let nodes = self.nodes();
5350 let Some(block) = nodes
5351 .iter()
5352 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5353 .filter(|n| n.span.start <= off && off <= n.span.end)
5354 .min_by_key(|n| n.span.end - n.span.start)
5355 else {
5356 return Vec::new();
5357 };
5358 if self.format == Format::Markdown
5359 && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
5360 && wysiwyg::element_tag(parent) == Some("div")
5361 && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
5362 {
5363 return parent.attrs.clone();
5364 }
5365 block.attrs.clone()
5366 }
5367
5368 /// The attribute set `wrap_range_attrs` is about to **replace** over
5369 /// `[start, end)` — the innermost attributed span the range lies inside,
5370 /// which twig re-styles rather than nesting a second one in. Empty when the
5371 /// range lies in no span, where the gesture mints a fresh one.
5372 fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5373 self.nodes()
5374 .into_iter()
5375 .filter(wysiwyg::is_run_span)
5376 .filter(|n| n.span.start <= start && end <= n.span.end)
5377 .min_by_key(|n| n.span.end - n.span.start)
5378 .map(|n| n.attrs)
5379 .unwrap_or_default()
5380 }
5381
5382 /// The attribute lists that bear on a presentation query, **nearest first**:
5383 /// the attributed spans the caret stands in (innermost first), then its
5384 /// block, then the `div`s around it. A `find_map` down this is the whole of
5385 /// each query, and the order is the rule the walker draws by.
5386 ///
5387 /// Read at the caret, and at the selection's *start* when the caret stands
5388 /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5389 /// caret one past the span it just wrote — `toggle`'s convention — so
5390 /// asking the menu which entry that press just ticked must not answer
5391 /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5392 fn presentation_chain(&mut self) -> Vec<Attrs> {
5393 let caret = self.caret.min(self.source.len());
5394 let mut chain = self.attr_chain_at(caret);
5395 if !chain.iter().any(|(span, _)| *span)
5396 && let Some((start, _)) = self.selection()
5397 {
5398 let alt = self.attr_chain_at(start);
5399 if alt.iter().any(|(span, _)| *span) {
5400 chain = alt;
5401 }
5402 }
5403 chain.into_iter().map(|(_, attrs)| attrs).collect()
5404 }
5405
5406 /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5407 /// node bearing the vocabulary that covers it, innermost first, each paired
5408 /// with whether it is an attributed span (which is what tells the caller
5409 /// its run-level answer came from a run).
5410 ///
5411 /// Sorted by span length, which *is* the nesting order: a span lies inside
5412 /// its block and a block inside its div, so shortest-first is
5413 /// nearest-first without a second tree walk.
5414 fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5415 let off = off.min(self.source.len());
5416 let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5417 for n in self.nodes() {
5418 let span = wysiwyg::is_run_span(&n);
5419 let block = matches!(n.kind, Kind::Para | Kind::Heading);
5420 let div = wysiwyg::element_tag(&n) == Some("div");
5421 if !(span || block || div) {
5422 continue;
5423 }
5424 // A span is half-open, the way a mark is: the offset one past it is
5425 // the text after it. A block and a div claim their end too, so a
5426 // caret resting at the end of a line still reads its paragraph.
5427 let inside = if span {
5428 n.span.start <= off && off < n.span.end
5429 } else {
5430 n.span.start <= off && off <= n.span.end
5431 };
5432 if !inside {
5433 continue;
5434 }
5435 hits.push((n.span.end - n.span.start, span, n.attrs));
5436 }
5437 hits.sort_by_key(|(len, _, _)| *len);
5438 hits.into_iter()
5439 .map(|(_, span, attrs)| (span, attrs))
5440 .collect()
5441 }
5442
5443 /// Convert the block at the caret to a heading level or paragraph.
5444 pub fn set_block(&mut self, kind: BlockKind) {
5445 // The read-only gate — this door reaches twig without the splice.
5446 if self.read_only {
5447 return;
5448 }
5449 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5450 return;
5451 }
5452 self.record_caret();
5453 // A blank line has no node to convert, and twig opens a block there
5454 // rather than declining — so the caret's own offset is the right thing
5455 // to hand it when `block_offset_for_caret` finds nothing.
5456 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5457 match self.editor.set_block(offset, kind) {
5458 Ok(change) => {
5459 self.last_edit_kind = None;
5460 self.refresh();
5461 // Opening a block on a blank line writes a marker the caret
5462 // belongs *after*; converting an existing one moves nothing.
5463 self.caret = self.caret.max(change.new.end);
5464 self.clamp_caret();
5465 self.anchor = None;
5466 self.dirty = self.source != self.clean_source;
5467 self.status = None;
5468 self.record_caret();
5469 }
5470 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5471 }
5472 }
5473
5474 /// Whether `off` is inside a text block (paragraph, heading, code block…).
5475 fn has_block_at(&mut self, off: usize) -> bool {
5476 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5477 chain
5478 .iter()
5479 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5480 })
5481 }
5482
5483 /// The offset to hand twig's `set_block`: the caret when it is already inside
5484 /// a block, otherwise nudged onto the previous character (a caret at a line
5485 /// end sits at the doc level, outside the block). `None` when the caret is on
5486 /// a blank line — a new paragraph with no block node to convert.
5487 fn block_offset_for_caret(&mut self) -> Option<usize> {
5488 let caret = self.caret.min(self.source.len());
5489 if self.has_block_at(caret) {
5490 return Some(caret);
5491 }
5492 // Nudge to the previous character — but never across a newline: that would
5493 // target the previous block, and a blank line genuinely has no block.
5494 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5495 && ch != '\n'
5496 && self.has_block_at(i)
5497 {
5498 return Some(i);
5499 }
5500 None
5501 }
5502
5503 /// The heading level of the text block at the caret, or `None` when that
5504 /// block is not a heading.
5505 pub fn current_heading_level(&mut self) -> Option<u32> {
5506 let caret = self.caret;
5507 self.nodes()
5508 .into_iter()
5509 .filter(|n| n.kind == Kind::Heading)
5510 .find(|n| n.span.start <= caret && caret <= n.span.end)
5511 .and_then(|n| n.level)
5512 }
5513
5514 /// The inline marks in force at the caret (or over the selection) — what a
5515 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5516 /// inline counterpart. Cheap enough to call every frame: one twig
5517 /// `ancestors_at` query per caret (two with a selection), each walking root
5518 /// → deepest node at one offset. It never snapshots the tree the way
5519 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5520 /// the only allocation is twig's own small ancestor `Vec`.
5521 ///
5522 /// **A selection reports a mark only when the mark covers *all* of it.**
5523 /// That's what every real toolbar means by an active button — Bold lit over
5524 /// a half-bold selection would claim a press turns bold *off*, when
5525 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5526 /// Whole-coverage is asked as "is the same mark node standing over both the
5527 /// first and the last character?": inline nodes are contiguous, so one node
5528 /// covering both ends covers every byte between them. Two touching runs
5529 /// (`**a****b**`) are two nodes, and correctly light nothing.
5530 ///
5531 /// At a bare caret a mark is active when the caret stands inside the mark's
5532 /// span — `span.start <= caret < span.end`, delimiters included, which is
5533 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5534 /// opening `*` (2) through the last byte of the closing `**` (9) are all
5535 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5536 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5537 /// typing would actually land inside the marked run. The offset one past the
5538 /// mark (10) is the text after it and reports nothing, at the end of the
5539 /// buffer exactly as in the middle.
5540 pub fn active_inline_marks(&mut self) -> InlineMarks {
5541 let Some((start, end)) = self.selection() else {
5542 // The marks actually in force at the caret, flipped by any armed
5543 // sticky delta — so `⌘b` at a bare caret lights the Bold button
5544 // immediately, before a single character is typed.
5545 let base: InlineMarks = self
5546 .marks_at(self.caret)
5547 .into_iter()
5548 .map(|(k, _)| k)
5549 .collect();
5550 return base.xor(self.pending_here());
5551 };
5552 // The selection's *last character*, not its exclusive end: `end` is the
5553 // offset one past the selection, which for a selection ending exactly at
5554 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5555 // entirely bold, but offset 10 is the space after).
5556 let last = prev_boundary(&self.source, end);
5557 let head = self.marks_at(start);
5558 let tail = self.marks_at(last);
5559 head.into_iter()
5560 .filter(|m| tail.contains(m))
5561 .map(|(k, _)| k)
5562 .collect()
5563 }
5564
5565 /// The inline marks whose span covers `off`, each with the id of the node
5566 /// carrying it — the id is what lets a selection tell one mark node from
5567 /// another of the same kind.
5568 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5569 let off = off.min(self.source.len());
5570 self.editor
5571 .ancestors_at(off)
5572 .unwrap_or_default()
5573 .into_iter()
5574 // `span.end` is the offset one *past* the mark, so it isn't in it.
5575 // twig already resolves a boundary to whatever starts there — in
5576 // `**bold** x` offset 8 is the following text, not the strong — but
5577 // when nothing follows, the tie has nobody to break for and the
5578 // chain still ends at the mark. That would make the answer at the
5579 // last offset of the document depend on whether the file happens to
5580 // end in a newline; the rule is `span.start <= off < span.end`, and
5581 // it's the same rule at the end of a buffer as in the middle.
5582 .filter(|m| off < m.span.end)
5583 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5584 .collect()
5585 }
5586
5587 /// Toggle a heading at the caret: if the block is already this heading level,
5588 /// revert it to a paragraph; otherwise convert it to this heading level.
5589 /// This gives the heading commands the same toggle feel as bold/italic/code —
5590 /// re-applying a heading a line already has turns it back into body text.
5591 pub fn toggle_heading(&mut self, level: u32) {
5592 if self.current_heading_level() == Some(level) {
5593 self.set_block(BlockKind::Paragraph);
5594 } else {
5595 self.set_block(BlockKind::Heading(level));
5596 }
5597 }
5598
5599 /// Toggle a block quote around the selection, or around the block at the
5600 /// caret — the toolbar's Quote button.
5601 pub fn toggle_blockquote(&mut self) {
5602 self.toggle_container(BlockContainerKind::BlockQuote);
5603 }
5604
5605 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5606 /// over the block at the caret — one op with the kind as a flag, the way
5607 /// `toggle_heading` takes its level, so a frontend needs no twig type to
5608 /// name the two buttons.
5609 ///
5610 /// Pressing the *other* list's button while in a list converts in place
5611 /// rather than nesting, so the pair reads as one three-state control
5612 /// (bulleted / numbered / neither) rather than two independent wrappers.
5613 pub fn toggle_list(&mut self, ordered: bool) {
5614 self.toggle_container(if ordered {
5615 BlockContainerKind::OrderedList
5616 } else {
5617 BlockContainerKind::BulletList
5618 });
5619 }
5620
5621 /// Toggle a fenced code block over the selection, or over the block at the
5622 /// caret — the toolbar's Code Block button, and the only way the rich view
5623 /// offers to open one: a typed backtick is escaped there, since it is a
5624 /// character the author wrote and not markup they meant.
5625 ///
5626 /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
5627 /// against the body, keeps a quote's `> ` on every line, and peels a fence
5628 /// (or dedents an indented block) on the way back. What is leaf's is the
5629 /// shape twig declines: a blank line holds no block to fence, and the
5630 /// gesture nobody should have to learn is "type something first" — so leaf
5631 /// spells the empty fence itself, with the caret on the empty line inside it
5632 /// and a blank line either side, which is what typing into a new block and
5633 /// then Backspacing out of it would have left.
5634 ///
5635 /// Inside a list item twig refuses in both directions (a fence at column
5636 /// zero would swallow the item's marker), and the refusal is reported rather
5637 /// than worked around.
5638 pub fn toggle_code_block(&mut self) {
5639 // The read-only gate — this door reaches twig without the splice.
5640 if self.read_only {
5641 return;
5642 }
5643 if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
5644 return;
5645 }
5646 let selected = self.selection();
5647 // The caret at a code block's rows resolves to its *content*, and twig
5648 // finds the block from any offset inside its span — but a caret parked
5649 // on the closing fence's own line end is past it, so the block's start
5650 // is handed over whenever the caret is in one at all.
5651 let (start, end) = match selected {
5652 Some(range) => range,
5653 None => match self.code_block_start_at_caret() {
5654 Some(start) => (start, start),
5655 None => match self.block_offset_for_caret() {
5656 Some(off) => (off, off),
5657 None => return self.open_empty_code_block(),
5658 },
5659 },
5660 };
5661 self.record_caret();
5662 match self.editor.toggle_code_block(start, end, None) {
5663 Ok(change) => {
5664 // Read the caret's place out of the *pre-edit* source, before
5665 // `refresh` swaps it out — and how many lines the region held,
5666 // which is what says whether a fence line went in above the
5667 // caret's line or came off it.
5668 let place = selected
5669 .is_none()
5670 .then(|| self.caret_line_tail(&change.old));
5671 let old_lines = self.source[change.old.clone()].matches('\n').count();
5672 self.last_edit_kind = None; // structural edit is its own undo step
5673 self.refresh();
5674 match place {
5675 // From a selection: keep the block selected, so a second
5676 // press reverses the first (twig unfences from any offset in
5677 // the fence's span, the region's start included).
5678 None => {
5679 self.anchor = Some(change.new.start);
5680 self.caret = change.new.end;
5681 }
5682 // From a caret: the same line, the same distance from its
5683 // end, shifted by the opening fence that was written above
5684 // it (or peeled off). Dedenting an indented block keeps the
5685 // lines one-to-one, and shifts nothing.
5686 Some((line, tail)) => {
5687 let new_lines = self.source[change.new.start.min(self.source.len())
5688 ..change.new.end.min(self.source.len())]
5689 .matches('\n')
5690 .count();
5691 let line = match new_lines.cmp(&old_lines) {
5692 std::cmp::Ordering::Greater => line + 1,
5693 std::cmp::Ordering::Less => line.saturating_sub(1),
5694 std::cmp::Ordering::Equal => line,
5695 };
5696 self.anchor = None;
5697 self.caret = self.line_tail_offset(&change.new, (line, tail));
5698 }
5699 }
5700 self.dirty = self.source != self.clean_source;
5701 self.status = None;
5702 self.clamp_caret();
5703 self.record_caret();
5704 }
5705 Err(e) => self.status = Some(format!("code block: {e}")),
5706 }
5707 }
5708
5709 /// Write an empty fenced block on the blank line the caret stands on, and
5710 /// put the caret on the empty line inside it — the half of
5711 /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
5712 /// twig reports `NotFound` for a range no block covers.
5713 ///
5714 /// Inside a quote the fence lines and the empty line keep the quote's
5715 /// prefix, as twig's own fencing does. A blank line goes in on whichever
5716 /// side has text against it, since a fence may interrupt a paragraph in
5717 /// Markdown but a block standing tight against its neighbours is not the
5718 /// document any editor writes.
5719 fn open_empty_code_block(&mut self) {
5720 let caret = self.caret.min(self.source.len());
5721 let prefix = self.quote_prefix_at(caret);
5722 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
5723 let line_end = self.source[caret..]
5724 .find('\n')
5725 .map_or(self.source.len(), |i| caret + i);
5726 let prev_blank = line_start == 0
5727 || self.source[..line_start - 1]
5728 .rsplit('\n')
5729 .next()
5730 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5731 let next_blank = line_end >= self.source.len()
5732 || self.source[line_end + 1..]
5733 .split('\n')
5734 .next()
5735 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5736 // A blank line inside a quote is a bare `>`: the space after it is
5737 // the content's, and there is none.
5738 let blank = prefix.trim_end();
5739 let mut text = String::new();
5740 if !prev_blank {
5741 text.push_str(blank);
5742 text.push('\n');
5743 }
5744 text.push_str(&prefix);
5745 text.push_str("```\n");
5746 text.push_str(&prefix);
5747 let inside = text.len();
5748 text.push('\n');
5749 text.push_str(&prefix);
5750 text.push_str("```");
5751 if !next_blank {
5752 text.push('\n');
5753 text.push_str(blank);
5754 }
5755 // Over whatever the blank line held (its quote prefix, trailing
5756 // spaces), so the block's own prefix is the one twig will read back.
5757 let at = line_start;
5758 if !self.splice(at, line_end, &text, EditKind::Other) {
5759 return;
5760 }
5761 self.caret = at + inside;
5762 self.anchor = None;
5763 self.clamp_caret();
5764 self.record_caret();
5765 }
5766
5767 /// Whether the caret stands in a code block, fenced or indented — what
5768 /// lights the Code Block button. Wider than
5769 /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
5770 /// narrower question a language prompt needs.
5771 pub fn caret_in_code_block(&mut self) -> bool {
5772 self.code_block_start_at_caret().is_some()
5773 }
5774
5775 // ── Task list items ──────────────────────────────────────────────────────
5776 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5777 // inline content of the item's first paragraph rather than part of its
5778 // marker, so adding or removing one must leave the item's continuation
5779 // indentation alone, and an item inside a quote is found past the quote
5780 // markers. leaf names the gesture and the offset; the spelling is twig's.
5781
5782 /// Whether the list item at the caret carries a checkbox, and which way it
5783 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5784 /// item or no item at all. What a toolbar reads to light its checkbox button.
5785 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5786 self.task_checked_at(self.caret)
5787 }
5788
5789 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5790 /// offset — what a frontend asks before deciding a click landed on a box.
5791 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5792 self.innermost_list_item(offset.min(self.source.len()))?
5793 .checked
5794 }
5795
5796 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5797 /// A no-op with a reported reason when the caret is in no task item — minting
5798 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5799 pub fn toggle_task_checked(&mut self) {
5800 self.toggle_task_at(self.caret);
5801 }
5802
5803 /// Tick or untick the task item covering `offset` — what a *click* on a
5804 /// rendered checkbox is. Separate from the caret form because a click carries
5805 /// its own offset and must not first move the caret there: ticking a box
5806 /// three paragraphs away should not take the cursor with it.
5807 pub fn toggle_task_at(&mut self, offset: usize) {
5808 // The read-only gate — this door reaches twig without the splice.
5809 if self.read_only {
5810 return;
5811 }
5812 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5813 return;
5814 }
5815 let offset = offset.min(self.source.len());
5816 self.record_caret();
5817 match self.editor.toggle_task_checked(offset) {
5818 Ok(_) => self.after_task_edit(),
5819 Err(e) => self.status = Some(format!("task: {e}")),
5820 }
5821 }
5822
5823 /// Give the list item at the caret a checkbox, or take its checkbox away —
5824 /// the gesture that converts between a plain bullet and a task. A new box
5825 /// arrives unticked.
5826 pub fn toggle_task_item(&mut self) {
5827 // The read-only gate — this door reaches twig without the splice.
5828 if self.read_only {
5829 return;
5830 }
5831 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5832 return;
5833 }
5834 let caret = self.caret.min(self.source.len());
5835 self.record_caret();
5836 match self.editor.toggle_task_item(caret) {
5837 Ok(_) => self.after_task_edit(),
5838 Err(e) => self.status = Some(format!("task: {e}")),
5839 }
5840 }
5841
5842 /// Settle after a task gesture. The caret rides its old byte offset and is
5843 /// clamped back in: a box is three or four bytes on the item's first line, so
5844 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5845 /// real stop either way.
5846 fn after_task_edit(&mut self) {
5847 self.last_edit_kind = None;
5848 self.refresh();
5849 self.anchor = None;
5850 self.dirty = self.source != self.clean_source;
5851 self.status = None;
5852 self.clamp_caret();
5853 self.record_caret();
5854 }
5855
5856 // ── Tables ───────────────────────────────────────────────────────────────
5857 // A table is a grid, and twig edits it as one — add/remove/move a row or
5858 // column, set a column's alignment — re-spelling the whole table in a single
5859 // splice. Every gesture is anchored at the caret's cell. leaf just names the
5860 // gesture and re-reads the result; the whole table's numbering, borders, and
5861 // delimiter are twig's to keep straight.
5862
5863 /// Whether the caret is inside a table — what a frontend asks to enable or
5864 /// disable its table controls.
5865 ///
5866 /// An HTML `<table>` still answers `true`: the caret really is in a table,
5867 /// and the reason the grid controls stay dark there is
5868 /// [`Capabilities::table`], which is a fact about the document's format
5869 /// rather than about the caret. A frontend needs both.
5870 pub fn caret_in_table(&mut self) -> bool {
5871 let caret = self.caret.min(self.source.len());
5872 self.editor
5873 .ancestors_at(caret)
5874 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5875 .unwrap_or(false)
5876 }
5877
5878 /// One grid op, guarded and settled — the shared body of the seven below.
5879 ///
5880 /// The guard is why this exists rather than seven copies of the same three
5881 /// lines, and it is the one guard leaf cannot delegate to twig. The table
5882 /// editor is the gesture family that consults no `Syntax` table (it spells a
5883 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5884 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5885 /// grid as a *pipe table* and reports success, swapping the element out for
5886 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5887 /// downstream could tell that from a successful edit — the splice is real,
5888 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5889 /// stopping at the door rather than detecting after the fact. See
5890 /// [`spells_pipe_tables`].
5891 fn table_op(
5892 &mut self,
5893 what: &str,
5894 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5895 ) {
5896 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5897 return;
5898 }
5899 self.record_caret();
5900 let at = self.caret;
5901 let r = op(&mut self.editor, at);
5902 self.apply_table(r, what);
5903 }
5904
5905 /// Insert an empty row below (`below`) or above the caret's row.
5906 pub fn table_insert_row(&mut self, below: bool) {
5907 self.table_op("table row", |e, at| e.table_insert_row(at, below));
5908 }
5909
5910 /// Delete the caret's row (not the header, not the last body row).
5911 pub fn table_delete_row(&mut self) {
5912 self.table_op("table row", |e, at| e.table_delete_row(at));
5913 }
5914
5915 /// Insert an empty column right (`right`) or left of the caret's column.
5916 pub fn table_insert_column(&mut self, right: bool) {
5917 self.table_op("table column", |e, at| e.table_insert_column(at, right));
5918 }
5919
5920 /// Delete the caret's column (unless it is the only one).
5921 pub fn table_delete_column(&mut self) {
5922 self.table_op("table column", |e, at| e.table_delete_column(at));
5923 }
5924
5925 /// Set the caret's column to `alignment`.
5926 pub fn table_set_alignment(&mut self, alignment: Alignment) {
5927 self.table_op("table alignment", |e, at| {
5928 e.table_set_alignment(at, alignment)
5929 });
5930 }
5931
5932 /// Move the caret's row one place down (`down`) or up, within the body rows.
5933 pub fn table_move_row(&mut self, down: bool) {
5934 self.table_op("table row", |e, at| e.table_move_row(at, down));
5935 }
5936
5937 /// Move the caret's column one place right (`right`) or left.
5938 pub fn table_move_column(&mut self, right: bool) {
5939 self.table_op("table column", |e, at| e.table_move_column(at, right));
5940 }
5941
5942 /// Settle the caret and document flags after a table op (or report its
5943 /// error). twig re-spells the whole table, so the caret rides its old byte
5944 /// offset and is clamped back into the rebuilt bytes — near enough to where
5945 /// it was, since the op preserves the cells' content and order around it.
5946 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5947 match result {
5948 Ok(()) => {
5949 self.last_edit_kind = None;
5950 self.refresh();
5951 self.anchor = None;
5952 self.clamp_caret();
5953 self.dirty = self.source != self.clean_source;
5954 self.status = None;
5955 self.record_caret();
5956 }
5957 Err(e) => self.status = Some(format!("{what}: {e}")),
5958 }
5959 }
5960
5961 /// One `toggle_block_container` over the block-level target.
5962 ///
5963 /// leaf says *where*; twig decides everything else — which blocks the range
5964 /// covers, whether that means wrapping, unwrapping, nesting or converting,
5965 /// and how this document's format spells the prefix. The rule that a
5966 /// container only comes off when the range covers every block it holds is
5967 /// what the re-anchoring below is built around.
5968 fn toggle_container(&mut self, kind: BlockContainerKind) {
5969 // The read-only gate — this door reaches twig without the splice.
5970 if self.read_only {
5971 return;
5972 }
5973 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5974 return;
5975 }
5976 let selected = self.selection();
5977 // A blank line holds no block, and twig opens an *empty* container on one
5978 // — since 3.2.0; it used to decline the range with `NotFound`, which is
5979 // why this used to lend it a scratch paragraph to wrap. Worth knowing
5980 // here because the line-for-line caret mapping below cannot describe it:
5981 // opening one under a paragraph writes the blank line the format needs
5982 // above the marker too, so the rewritten region has a line the old one
5983 // didn't, and "the same line, the same distance from its end" lands on
5984 // that new blank instead of in the container.
5985 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5986 // Without a selection the target is the caret's own block, resolved the
5987 // way `set_block` resolves it — a caret at a line end sits at the doc
5988 // level and has to be nudged back onto the block it looks like it's in.
5989 // An empty range is enough: twig widens to the whole lines it touches.
5990 let (start, end) = match selected {
5991 Some(range) => range,
5992 None => {
5993 let off = self.block_offset_for_caret().unwrap_or(self.caret);
5994 (off, off)
5995 }
5996 };
5997 self.record_caret();
5998 match self.editor.toggle_block_container(start, end, kind) {
5999 Ok(change) => {
6000 // Read the caret's place out of the *pre-edit* source, before
6001 // `refresh` swaps that source out from under it.
6002 let place = (selected.is_none() && !opened_empty)
6003 .then(|| self.caret_line_tail(&change.old));
6004 self.last_edit_kind = None; // structural edit is its own undo step
6005 self.refresh();
6006 match place {
6007 // Both land the caret at the far end of what twig wrote, and
6008 // differ only in what they leave selected.
6009 //
6010 // From a selection: select what the container now holds, the
6011 // way `toggle` keeps its marked region selected — and for a
6012 // stronger reason than symmetry: a container comes *off* only
6013 // a range covering every block it holds, so a selection left
6014 // on its old bytes (now short by a prefix per line) would nest
6015 // on the second press instead of reversing the first.
6016 //
6017 // From a blank line: nothing to select, and the end of the
6018 // region is exactly past the bare `> ` / `- ` twig wrote —
6019 // the caret standing inside the container that was asked for.
6020 None => {
6021 self.anchor = (!opened_empty).then_some(change.new.start);
6022 self.caret = change.new.end;
6023 }
6024 Some(place) => {
6025 self.anchor = None;
6026 self.caret = self.line_tail_offset(&change.new, place);
6027 }
6028 }
6029 self.dirty = self.source != self.clean_source;
6030 self.status = None;
6031 self.clamp_caret();
6032 self.record_caret();
6033 }
6034 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6035 }
6036 }
6037
6038 /// The caret's place inside the region a container toggle is rewriting, in
6039 /// the only terms the rewrite preserves: which of the region's lines it sits
6040 /// on, and how many bytes of that line lie ahead of it.
6041 ///
6042 /// A container's markup goes in at column 0 and never touches what follows
6043 /// on the line, so that pair survives the edit exactly where a byte offset
6044 /// does not — a caret left on its old offset slides back by one prefix per
6045 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
6046 /// `> ` it just asked for.
6047 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
6048 let caret = self.caret.clamp(old.start, old.end);
6049 let line = self.source[old.start..caret].matches('\n').count();
6050 let end = self.source[caret..old.end]
6051 .find('\n')
6052 .map_or(old.end, |i| caret + i);
6053 (line, end - caret)
6054 }
6055
6056 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
6057 /// region: the offset `tail` bytes back from the end of the region's `line`.
6058 ///
6059 /// Both walks are clamped rather than trusted, because the one op that does
6060 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
6061 /// the items back apart with blank lines between them — and a caret landing
6062 /// on the nearest line of the right item beats one landing out of the region
6063 /// entirely.
6064 fn line_tail_offset(
6065 &self,
6066 new: &std::ops::Range<usize>,
6067 (line, tail): (usize, usize),
6068 ) -> usize {
6069 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
6070 let mut start = 0;
6071 for _ in 0..line {
6072 match region[start..].find('\n') {
6073 Some(i) => start += i + 1,
6074 None => break,
6075 }
6076 }
6077 let end = region[start..]
6078 .find('\n')
6079 .map_or(region.len(), |i| start + i);
6080 new.start + end.saturating_sub(tail).max(start)
6081 }
6082
6083 /// Link the selection to `destination` — the toolbar's Link button. With no
6084 /// selection it acts at the caret, which re-points a link the caret is
6085 /// already standing in (twig replaces an existing link's destination and
6086 /// keeps its text) and otherwise spells a link that has no text of its own:
6087 /// an autolink (`<https://x.dev>`) where the destination is one, and
6088 /// `[destination](destination)` where it isn't.
6089 ///
6090 /// `destination` reaches twig raw. Escaping it is format knowledge and the
6091 /// two formats genuinely disagree — Markdown ends a destination at the first
6092 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
6093 /// itself — so the side holding the document is the side that gets to spell
6094 /// it. A destination twig can't carry at all (one with a newline) comes back
6095 /// as an error rather than a quietly rewritten URL.
6096 pub fn insert_link(&mut self, destination: &str) {
6097 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6098 return;
6099 }
6100 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6101 self.record_caret();
6102 match self.editor.insert_link(start, end, destination) {
6103 Ok(change) => {
6104 self.last_edit_kind = None;
6105 self.refresh();
6106 match self.link_text_span(change.new.start) {
6107 // A link with text of its own: select it, so typing replaces
6108 // a `[dest](dest)`'s stand-in label and a second press
6109 // re-points what the first one linked.
6110 Some(text) => {
6111 self.anchor = (text.start != text.end).then_some(text.start);
6112 self.caret = text.end;
6113 }
6114 // An autolink is finished the moment it's written — its text
6115 // *is* the URL. Leaving it selected would aim the next press
6116 // at the one shape twig still wraps instead of re-points.
6117 None => {
6118 self.anchor = None;
6119 self.caret = change.new.end;
6120 }
6121 }
6122 self.dirty = self.source != self.clean_source;
6123 self.status = None;
6124 self.clamp_caret();
6125 self.record_caret();
6126 }
6127 Err(e) => self.status = Some(format!("link: {e}")),
6128 }
6129 }
6130
6131 /// Insert a block-level image at the caret: ``. Any
6132 /// selection becomes the alt text (so "select a caption, insert image" labels
6133 /// it); with no selection, `alt` is used — empty for none. The caret lands
6134 /// just past the inserted image.
6135 ///
6136 /// Both halves go through twig (`insert_literal` for the alt text,
6137 /// `insert_image` for the image), so neither is spelled here. That used to be a
6138 /// `format!`, and it was wrong the first time an app inserted a real filename:
6139 /// Markdown ends a destination at the first space, so `` is
6140 /// not an image at all — and the fix is per-format, since moving into the
6141 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
6142 pub fn insert_image(&mut self, destination: &str, alt: &str) {
6143 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
6144 return;
6145 }
6146 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6147 self.record_caret();
6148 // With no selection and an explicit `alt`, the alt text has to exist in the
6149 // document before it can be the image's — and it is raw caller input, so
6150 // it goes in through `insert_literal`, which escapes it for the format
6151 // rather than letting a `]` in someone's caption close the image early.
6152 let (start, end) = if start == end && !alt.is_empty() {
6153 match self.editor.insert_literal(start, alt) {
6154 Ok(change) => (change.new.start, change.new.end),
6155 Err(e) => {
6156 self.status = Some(format!("image: {e}"));
6157 return;
6158 }
6159 }
6160 } else {
6161 (start, end)
6162 };
6163 match self.editor.insert_image(start, end, destination) {
6164 Ok(change) => {
6165 self.last_edit_kind = None;
6166 self.refresh();
6167 // Just past the image, nothing selected — where a caret belongs
6168 // after inserting one.
6169 self.anchor = None;
6170 self.caret = change.new.end;
6171 self.dirty = self.source != self.clean_source;
6172 self.status = None;
6173 self.clamp_caret();
6174 self.record_caret();
6175 }
6176 Err(e) => self.status = Some(format!("image: {e}")),
6177 }
6178 }
6179
6180 /// Insert a block-level image, video, or audio at the caret. The image case
6181 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
6182 /// HTML elements, which is the only spelling Markdown and Djot have for them:
6183 ///
6184 /// ```text
6185 /// <video src="clip.mp4" controls>alt</video>
6186 /// <audio src="take.mp3" controls>alt</audio>
6187 /// ```
6188 ///
6189 /// HTML rather than a `::video{…}` directive deliberately. A directive means
6190 /// something only to an app that knows the vocabulary, so the document would
6191 /// read as literal punctuation everywhere else; `<video>` is what every other
6192 /// renderer already understands, and what leaf's own reader picks back up
6193 /// through `html_elements` promotion (see [`parse_extensions`]).
6194 ///
6195 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
6196 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
6197 /// `html_elements`. Before that only the multi-line form parsed as a block at
6198 /// all, and this wrote three lines to work around it.
6199 ///
6200 /// `controls` is always written: a player with no transport is a still frame
6201 /// the reader can't do anything with. Any selection becomes the element's
6202 /// fallback text, exactly as it becomes an image's alt.
6203 ///
6204 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
6205 /// applies, and bites harder here: a `"` in `destination` closes the
6206 /// attribute. A frontend taking these from a file picker is fine; one taking
6207 /// them from free text should keep them tame.
6208 ///
6209 /// [`MediaInfo`]: crate::MediaInfo
6210 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6211 if kind == MediaKind::Image {
6212 return self.insert_image(destination, alt);
6213 }
6214 // Gated on the *image* gesture, not on one of its own — there isn't one,
6215 // since the bytes below are spelled here rather than by twig, and an HTML
6216 // document would in fact parse them. The button is one control with three
6217 // kinds behind it, and two of them working in a format where the third
6218 // cannot is a worse surface than three that agree — especially as
6219 // `insert_image` is the kind anyone reaches for first.
6220 if self.refuse_unsupported("media", Gesture::InsertImage) {
6221 return;
6222 }
6223 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6224 let alt_text = self
6225 .selected_text()
6226 .map(str::to_string)
6227 .unwrap_or_else(|| alt.to_string());
6228 let tag = match kind {
6229 MediaKind::Audio => "audio",
6230 _ => "video",
6231 };
6232 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
6233 self.edit(start, end, &markup);
6234 }
6235
6236 /// Append media at the **end** of the document, as a block of its own —
6237 /// the verb for a picture that *arrives* rather than one the writer
6238 /// places: an attachment imported while the caret was wherever it last
6239 /// was, a drawing placed from a tray under the body. At the caret it
6240 /// would land inline in front of whatever word the caret happened to be
6241 /// beside, which for an editor nobody has tapped yet is the first word
6242 /// of the document.
6243 ///
6244 /// The document is ended with a blank line first, where it does not
6245 /// already end with one, so the media is a paragraph of its own rather
6246 /// than a lazy continuation of the last one; an empty document needs no
6247 /// separator. Then [`insert_media`](Self::insert_media) at the new end,
6248 /// with everything that means: the same markup per kind, the same
6249 /// refusal on a format without images. The selection is dropped — the
6250 /// verb is about the end of the document, not about what was selected —
6251 /// and the caret is left past the media, as `insert_media` leaves it.
6252 pub fn append_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6253 if self.read_only || self.refuse_unsupported("media", Gesture::InsertImage) {
6254 return;
6255 }
6256 let separator = if self.source.is_empty() || self.source.ends_with("\n\n") {
6257 ""
6258 } else if self.source.ends_with('\n') {
6259 "\n"
6260 } else {
6261 "\n\n"
6262 };
6263 let end = self.source.len();
6264 self.anchor = None;
6265 self.caret = end;
6266 if !separator.is_empty() {
6267 self.edit(end, end, separator);
6268 }
6269 self.anchor = None;
6270 self.caret = self.source.len();
6271 self.insert_media(kind, destination, alt);
6272 }
6273
6274 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
6275 /// button. Spelling and placement are both twig's; leaf used to write `---`
6276 /// itself, which was the Markdown spelling in a djot document too.
6277 ///
6278 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
6279 /// mid-paragraph and lands it after the caret's whole block. To get a rule
6280 /// *at* the caret — the paragraph parted in two around it, which is what a
6281 /// rule button is understood to do — the paragraph is first divided with
6282 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
6283 /// the offset `split_block` returns puts the rule after the *second* half
6284 /// instead, which is a rule in the right document and the wrong place.
6285 ///
6286 /// Only a plain paragraph is split, and only where there is something to
6287 /// part: at the paragraph's end the split has no second half to mint and
6288 /// would write the separator anyway — a blank line and the empty slot Enter
6289 /// leaves for the next paragraph, which the rule then lands above and
6290 /// nothing fills — so there the rule goes straight after the paragraph,
6291 /// which is where the split-and-aim was sending it regardless. At the
6292 /// paragraph's *start* the split is kept, though it parts nothing either:
6293 /// `|para` becomes `\npara` with the caret on the new blank line, and a
6294 /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
6295 /// "before the paragraph" is said through a gesture that only knows
6296 /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
6297 /// else the rule simply lands after the block, which is both twig's own
6298 /// answer and the better one: splitting a fenced code block would leave two
6299 /// fences with a rule between them, and splitting a list item would mint an
6300 /// item nobody asked for on the way to a rule that lands after the list
6301 /// regardless. A table and a setext heading refuse the split outright, so
6302 /// they take the same path by themselves.
6303 pub fn insert_thematic_break(&mut self) {
6304 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
6305 {
6306 return;
6307 }
6308 self.caret = self.skip_trailing_close_delims(self.caret);
6309 // A selection is replaced by the rule, so collapse it first and let the
6310 // split-and-rule below run from the caret it leaves behind.
6311 if let Some((s, e)) = self.selection() {
6312 self.splice(s, e, "", EditKind::Other);
6313 }
6314 self.anchor = None;
6315 self.record_caret();
6316 let at = self.caret;
6317 if self.caret_parts_bare_paragraph() {
6318 // A failure here is not fatal: the rule still lands after the block,
6319 // which is exactly what this call was trying to improve on.
6320 let _ = self.editor.split_block(at);
6321 }
6322 match self.editor.insert_thematic_break(at) {
6323 Ok(change) => {
6324 self.last_edit_kind = None;
6325 self.refresh();
6326 self.anchor = None;
6327 self.caret = change.new.end;
6328 self.dirty = self.source != self.clean_source;
6329 self.status = None;
6330 self.clamp_caret();
6331 self.record_caret();
6332 }
6333 Err(e) => self.status = Some(format!("thematic break: {e}")),
6334 }
6335 }
6336
6337 /// Insert a fresh table at the caret — the toolbar's Table button. One
6338 /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
6339 /// the document's own dialect and placed the way its thematic break is:
6340 /// after the caret's block, blank-separated. A bare paragraph is parted
6341 /// around the caret first, exactly as
6342 /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
6343 /// table lands *at* the caret rather than after everything the caret's
6344 /// paragraph says.
6345 ///
6346 /// The caret ends in the first header cell, selected the way Tab selects
6347 /// a cell — the natural next act is to type the heading, and Tab then
6348 /// walks the grid. That cell is read back from the rebuilt table map
6349 /// rather than computed from the splice, because twig's blank line and
6350 /// quote prefix put the first bar at an offset only the reparse knows.
6351 ///
6352 /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
6353 /// rows or columns is twig's refusal (a header with nothing under it is
6354 /// what its row delete refuses to leave), reported through `status`.
6355 pub fn insert_table(&mut self, rows: usize, cols: usize) {
6356 if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
6357 return;
6358 }
6359 self.caret = self.skip_trailing_close_delims(self.caret);
6360 if let Some((s, e)) = self.selection() {
6361 self.splice(s, e, "", EditKind::Other);
6362 }
6363 self.anchor = None;
6364 self.record_caret();
6365 let at = self.caret;
6366 if self.caret_parts_bare_paragraph() {
6367 let _ = self.editor.split_block(at);
6368 }
6369 match self.editor.insert_table(at, rows, cols) {
6370 Ok(change) => {
6371 self.last_edit_kind = None;
6372 self.refresh();
6373 self.anchor = None;
6374 self.caret = change.new.end;
6375 self.dirty = self.source != self.clean_source;
6376 self.status = None;
6377 self.clamp_caret();
6378 // Into the first header cell of the table just written: the
6379 // first table whose grid begins inside the splice.
6380 self.rebuild_map();
6381 let first_cell = self
6382 .vmap
6383 .tables
6384 .iter()
6385 .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
6386 .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
6387 .map(|cell| (cell.start, cell.end));
6388 if let Some((start, end)) = first_cell {
6389 self.select_cell(start, end);
6390 }
6391 self.record_caret();
6392 }
6393 Err(e) => self.status = Some(format!("table: {e}")),
6394 }
6395 }
6396
6397 /// Whether the caret sits in a paragraph and nothing else — no list item, no
6398 /// quote, no fence, no table — with paragraph text still ahead of it. The
6399 /// one shape where parting the block around the caret is unambiguously what
6400 /// a rule button means; see
6401 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
6402 /// container is left to take the rule after itself.
6403 ///
6404 /// The "text ahead" half is what keeps `split_block` from running at the
6405 /// one edge where its output composes badly. At a paragraph's end twig
6406 /// cannot mint the empty second half (no format spells an empty
6407 /// paragraph), so it writes only the separator — a blank line and the
6408 /// slot Enter leaves for the paragraph to come — and a block then aimed at
6409 /// the first half lands above a slot that nothing fills: `para\n` with the
6410 /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
6411 /// as nothing ahead, since the split would shed it as the second half's
6412 /// leading indent and leave the same slot. Which end of the newline a
6413 /// paragraph's span stops at differs between the formats (Markdown before
6414 /// it, djot after), which is why this reads the remaining bytes rather
6415 /// than comparing offsets. The paragraph's
6416 /// start is deliberately not the same case — see
6417 /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
6418 /// split is kept.
6419 fn caret_parts_bare_paragraph(&mut self) -> bool {
6420 let caret = self.caret.min(self.source.len());
6421 let Ok(chain) = self.editor.ancestors_at(caret) else {
6422 return false;
6423 };
6424 let mut para_end = None;
6425 for m in chain {
6426 match m.kind {
6427 Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
6428 Kind::ListItem
6429 | Kind::TaskListItem
6430 | Kind::BlockQuote
6431 | Kind::CodeBlock
6432 | Kind::Table => return false,
6433 _ => {}
6434 }
6435 }
6436 match para_end {
6437 Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
6438 _ => false,
6439 }
6440 }
6441
6442 /// The destination of the link under the caret — what a Link prompt shows so
6443 /// ⌘K on an existing link edits its URL instead of asking for it again.
6444 /// `None` when the caret stands in no link.
6445 ///
6446 /// An autolink carries no separate destination: its text *is* the URL, so
6447 /// that's what comes back for one.
6448 pub fn link_destination_at_caret(&mut self) -> Option<String> {
6449 self.link_destination_at(self.caret)
6450 }
6451
6452 /// The destination of the link at `off`.
6453 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
6454 /// the caret isn't.
6455 ///
6456 /// The offset form exists for the same reason
6457 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
6458 /// the document somewhere else — a footnote's text in a popover, say — has
6459 /// rows and runs but no caret in them, and still needs to know which of those
6460 /// runs a reader can follow.
6461 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
6462 self.nodes()
6463 .into_iter()
6464 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
6465 .filter(|n| n.span.start <= off && off < n.span.end)
6466 .max_by_key(|n| n.span.start)
6467 .and_then(|n| n.destination.or(n.text))
6468 }
6469
6470 /// Where the locator `id` lands in this document — the `#v2` half of a
6471 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
6472 /// answers to it.
6473 ///
6474 /// The other end of a link, and the reason this exists: without it a
6475 /// destination has only file granularity, so following a citation into a
6476 /// chapter drops the reader at the top of it to hunt for the verse. Which is
6477 /// also why it is a *document* query rather than a caret one — the document
6478 /// being asked is usually not the one the reader is in.
6479 ///
6480 /// Three readings, tried in order, because the same `#some-heading` is
6481 /// written three ways across the formats leaf opens:
6482 ///
6483 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
6484 /// the auto-ids djot mints for its headings. The only exact answer, so it
6485 /// goes first — a document that says `{#v1}` has settled the question.
6486 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
6487 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
6488 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
6489 /// authored anywhere land on a djot heading.
6490 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
6491 /// none and `{#custom}` is literal text in a Markdown heading — so for
6492 /// the format most vaults are written in, the heading's own words are the
6493 /// only thing a fragment can name. This is the rule every Markdown
6494 /// renderer already follows, which is what makes `#a-heading` mean in
6495 /// diaryx what it means on the web.
6496 ///
6497 /// Ties go to the earliest match, then to the widest: a duplicated id is the
6498 /// document's mistake and the first one is the answer every anchor
6499 /// implementation gives, while preferring the wider span picks the section
6500 /// over the heading that opens it — more for a peek to show, same place to
6501 /// land.
6502 pub fn locate(&mut self, id: &str) -> Option<Landing> {
6503 let id = id.trim();
6504 if id.is_empty() {
6505 return None;
6506 }
6507 let nodes = self.nodes();
6508
6509 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
6510 // is picking, among nodes that start together, the one that ends last.
6511 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
6512 matches
6513 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
6514 .map(|n| Landing {
6515 start: n.span.start,
6516 end: n.span.end,
6517 })
6518 };
6519
6520 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
6521 return Some(landing);
6522 }
6523 let want = slug(id);
6524 if want.is_empty() {
6525 return None;
6526 }
6527 if let Some(landing) = pick(
6528 &mut nodes
6529 .iter()
6530 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
6531 ) {
6532 return Some(landing);
6533 }
6534
6535 // A heading by its words. Its span is one line, so the end comes from
6536 // where the *section* it opens gives out — the next heading that is not
6537 // under it, or the end of the document. A Markdown heading has no
6538 // section node to ask (twig only builds those for djot), and a peek that
6539 // showed the heading alone would answer "what does that say" with the
6540 // title of the thing it says.
6541 let heading = nodes
6542 .iter()
6543 .filter(|n| n.kind == Kind::Heading)
6544 .filter(|n| {
6545 n.content_span
6546 .clone()
6547 .and_then(|s| self.source.get(s))
6548 .is_some_and(|text| slug(text) == want)
6549 })
6550 .min_by_key(|n| n.span.start)?;
6551 let level = heading.level.unwrap_or(u32::MAX);
6552 let end = nodes
6553 .iter()
6554 .filter(|n| n.kind == Kind::Heading)
6555 .filter(|n| n.span.start > heading.span.start)
6556 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
6557 .map(|n| n.span.start)
6558 .min()
6559 .unwrap_or(self.source.len());
6560 Some(Landing {
6561 start: heading.span.start,
6562 end,
6563 })
6564 }
6565
6566 /// Write a footnote at the caret — the toolbar's Footnote button, and the
6567 /// one gesture in the footnote story that *authors* rather than follows.
6568 ///
6569 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6570 /// the `[^1]:` definition at the end of the document. Half a footnote is not
6571 /// a footnote — a bare reference with nothing defining it renders as literal
6572 /// brackets — so a single button that wrote only the reference would leave
6573 /// the author to hand-spell the other half in a document that had just
6574 /// stopped showing them what the first half meant. One edit also means one
6575 /// undo takes both back.
6576 ///
6577 /// The definition's body is left empty and **the caret lands in it**, which
6578 /// is the whole point of pressing the button: nobody wants a reference to a
6579 /// note they have not written yet. Getting back to where they were writing
6580 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6581 /// the same return leg a reader following a reference already uses, so the
6582 /// author is left standing on the near end of a round trip that works.
6583 ///
6584 /// A selection collapses to its *end* rather than being replaced: a
6585 /// reference annotates the words before it, so "select the claim, add a
6586 /// footnote" should mark that claim, not consume it.
6587 pub fn insert_footnote(&mut self) {
6588 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6589 return;
6590 }
6591 let at = self.selection().map_or(self.caret, |(_, end)| end);
6592 self.anchor = None;
6593 self.caret = at;
6594 self.record_caret();
6595 let label = self.next_footnote_label();
6596 match self.editor.insert_footnote(at, &label) {
6597 Ok(change) => {
6598 self.last_edit_kind = None;
6599 self.refresh();
6600 self.anchor = None;
6601 // `change.new` runs from the reference to the end of the
6602 // document, so its start is the `[^1]` just written and
6603 // `footnote_at` resolves it to the note the same way a reader's
6604 // tap does — and to the note's *body*, which is already a caret
6605 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6606 // and has none), so this needs no snap on top. The fallback is
6607 // the reference's own offset: a format that spelled the pair some
6608 // way leaf can't read back should still leave the caret on the
6609 // edit rather than at the far end of a document it just grew.
6610 self.caret = self
6611 .footnote_at(change.new.start)
6612 .and_then(|note| note.offset)
6613 .unwrap_or(change.new.start);
6614 self.dirty = self.source != self.clean_source;
6615 self.status = None;
6616 self.clamp_caret();
6617 self.record_caret();
6618 }
6619 Err(e) => self.status = Some(format!("footnote: {e}")),
6620 }
6621 }
6622
6623 /// The label to give a footnote the author has not named: the lowest counting
6624 /// number no footnote in the document is already wearing.
6625 ///
6626 /// twig takes the label rather than minting one, because it holds no opinion
6627 /// about what a document's footnotes should be called — and it is right not
6628 /// to. Numbering them is what every author of a numbered note expects, and
6629 /// re-using a taken number would silently point the new reference at somebody
6630 /// else's note (twig reuses an existing definition rather than appending a
6631 /// second one, which is the right rule for citing a note twice on purpose and
6632 /// exactly the wrong accident to have by default).
6633 ///
6634 /// *References* are counted alongside definitions, not just definitions: a
6635 /// document carrying a dangling `[^2]` has a 2 that means something to
6636 /// whoever wrote it, and minting a definition for it here would answer a
6637 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6638 /// count entirely — they take no number, so they block none.
6639 fn next_footnote_label(&mut self) -> String {
6640 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6641 .into_iter()
6642 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6643 .filter_map(|label| label.parse().ok())
6644 .collect();
6645 taken.extend(
6646 self.nodes()
6647 .into_iter()
6648 .filter(|n| n.kind == Kind::FootnoteReference)
6649 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6650 .filter_map(|label| label.parse::<u32>().ok()),
6651 );
6652 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6653 }
6654
6655 /// The footnote reference under the caret, resolved to the note it names.
6656 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6657 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6658 self.footnote_at(self.caret)
6659 }
6660
6661 /// The footnote reference at `off`, resolved to the note it names — what a
6662 /// frontend shows when a reader activates a `[^1]`.
6663 ///
6664 /// A reference is not a link node, so
6665 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6666 /// (and should not) answer for one: a link names a destination to leave for,
6667 /// a reference names a note that is already in this document. Following one
6668 /// is a move within the page, which is why this hands back an `offset`
6669 /// rather than something to open.
6670 ///
6671 /// Offset-based rather than caret-only because the gesture that wants this
6672 /// most is the one that must not move the caret: a pointer hovering a `[1]`
6673 /// asks what note it names without disturbing where the reader was typing.
6674 /// The caret is just the offset a click already placed —
6675 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6676 ///
6677 /// `None` when `off` stands in no reference. A reference whose note the
6678 /// document never defines is *not* `None` — it answers with the label it
6679 /// looked for and no text, which is what lets a frontend say so instead of
6680 /// silently doing nothing.
6681 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6682 // Innermost-wins by latest start, the rule its link sibling uses.
6683 let span = self
6684 .nodes()
6685 .into_iter()
6686 .filter(|n| n.kind == Kind::FootnoteReference)
6687 .filter(|n| n.span.start <= off && off < n.span.end)
6688 .max_by_key(|n| n.span.start)?
6689 .span;
6690 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6691
6692 // The note itself. Definitions are roots beside `doc` rather than
6693 // children of it, so they're asked for directly — see
6694 // `wysiwyg::footnote_definitions`.
6695 let note = wysiwyg::footnote_definitions(&mut self.editor)
6696 .into_iter()
6697 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
6698 let Some(note) = note else {
6699 return Some(FootnoteRef {
6700 label,
6701 text: None,
6702 offset: None,
6703 end: None,
6704 });
6705 };
6706 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
6707 Some(FootnoteRef {
6708 label,
6709 text: body
6710 .clone()
6711 .and_then(|b| self.source.get(b))
6712 .map(str::to_string),
6713 // The body's start, not the definition's — see `FootnoteRef::offset`.
6714 offset: body.clone().map(|b| b.start),
6715 end: body.map(|b| b.end),
6716 })
6717 }
6718
6719 /// The footnote *definition* the caret stands in, and where the reference
6720 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6721 /// at the caret's offset.
6722 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6723 self.footnote_definition_at(self.caret)
6724 }
6725
6726 /// The footnote definition spanning `off`, and where the reference that
6727 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6728 ///
6729 /// The mirror image, deliberately: the same gesture that takes a reader from
6730 /// `[1]` down to the note takes them from the note back up to `[1]`, so
6731 /// following a footnote is a round trip rather than a fall. It needs no
6732 /// memory of how the reader arrived — the document says where the reference
6733 /// is — which is what makes it work for a reader who scrolled to the notes
6734 /// themselves, and what keeps it right after an edit moves either end.
6735 ///
6736 /// `None` when `off` stands in no definition. A definition nothing cites is
6737 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6738 /// its label and no offset, so a frontend can say "nothing refers to this"
6739 /// rather than offer a jump that goes nowhere.
6740 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6741 // Definitions are roots beside `doc`, so `nodes()` — which walks the
6742 // document body — never reports one. They're asked for directly, the way
6743 // `footnote_at` asks for the note it resolves to.
6744 //
6745 // Closed at the end, unlike the half-open test its neighbours use. A
6746 // definition's span stops at its last content byte — the newline ending
6747 // the line is outside it — so `span.end` is the caret stop at the end of
6748 // the note's own row, not the first byte of anything after. Excluding it
6749 // meant the one caret an author is guaranteed to have, the one left
6750 // sitting at the end of the note they just typed, was in no definition at
6751 // all: writing a note and then asking to go back to its reference
6752 // answered nothing. Two definitions in a row still can't both match —
6753 // there is a blank line between them — and `max_by_key` decides anyway.
6754 let note = wysiwyg::footnote_definitions(&mut self.editor)
6755 .into_iter()
6756 .filter(|m| m.span.start <= off && off <= m.span.end)
6757 .max_by_key(|m| m.span.start)?;
6758 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6759
6760 // The earliest reference carrying this label. `min` rather than a `find`,
6761 // because `nodes()` reports a flattened walk whose order is twig's
6762 // business, not document order. Bound first: the walk needs `&mut self`
6763 // and reading the labels back out needs `&self.source`.
6764 let nodes = self.nodes();
6765 let offset = nodes
6766 .into_iter()
6767 .filter(|n| n.kind == Kind::FootnoteReference)
6768 .filter(|n| {
6769 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6770 })
6771 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6772 .map(|n| n.span.start + 2)
6773 .min();
6774 Some(FootnoteDef { label, offset })
6775 }
6776
6777 /// The destination of the image under the caret — what an image prompt shows
6778 /// so editing an existing image starts from its current URL instead of blank,
6779 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6780 /// `None` when the caret stands in no image. A caret resting just after a
6781 /// block image (its trailing stop) is still "in" it — the half-open span test
6782 /// excludes that offset, which is the intended precision: past the image is
6783 /// past it.
6784 pub fn image_destination_at_caret(&mut self) -> Option<String> {
6785 let off = self.caret;
6786 self.nodes()
6787 .into_iter()
6788 .filter(|n| n.kind == Kind::Image)
6789 .filter(|n| n.span.start <= off && off < n.span.end)
6790 .max_by_key(|n| n.span.start)
6791 .and_then(|n| n.destination)
6792 }
6793
6794 /// The language of the fenced code block the caret stands in — what a
6795 /// language prompt shows so editing it starts from the current value rather
6796 /// than blank. `None` when the caret is in no code block, or in one whose
6797 /// fence carries no language (or an indented block, which has no fence).
6798 pub fn code_language_at_caret(&mut self) -> Option<String> {
6799 let start = self.code_block_start_at_caret()?;
6800 wysiwyg::code_language(&self.source, start)
6801 }
6802
6803 /// Whether the caret stands in a fenced code block — the one a language
6804 /// prompt could edit. A frontend gates its "set language" affordance on this
6805 /// (an indented block, which can't carry a language, reports `false`).
6806 pub fn caret_in_fenced_code(&mut self) -> bool {
6807 self.code_block_start_at_caret()
6808 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6809 }
6810
6811 /// Set (or clear, with `""`) the language of the fenced code block the caret
6812 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6813 /// block, and a reported error for a language the format's fence cannot
6814 /// carry.
6815 ///
6816 /// twig rewrites the info string, so the fence's own width — measured
6817 /// against a body neither side touches — is kept, and a language holding a
6818 /// space, a line end or the fence character is refused rather than written
6819 /// out to reparse as something else. Leaf used to splice over the info span
6820 /// itself and `trim()` the input, which handled the one bad case it had
6821 /// thought of.
6822 pub fn set_code_language(&mut self, lang: &str) {
6823 // The read-only gate — this door reaches twig without the splice.
6824 if self.read_only {
6825 return;
6826 }
6827 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6828 return;
6829 }
6830 if self.code_block_start_at_caret().is_none() {
6831 return;
6832 }
6833 let lang = lang.trim();
6834 // `None` clears the info string; `Some("")` asks for an empty one. Both
6835 // write a bare fence, and the prompt's empty value means "clear".
6836 let want = (!lang.is_empty()).then_some(lang);
6837 self.record_caret();
6838 match self.editor.set_code_language(self.caret, want) {
6839 Ok(_) => {
6840 self.last_edit_kind = None;
6841 self.refresh();
6842 self.anchor = None;
6843 self.dirty = self.source != self.clean_source;
6844 self.status = None;
6845 self.clamp_caret();
6846 self.record_caret();
6847 }
6848 Err(e) => self.status = Some(format!("code language: {e}")),
6849 }
6850 }
6851
6852 /// The `span.start` of the code block covering the caret — the anchor
6853 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6854 /// in none.
6855 fn code_block_start_at_caret(&mut self) -> Option<usize> {
6856 let off = self.caret;
6857 self.nodes()
6858 .into_iter()
6859 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6860 .max_by_key(|n| n.span.start)
6861 .map(|n| n.span.start)
6862 }
6863
6864 /// The source range of the text inside the link covering `off` — what sits
6865 /// between its `[` and `]`. `None` when twig reports no link there.
6866 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6867 self.nodes()
6868 .into_iter()
6869 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6870 // the other's `span.start`; the link that starts latest at or before
6871 // `off` is the one `off` is actually in.
6872 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6873 .max_by_key(|n| n.span.start)
6874 .and_then(|n| n.content_span)
6875 }
6876
6877 // ── undo / redo ───────────────────────────────────────────────────────────
6878 // twig owns the history of *bytes* (it owns the buffer) and now carries the
6879 // caret through it too: `record_caret` stashes each state's caret in twig's
6880 // opaque per-step blob, and undo/redo hand it back with the source they
6881 // restore. So leaf keeps no history of its own — no parallel stacks to march
6882 // in lockstep and silently drift out of it.
6883
6884 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6885 /// where they were when that step began.
6886 pub fn undo(&mut self) {
6887 if self.read_only {
6888 return;
6889 }
6890 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6891 match self.editor.undo() {
6892 Ok(Some(change)) => {
6893 self.after_history(change);
6894 // `refresh` counted the restore as an edit; it was a step back.
6895 self.undo_steps = undone.saturating_sub(1);
6896 self.redo_steps = redoable + 1;
6897 }
6898 Ok(None) => {
6899 self.undo_steps = 0;
6900 self.status = Some("nothing to undo".into());
6901 }
6902 Err(e) => self.status = Some(format!("undo: {e}")),
6903 }
6904 }
6905
6906 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6907 /// selection back where that step originally left them.
6908 pub fn redo(&mut self) {
6909 if self.read_only {
6910 return;
6911 }
6912 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6913 match self.editor.redo() {
6914 Ok(Some(change)) => {
6915 self.after_history(change);
6916 // `refresh` counted the restore as an edit; it was a step forward.
6917 self.undo_steps = undone + 1;
6918 self.redo_steps = redoable.saturating_sub(1);
6919 }
6920 Ok(None) => {
6921 self.redo_steps = 0;
6922 self.status = Some("nothing to redo".into());
6923 }
6924 Err(e) => self.status = Some(format!("redo: {e}")),
6925 }
6926 }
6927
6928 /// Refresh the cached source and put the caret back where the step being
6929 /// undone/redone had it, clearing any active run.
6930 ///
6931 /// The caret comes from twig's blob for the restored state (what
6932 /// `record_caret` stored). `change` is only the fallback for a state with no
6933 /// blob — a caret at the end of the restored text, which is where this always
6934 /// landed before the blobs were kept. It is the edit site, not where the user
6935 /// was standing, so it's a floor and not the behaviour: undoing should hand
6936 /// back the document *and* the place you were working, which for an edit made
6937 /// anywhere but under the caret are two different places.
6938 fn after_history(&mut self, change: Change) {
6939 self.refresh();
6940 match self
6941 .editor
6942 .caret_blob()
6943 .ok()
6944 .and_then(|b| CaretState::from_blob(&b))
6945 {
6946 Some(state) => {
6947 self.caret = state.caret.min(self.source.len());
6948 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6949 }
6950 None => {
6951 self.caret = change.new.end.min(self.source.len());
6952 self.anchor = None;
6953 }
6954 }
6955 self.goal_col = None;
6956 self.last_edit_kind = None;
6957 self.dirty = self.source != self.clean_source;
6958 self.status = None;
6959 self.clamp_caret();
6960 }
6961
6962 // ── the file ──────────────────────────────────────────────────────────────
6963
6964 #[cfg(feature = "fs")]
6965 pub fn save(&mut self) {
6966 if self.is_untitled() {
6967 // No path to write and no name to invent: ⌘S on an untitled document
6968 // is a Save As, and only a frontend has a picker to ask with. Say so
6969 // rather than failing at the filesystem with an empty path.
6970 self.status = Some("untitled — save as…".into());
6971 return;
6972 }
6973 let path = self.path.clone();
6974 if self.write(&path) {
6975 self.mark_saved();
6976 }
6977 }
6978
6979 /// Save As: write the document to `path` and *move* it there — `self.path`
6980 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6981 /// what Save As means; a copy would leave the user editing a document whose
6982 /// name is no longer where their keystrokes go.
6983 ///
6984 /// The move only happens if the bytes actually landed. A failed write leaves
6985 /// the path, `dirty`, and the disk watermark exactly as they were, with the
6986 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6987 /// must never come away believing it was saved.
6988 ///
6989 /// An existing `path` is overwritten, and the caller is the one that knows
6990 /// whether to ask first: a Save As picker has already run that prompt, and a
6991 /// second confirmation from down here would be the same question twice.
6992 ///
6993 /// `format` does **not** follow the new extension. The buffer is parsed as
6994 /// the format it was opened with, and re-reading it as another one is a
6995 /// conversion — a different, lossy operation that would throw away the undo
6996 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6997 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6998 /// until it's reopened.
6999 #[cfg(feature = "fs")]
7000 pub fn save_as(&mut self, path: PathBuf) {
7001 if !self.write(&path) {
7002 return;
7003 }
7004 self.path = path;
7005 self.mark_saved();
7006 }
7007
7008 /// Put `source` on disk at `path`, reporting whether it got there. The one
7009 /// place leaf writes a document, so a save and a Save As can't disagree
7010 /// about what a failure looks like.
7011 #[cfg(feature = "fs")]
7012 fn write(&mut self, path: &Path) -> bool {
7013 match std::fs::write(path, self.source.as_bytes()) {
7014 Ok(()) => true,
7015 Err(e) => {
7016 self.status = Some(format!("save failed: {e}"));
7017 false
7018 }
7019 }
7020 }
7021
7022 /// Re-base the document's saved watermark to the current bytes: clears
7023 /// `dirty`, records `source` as the new clean state (so undoing back to here
7024 /// clears the flag again), and re-stamps the on-disk hash.
7025 ///
7026 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
7027 /// the hook a **filesystem-free host** calls itself once it has persisted
7028 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
7029 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
7030 /// are already where that host wants them, and this just tells the model they
7031 /// are safe.
7032 pub fn mark_saved(&mut self) {
7033 self.clean_source = self.source.clone();
7034 self.dirty = false;
7035 // The bytes on disk are now ours, so this is the new watermark: without
7036 // re-stamping it, every save would report its own work as an external
7037 // change forever after.
7038 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
7039 self.status = Some(format!("saved {}", self.file_name()));
7040 }
7041
7042 /// What the file looks like now against the bytes leaf last read or wrote.
7043 ///
7044 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
7045 /// so this is a filesystem round-trip, not a per-frame question — ask it
7046 /// when a window regains focus, on a timer, or before a save.
7047 ///
7048 /// This *only* reports the file. Whether the document also has unsaved edits
7049 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
7050 /// [`DiskState::Changed`] means a save overwrites someone's work and a
7051 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
7052 /// it has no way to ask — so it hands a frontend both halves and lets it put
7053 /// the question to the person who can answer it.
7054 #[cfg(feature = "fs")]
7055 pub fn disk_state(&self) -> DiskState {
7056 let Some(want) = self.disk_hash else {
7057 return DiskState::Untitled;
7058 };
7059 match std::fs::read(&self.path) {
7060 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
7061 Ok(_) => DiskState::Changed,
7062 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
7063 Err(_) => DiskState::Unreadable,
7064 }
7065 }
7066
7067 /// Re-read the file and replace the document with what's there — the other
7068 /// answer to a [`DiskState::Changed`].
7069 ///
7070 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
7071 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
7072 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
7073 /// document shouldn't have to argue with a guard.
7074 ///
7075 /// **The undo history survives, and the reload is one step in it.** The
7076 /// whole buffer is spliced with the file's bytes through the same door every
7077 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
7078 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
7079 /// swapped the document out from under a reader gives them back what they
7080 /// were looking at, marked dirty, and ^Z again carries on into whatever they
7081 /// had done before it. This used to build a fresh parse and drop the stack,
7082 /// on the reasoning that twig's history belongs to the buffer and these are
7083 /// different bytes; that is true of *rebasing* a step onto them and not of
7084 /// recording the swap itself as one, which is all this is. A splice twig
7085 /// won't take falls back to the fresh parse, and only that path still costs
7086 /// the history.
7087 ///
7088 /// The caret keeps its byte offset, clamped to the new length; the selection
7089 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
7090 /// file changed, so it can't know where the caret "still" is. Clamping keeps
7091 /// it where the user left it in the common case (a change further down the
7092 /// file, or none in the text they're sitting in), and never puts it
7093 /// somewhere invalid. A selection has two such offsets and no such excuse —
7094 /// silently reinterpreting one over changed bytes would arm the *next*
7095 /// keystroke to delete something the user never selected.
7096 ///
7097 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
7098 /// document alone with a status.
7099 #[cfg(feature = "fs")]
7100 pub fn reload(&mut self) {
7101 if self.is_untitled() {
7102 self.status = Some("no file to reload".into());
7103 return;
7104 }
7105 let bytes = match std::fs::read(&self.path) {
7106 Ok(b) => b,
7107 Err(e) => {
7108 self.status = Some(format!("reload failed: {e}"));
7109 return;
7110 }
7111 };
7112 let Ok(source) = String::from_utf8(bytes) else {
7113 self.status = Some("reload failed: file is not UTF-8".into());
7114 return;
7115 };
7116 // Already these bytes — someone saved a file back unchanged, or leaf's
7117 // own write is being read back. Re-baseline against it and stop: a
7118 // splice of the text onto itself would put an undo step on the stack for
7119 // something nobody did.
7120 if source == self.source {
7121 self.disk_hash = Some(hash_bytes(source.as_bytes()));
7122 self.clean_source = source;
7123 self.dirty = false;
7124 self.status = Some(format!("reloaded {}", self.file_name()));
7125 return;
7126 }
7127 let caret = self.caret;
7128 // The pre-reload caret, so undoing the swap puts it back where the
7129 // reader was standing — the same bracketing `splice_exact` does.
7130 self.record_caret();
7131 if self
7132 .editor
7133 .edit_range(0, self.source.len(), &source)
7134 .is_ok()
7135 {
7136 self.refresh();
7137 } else {
7138 // twig wouldn't take the splice. Start over from the bytes, which is
7139 // what this always did, and is the one path that still costs the
7140 // history — `format` is the format this document *is*, not what the
7141 // (unchanged) name now says, see `save_as`.
7142 match new_editor(source.as_bytes(), self.format) {
7143 Ok(editor) => {
7144 self.editor = editor;
7145 self.source = source.clone();
7146 // Not going through `refresh`, so the revision has to move
7147 // here or every frontend keeps painting the old file from
7148 // cache.
7149 self.revision += 1;
7150 }
7151 Err(e) => {
7152 self.status = Some(format!("reload failed: {e}"));
7153 return;
7154 }
7155 }
7156 }
7157 self.disk_hash = Some(hash_bytes(source.as_bytes()));
7158 self.clean_source = self.source.clone();
7159 self.caret = caret.min(self.source.len());
7160 self.anchor = None;
7161 self.goal_col = None;
7162 self.last_edit_kind = None;
7163 self.dirty = false;
7164 self.status = Some(format!("reloaded {}", self.file_name()));
7165 self.clamp_caret();
7166 // And the post-reload caret, so a redo restores it.
7167 self.record_caret();
7168 }
7169
7170 /// Re-read the source from twig after it has changed the document. The one
7171 /// funnel every edit, undo, and redo comes through — so it's where the
7172 /// revision moves, and anything cached against the text dies here.
7173 fn refresh(&mut self) {
7174 if let Ok(s) = self.editor.source_str() {
7175 self.source = s;
7176 }
7177 self.revision += 1;
7178 // An edit is a step onto the history and the end of anything undone;
7179 // `undo`/`redo` come through here too and correct this after.
7180 self.undo_steps += 1;
7181 self.redo_steps = 0;
7182 self.clamp_caret();
7183 }
7184
7185 /// Whether [`undo`](Self::undo) has a step to take back — for a native
7186 /// Edit menu to enable its item by. See the note on `undo_steps` for what
7187 /// "has" means here.
7188 pub fn can_undo(&self) -> bool {
7189 !self.read_only && self.undo_steps > 0
7190 }
7191
7192 /// Whether [`redo`](Self::redo) has an undone step to restore.
7193 pub fn can_redo(&self) -> bool {
7194 !self.read_only && self.redo_steps > 0
7195 }
7196
7197 // ── caret movement ─────────────────────────────────────────────────────────
7198 // `extend` grows the selection (Shift+motion): it pins the anchor on the
7199 // first extended step and moves only the caret; an un-extended motion drops
7200 // the selection.
7201
7202 /// Place the caret at byte `offset` (clamped to a char boundary), extending
7203 /// the selection when `extend` is set. The public form of `move_to`, for a
7204 /// frontend that hit-tests pixels straight to a source offset.
7205 pub fn place_caret(&mut self, offset: usize, extend: bool) {
7206 self.goal_col = None;
7207 let before = self.caret;
7208 // A pixel hit-test can land between the visible caret stops — in the
7209 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
7210 // Snap to the nearest real stop so the caret can't come to rest where it
7211 // would draw in one place and type in another. The `(row, col)` click
7212 // path (`click`) already snaps this way through `offset_of_pos`; the
7213 // source view reaches every byte, so it snaps to nothing.
7214 let target = match self.view {
7215 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
7216 // The source view reaches every byte, so there is no stop to snap
7217 // to — but "every byte" still means every *character* boundary. A
7218 // caret resting inside a multi-byte character draws nowhere real
7219 // and panics the next time anything slices there.
7220 View::Source => self.char_boundary_at_or_before(offset),
7221 };
7222 self.move_to(target, extend);
7223 self.clamp_caret();
7224 self.debug_assert_on_a_stop(before);
7225 }
7226
7227 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
7228 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
7229 /// grab the metadata) while the source view still selects the literal whole.
7230 pub fn select_all(&mut self) {
7231 self.anchor = Some(self.caret_floor());
7232 self.caret = self.source.len();
7233 self.goal_col = None;
7234 self.last_edit_kind = None;
7235 self.status = None;
7236 }
7237
7238 /// Select the word (or whitespace / punctuation run) at `offset` — the
7239 /// double-click gesture. Anchors on the run's start with the caret at its
7240 /// end so a following Shift-motion extends from the far edge.
7241 pub fn select_word_at(&mut self, offset: usize) {
7242 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
7243 self.anchor = Some(s);
7244 self.caret = e;
7245 self.goal_col = None;
7246 self.last_edit_kind = None;
7247 self.status = None;
7248 self.clamp_caret();
7249 }
7250
7251 /// Select the whole enclosing text block (paragraph, heading, list item's
7252 /// text…) at `offset` — the triple-click gesture. Reads the range straight
7253 /// from the AST (twig's `content_span`), so it selects the entire *logical*
7254 /// paragraph even when that paragraph soft-wraps across several visual rows —
7255 /// where a visual-row-based select breaks down, because one source offset at
7256 /// a wrap boundary belongs to two rows at once.
7257 pub fn select_block_at(&mut self, offset: usize) {
7258 let off = offset.min(self.source.len());
7259 let range = self
7260 .editor
7261 .ancestors_at(off)
7262 .ok()
7263 .and_then(|chain| {
7264 // Ancestors run root → deepest; the deepest node that is neither
7265 // an inline span nor a multi-block container is the text block
7266 // the caret sits in (a paragraph, a heading, a code block…).
7267 chain
7268 .into_iter()
7269 .rev()
7270 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
7271 .map(|m| m.content_span.unwrap_or(m.span))
7272 })
7273 .unwrap_or_else(|| source_line_range(&self.source, off));
7274 self.anchor = Some(range.start.min(self.source.len()));
7275 self.caret = range.end.min(self.source.len());
7276 self.goal_col = None;
7277 self.last_edit_kind = None;
7278 self.status = None;
7279 self.clamp_caret();
7280 }
7281
7282 /// Select the exact source range `[start, end)` — anchor at `start`, caret
7283 /// at `end` — without snapping either end to a visible caret stop.
7284 ///
7285 /// The one caret verb that takes a range it was *handed* rather than one it
7286 /// worked out, for a host that already knows the bytes it means: a search
7287 /// hit, an annotation's footprint, a quote re-anchored through
7288 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
7289 /// wrong tool for that, and not by a little — it snaps to the nearest
7290 /// *visible* stop, and where a range butts up against a hidden delimiter
7291 /// the nearest stop is the one before it, so selecting the "needle" of
7292 /// `**needle**` comes back with "needl" and an edit against it strands the
7293 /// "e".
7294 ///
7295 /// What `place_caret` does that is bookkeeping rather than snapping still
7296 /// happens here, because a host handing in a range is not asking to opt out
7297 /// of the invariants:
7298 ///
7299 /// - both ends are clamped into the document and up to
7300 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
7301 /// frontmatter is hidden, and a caret parked in it draws nowhere and
7302 /// types into the metadata;
7303 /// - both land on character boundaries, so nothing slices a `é` in half;
7304 /// - the sticky vertical goal column is dropped, and any armed inline mark
7305 /// disarmed, since a range from outside inherits neither.
7306 ///
7307 /// An empty range is a caret rather than a selection —
7308 /// [`selection`](Self::selection) reports `None` for it, as it does for any
7309 /// anchor that has met the caret.
7310 pub fn select_range(&mut self, start: usize, end: usize) {
7311 let floor = self.caret_floor();
7312 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
7313 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
7314 self.anchor = Some(anchor);
7315 self.caret = caret;
7316 self.goal_col = None;
7317 self.status = None;
7318 self.last_edit_kind = None;
7319 self.clear_pending();
7320 }
7321
7322 /// `offset` itself if it is a character boundary, else the boundary before
7323 /// it. An offset that isn't one draws nowhere real and panics the next time
7324 /// anything slices there.
7325 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
7326 let mut o = offset.min(self.source.len());
7327 while o > 0 && !self.source.is_char_boundary(o) {
7328 o -= 1;
7329 }
7330 o
7331 }
7332
7333 /// The lowest source offset the caret may occupy in the active view. In
7334 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
7335 /// the first rendered offset; the source view reaches everything, so it's 0.
7336 fn caret_floor(&self) -> usize {
7337 match self.view {
7338 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
7339 View::Source => 0,
7340 }
7341 }
7342
7343 /// Land in a table cell with its whole content selected — the anchor at the
7344 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
7345 /// like tabbing into a form field: the text comes up selected, so typing
7346 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
7347 /// end`) collapses to a plain caret home (an empty selection is no selection).
7348 fn select_cell(&mut self, start: usize, end: usize) {
7349 self.select_range(start, end);
7350 }
7351
7352 fn move_to(&mut self, offset: usize, extend: bool) {
7353 if extend {
7354 if self.anchor.is_none() {
7355 self.anchor = Some(self.caret);
7356 }
7357 } else {
7358 self.anchor = None;
7359 }
7360 self.caret = offset.min(self.source.len()).max(self.caret_floor());
7361 self.status = None;
7362 // A caret move ends the current typing/deletion run, so the next edit
7363 // starts a fresh undo group rather than coalescing across the gap.
7364 self.last_edit_kind = None;
7365 // Moving away disarms any sticky mark — "start bold" applies only where
7366 // it was asked for, not wherever the caret next lands.
7367 self.clear_pending();
7368 }
7369
7370 // In the source view, motion walks source bytes / source lines. In the
7371 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
7372 // what steps the caret cleanly over hidden delimiters.
7373
7374 pub fn move_left(&mut self, extend: bool) {
7375 self.goal_col = None;
7376 if !extend && let Some((s, _e)) = self.selection() {
7377 self.move_to(s, false);
7378 return;
7379 }
7380 let target = match self.view {
7381 View::Source => {
7382 if self.caret > 0 {
7383 prev_boundary(&self.source, self.caret)
7384 } else {
7385 0
7386 }
7387 }
7388 // Walks caret *stops*, not columns: decoration (a table border, a
7389 // cell's padding) is stepped over in one press, and a hidden
7390 // delimiter never holds the caret up — though the end of a mark's
7391 // content is a stop of its own (`VisualMap::mark_ends`), so
7392 // leaving `**bold**` from past its `**` is a press onto the end of
7393 // the bold and another onto the `d`.
7394 View::Wysiwyg => self
7395 .vmap
7396 .caret_stop_before(self.caret)
7397 .unwrap_or(self.caret),
7398 };
7399 let before = self.caret;
7400 self.move_to(target, extend);
7401 self.debug_assert_on_a_stop(before);
7402 }
7403
7404 pub fn move_right(&mut self, extend: bool) {
7405 self.goal_col = None;
7406 if !extend && let Some((_s, e)) = self.selection() {
7407 self.move_to(e, false);
7408 return;
7409 }
7410 let target = match self.view {
7411 View::Source => {
7412 if self.caret < self.source.len() {
7413 next_boundary(&self.source, self.caret)
7414 } else {
7415 self.caret
7416 }
7417 }
7418 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
7419 };
7420 let before = self.caret;
7421 self.move_to(target, extend);
7422 self.debug_assert_on_a_stop(before);
7423 }
7424
7425 /// Move to the start of the previous word (⌥← / Ctrl+←).
7426 pub fn move_word_left(&mut self, extend: bool) {
7427 self.goal_col = None;
7428 let before = self.caret;
7429 let target = self.word_left_from(self.caret);
7430 self.move_to(target, extend);
7431 self.debug_assert_on_a_stop(before);
7432 }
7433
7434 /// Move to the end of the next word (⌥→ / Ctrl+→).
7435 pub fn move_word_right(&mut self, extend: bool) {
7436 self.goal_col = None;
7437 let before = self.caret;
7438 let target = self.word_right_from(self.caret);
7439 self.move_to(target, extend);
7440 self.debug_assert_on_a_stop(before);
7441 }
7442
7443 // Word boundaries are found in the space the *view* is in. The source view
7444 // walks the source, because there the source is what's rendered. WYSIWYG
7445 // walks the rendered text instead: `**` is invisible to the user, so it has
7446 // to be invisible to word motion too — a caret parked inside one draws in
7447 // the column after `bold` and types two bytes earlier, and a word-delete
7448 // that stops there shreds the markup into `a ** c`.
7449
7450 /// The word boundary to the left of `off` in the active view's space.
7451 fn word_left_from(&self, off: usize) -> usize {
7452 match self.view {
7453 View::Source => prev_word(&self.source, off),
7454 View::Wysiwyg => self.glyph_word_left(off),
7455 }
7456 }
7457
7458 /// The word boundary to the right of `off` in the active view's space.
7459 fn word_right_from(&self, off: usize) -> usize {
7460 match self.view {
7461 View::Source => next_word(&self.source, off),
7462 View::Wysiwyg => self.glyph_word_right(off),
7463 }
7464 }
7465
7466 /// The character class of the glyph drawn at stop `off`.
7467 ///
7468 /// Read from the source, because a stop points at the source byte its glyph
7469 /// came from — the source *is* where the rendered character is written. What
7470 /// makes the walk glyph space rather than source space is that it only ever
7471 /// visits stops, and the hidden bytes between them have none.
7472 fn class_at(&self, off: usize) -> Class {
7473 self.source
7474 .get(off..)
7475 .and_then(|s| s.chars().next())
7476 .map_or(Class::Space, classify)
7477 }
7478
7479 /// [`next_word`] in glyph space: skip any leading separators, then consume
7480 /// the following word run, with the stop table standing in for the source's
7481 /// characters.
7482 fn glyph_word_right(&self, from: usize) -> usize {
7483 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
7484 return from;
7485 };
7486 let mut in_word = false;
7487 loop {
7488 match self.class_at(off) {
7489 Class::Word => in_word = true,
7490 _ if in_word => return off,
7491 _ => {}
7492 }
7493 match self.vmap.stop_after(off) {
7494 Some(next) => off = next,
7495 None => return off,
7496 }
7497 }
7498 }
7499
7500 /// [`prev_word`] in glyph space: skip separators walking left, then consume
7501 /// the preceding word run.
7502 fn glyph_word_left(&self, from: usize) -> usize {
7503 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
7504 return from;
7505 };
7506 let mut in_word = false;
7507 while let Some(prev) = self.vmap.stop_before(off) {
7508 match self.class_at(prev) {
7509 Class::Word => in_word = true,
7510 _ if in_word => return off,
7511 _ => {}
7512 }
7513 off = prev;
7514 }
7515 off
7516 }
7517
7518 /// After a motion that walks the visual map, the caret must be *on* the map.
7519 /// A stop is the only offset where the caret draws and edits in the same
7520 /// place, and it's the invariant both a caret parked inside an emoji and one
7521 /// parked inside a `**` were quietly breaking.
7522 ///
7523 /// Only when the caret actually moved: a walk with nowhere to go leaves it
7524 /// where it was, which is wherever the floor or a frontend put it rather
7525 /// than somewhere this motion chose.
7526 fn debug_assert_on_a_stop(&self, before: usize) {
7527 debug_assert!(
7528 self.view != View::Wysiwyg
7529 || self.vmap.num_rows() == 0
7530 || self.caret == before
7531 || self.vmap.is_stop(self.caret),
7532 "motion left the caret at {}, which is not a caret stop: it would draw in \
7533 one place and type in another",
7534 self.caret
7535 );
7536 }
7537
7538 // Up and Down run off the ends of the document rather than stopping dead at
7539 // them: Up from the first row lands at the document's start, Down from the
7540 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
7541 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
7542 // reaching the end of the text is what a reader means by it.
7543 //
7544 // The views used to disagree here by accident rather than by decision: the
7545 // source view fell into the edge behaviour through `row_col_to_offset`
7546 // clamping an out-of-range row to the end of the string, while WYSIWYG had
7547 // no row below to walk to and did nothing at all. They share the rule now,
7548 // each in its own space — the source view reaches every byte, WYSIWYG only
7549 // the offsets it draws.
7550
7551 pub fn move_up(&mut self, extend: bool) {
7552 let (row, col) = self.caret_pos();
7553 let goal = self.goal_col.unwrap_or(col);
7554 let target = match self.view {
7555 View::Source => match row.checked_sub(1) {
7556 Some(r) => row_col_to_offset(&self.source, r, goal),
7557 None => self.reachable_start(),
7558 },
7559 // A table's border rules are drawn but hold no caret, so Up steps
7560 // over them to the row that does.
7561 View::Wysiwyg => match self.vmap.navigable_above(row) {
7562 Some(r) => self.row_target(r, goal),
7563 None => self.reachable_start(),
7564 },
7565 };
7566 self.step_vertical(target, goal, extend);
7567 }
7568
7569 pub fn move_down(&mut self, extend: bool) {
7570 let (row, col) = self.caret_pos();
7571 let goal = self.goal_col.unwrap_or(col);
7572 let target = match self.view {
7573 View::Source => match self.source_row_below(row) {
7574 Some(r) => row_col_to_offset(&self.source, r, goal),
7575 None => self.reachable_end(),
7576 },
7577 View::Wysiwyg => match self.vmap.navigable_below(row) {
7578 Some(r) => self.row_target(r, goal),
7579 None => self.reachable_end(),
7580 },
7581 };
7582 self.step_vertical(target, goal, extend);
7583 }
7584
7585 /// Land a vertical motion at `target`, latching the `goal` column it aimed
7586 /// with so the rest of the run keeps aiming there.
7587 ///
7588 /// A motion with nowhere to go changes *nothing*, the goal column included:
7589 /// the latch used to run before the early return at the top of the document,
7590 /// so an Up that did nothing still armed a column, and the next Down aimed
7591 /// at one the caret had never been in.
7592 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
7593 let before = self.caret;
7594 if target == before {
7595 return;
7596 }
7597 self.goal_col = Some(goal);
7598 self.move_to(target, extend);
7599 self.debug_assert_on_a_stop(before);
7600 }
7601
7602 /// The source line below `row`, or `None` when `row` is the last one. Lines
7603 /// are counted by newline, so a trailing one leaves a real, empty last line
7604 /// for the caret to sit on — the document ends below it, not on it.
7605 fn source_row_below(&self, row: usize) -> Option<usize> {
7606 let last = self.source.bytes().filter(|&b| b == b'\n').count();
7607 (row < last).then_some(row + 1)
7608 }
7609
7610 /// Where a vertical motion aiming at the `goal` column lands on visual row
7611 /// `r`: the column clamped to the row, mapped to its offset, then held
7612 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
7613 /// column belongs to the row below, and a gutter's column 0 points at the
7614 /// block rather than at this row.
7615 fn row_target(&self, r: usize, goal: usize) -> usize {
7616 let (start, end) = self.row_bounds(r);
7617 self.vmap
7618 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
7619 .clamp(start, end)
7620 }
7621
7622 /// The first and last offsets the caret can reach in the active view.
7623 ///
7624 /// Not the same span in both: the source view shows every byte, so it can
7625 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
7626 /// sits below the first stop, and a document's trailing newline is drawn
7627 /// nowhere and so sits past the last.
7628 fn reachable_start(&self) -> usize {
7629 match self.view {
7630 View::Source => 0,
7631 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
7632 }
7633 }
7634
7635 fn reachable_end(&self) -> usize {
7636 match self.view {
7637 View::Source => self.source.len(),
7638 View::Wysiwyg => self
7639 .vmap
7640 .stop_at_or_before(self.source.len())
7641 .unwrap_or(self.caret),
7642 }
7643 }
7644
7645 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
7646 /// including the space a soft wrap ate off its end, which is drawn on this
7647 /// row however much the offset past it belongs to the next one.
7648 fn row_span(&self, r: usize) -> (usize, usize) {
7649 let start = self
7650 .vmap
7651 .row_start(r)
7652 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
7653 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
7654 (start.min(end), end)
7655 }
7656
7657 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
7658 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
7659 /// this row's last position is the one before it — the offset before the
7660 /// space the wrap ate, where the caret draws just past the row's last word
7661 /// and types there too.
7662 ///
7663 /// Aiming at the shared offset instead is what stalled End: it is the row's
7664 /// last *column*, so End pressed on the row reached it and then read back as
7665 /// the row below's start, where a second press ran on to that row's end and
7666 /// the next to the one after — End walking down the paragraph a row a press.
7667 fn row_bounds(&self, r: usize) -> (usize, usize) {
7668 let (start, end) = self.row_span(r);
7669 let wraps = self
7670 .vmap
7671 .navigable_below(r)
7672 .and_then(|b| self.vmap.row_start(b))
7673 .is_some_and(|off| off == end);
7674 match wraps {
7675 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
7676 false => (start, end),
7677 }
7678 }
7679
7680 /// The `[start, end]` of the line Home and End aim at: the visual row in
7681 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
7682 ///
7683 /// A soft-wrapped row is a line here, because it is one to the eye and the
7684 /// eye is what these keys are aimed by — a reader pressing End means the end
7685 /// of the line they can see. (`select_block_at` wants the opposite and reads
7686 /// the AST for it: a triple-click grabs the whole paragraph, however many
7687 /// rows it folds into.)
7688 fn line_bounds(&self) -> (usize, usize) {
7689 let (row, _) = self.caret_pos();
7690 match self.view {
7691 View::Source => {
7692 let start = line_start(&self.source, row);
7693 (start, line_end_from(&self.source, start))
7694 }
7695 View::Wysiwyg => self.row_bounds(row),
7696 }
7697 }
7698
7699 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
7700 /// *drawn* — what a kill takes.
7701 ///
7702 /// The two part only at a soft wrap, over the space the wrap ate: the caret
7703 /// can't stand after it (that offset opens the row below, and End stopping
7704 /// there would walk), but it is on this row, and a kill that spared it would
7705 /// leave a double space behind where the row's text had been. Deleting it
7706 /// joins nothing — a wrap is drawn, not written.
7707 fn line_span(&self) -> (usize, usize) {
7708 let (row, _) = self.caret_pos();
7709 match self.view {
7710 View::Source => self.line_bounds(),
7711 View::Wysiwyg => self.row_span(row),
7712 }
7713 }
7714
7715 /// The first offset in `[start, end]` holding something other than
7716 /// whitespace, or `end` when the line holds nothing else — where Home aims.
7717 ///
7718 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7719 /// so a hidden delimiter is never taken for the line's first character (nor
7720 /// landed on), and the source view steps the source it is showing.
7721 fn first_non_space(&self, start: usize, end: usize) -> usize {
7722 let mut off = start;
7723 while off < end {
7724 if self.class_at(off) != Class::Space {
7725 return off;
7726 }
7727 off = match self.view {
7728 View::Source => next_boundary(&self.source, off),
7729 View::Wysiwyg => match self.vmap.stop_after(off) {
7730 Some(next) => next,
7731 None => return end,
7732 },
7733 };
7734 }
7735 end
7736 }
7737
7738 /// Home: to the first character on the line, or to column 0 when the caret
7739 /// is already on it — the two-press toggle every editor spells this way.
7740 /// The indentation is somewhere the caret has to be able to reach and almost
7741 /// never where a reader is headed, so it costs the second press.
7742 pub fn move_home(&mut self, extend: bool) {
7743 self.goal_col = None;
7744 let (start, end) = self.line_bounds();
7745 let text = self.first_non_space(start, end);
7746 let target = if self.caret == text { start } else { text };
7747 let before = self.caret;
7748 self.move_to(target, extend);
7749 self.debug_assert_on_a_stop(before);
7750 }
7751
7752 /// End: to the end of the line.
7753 pub fn move_end(&mut self, extend: bool) {
7754 self.goal_col = None;
7755 let (_, end) = self.line_bounds();
7756 let before = self.caret;
7757 self.move_to(end, extend);
7758 self.debug_assert_on_a_stop(before);
7759 }
7760
7761 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7762 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7763 /// when the caret isn't in a table, or is already in the last/first cell — the
7764 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7765 /// meaning everywhere else.
7766 pub fn cell_hop(&mut self, forward: bool) -> bool {
7767 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7768 return false;
7769 };
7770 // Flatten to document (row-major) order and step one cell either way.
7771 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7772 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7773 let next = if forward {
7774 i.checked_add(1)
7775 } else {
7776 i.checked_sub(1)
7777 };
7778 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7779 return false; // at the table's edge; leave Tab to the frontend
7780 };
7781 self.select_cell(start, end);
7782 true
7783 }
7784
7785 /// Move the caret to the cell directly above (`down == false`) or below in
7786 /// the same column, landing with the cell's whole content selected (see
7787 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7788 /// when the caret isn't in a table), so the frontend can fall through — the
7789 /// vertical counterpart of [`Self::cell_hop`].
7790 ///
7791 /// A ragged row that is short a column clamps to its last cell, so Down never
7792 /// falls out of the table over a gap the row above happened to have.
7793 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7794 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7795 return false;
7796 };
7797 let target = match down {
7798 true => r + 1,
7799 false if r == 0 => return false,
7800 false => r - 1,
7801 };
7802 let Some(row) = grid.get(target) else {
7803 return false;
7804 };
7805 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7806 return false;
7807 };
7808 self.select_cell(start, end);
7809 true
7810 }
7811
7812 /// The table containing `off` as a row-major grid of `(start, end)` cell
7813 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7814 /// isn't in a table. Read straight off the visual map's laid-out grid, so
7815 /// every cell (an empty one included, whose derived home twig gives no
7816 /// `content_span` for) is present and in the order Tab walks them.
7817 // Grid, row, column — three returns that only ever travel together, and a
7818 // named type for the pair of them would be read at one call site.
7819 #[allow(clippy::type_complexity)]
7820 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7821 for t in &self.vmap.tables {
7822 let mut pos = None;
7823 let grid: Vec<Vec<(usize, usize)>> = t
7824 .grid
7825 .iter()
7826 .enumerate()
7827 .map(|(r, row)| {
7828 row.cells
7829 .iter()
7830 .enumerate()
7831 .map(|(c, cell)| {
7832 if pos.is_none() && off >= cell.start && off <= cell.end {
7833 pos = Some((r, c));
7834 }
7835 (cell.start, cell.end)
7836 })
7837 .collect()
7838 })
7839 .collect();
7840 if let Some((r, c)) = pos {
7841 return Some((grid, r, c));
7842 }
7843 }
7844 None
7845 }
7846
7847 // ── table key policy ──────────────────────────────────────────────────────
7848 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7849 // as one policy every frontend shares, rather than each re-deriving it. Each
7850 // reports whether it acted *as a table key*; a `false` hands the key back to
7851 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7852 // everywhere else.
7853
7854 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7855 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7856 /// back and simply stays put at the very first cell. `false` when the caret
7857 /// isn't in a table.
7858 pub fn cell_tab(&mut self, forward: bool) -> bool {
7859 if !self.caret_in_table() {
7860 return false;
7861 }
7862 if self.cell_hop(forward) {
7863 return true;
7864 }
7865 // Off the last cell: grow the table by a row and step into its first
7866 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7867 if forward {
7868 self.append_row_and_enter(0);
7869 }
7870 true
7871 }
7872
7873 /// Return inside a table: drop to the cell below in the same column,
7874 /// appending a new row when the caret is already in the last one. `false`
7875 /// when the caret isn't in a table, so the frontend inserts a newline.
7876 pub fn cell_return(&mut self) -> bool {
7877 if !self.caret_in_table() {
7878 return false;
7879 }
7880 if self.cell_move_vertical(true) {
7881 return true;
7882 }
7883 // Already on the last row: grow one below and drop into the same column.
7884 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7885 self.append_row_and_enter(col);
7886 true
7887 }
7888
7889 /// Append a row below the caret's (last) row and land in `col` of it. The
7890 /// caret is in the last row, so twig's "insert below" makes the fresh row the
7891 /// table's new last — but twig re-spells the whole table, moving every byte,
7892 /// so the destination is read back from the rebuilt grid by the table's
7893 /// position (stable across a row insert), not from the pre-edit caret.
7894 fn append_row_and_enter(&mut self, col: usize) {
7895 let table = self.caret_table_index();
7896 self.table_insert_row(true);
7897 self.rebuild_map();
7898 let Some((start, end)) = table
7899 .and_then(|ti| self.vmap.tables.get(ti))
7900 .and_then(|t| t.grid.last())
7901 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7902 .map(|cell| (cell.start, cell.end))
7903 else {
7904 return;
7905 };
7906 self.select_cell(start, end);
7907 }
7908
7909 /// The index, among the document's tables, of the one the caret sits in —
7910 /// `None` when it's in none. Used to re-find a table after an edit re-spells
7911 /// it (a row insert leaves the table order unchanged).
7912 fn caret_table_index(&self) -> Option<usize> {
7913 let off = self.caret;
7914 self.vmap.tables.iter().position(|t| {
7915 t.grid
7916 .iter()
7917 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7918 })
7919 }
7920
7921 /// Shift+Return inside a table: insert a hard line break *within* the current
7922 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7923 /// table, so the frontend inserts an ordinary line break.
7924 ///
7925 /// A table row is a single source line, so the newline-spelled hard break
7926 /// can't live in a cell. twig spells the in-cell break the format's way
7927 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7928 /// break round-trips as structure the renderer reads back as a line — not the
7929 /// opaque raw HTML the old raw-splice left behind.
7930 ///
7931 /// Djot has no idiomatic in-cell break, so twig refuses it
7932 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7933 /// would render as the literal text `<br>`. The gesture is still *consumed*
7934 /// there — returning `false` would let the frontend insert a real newline,
7935 /// which splits the one-line row — it just leaves the cell unchanged and says
7936 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7937 ///
7938 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7939 /// have to be read together: djot is not the only `false`, and naming it in
7940 /// the message was already a guess that HTML — which spells the break as its
7941 /// own `<br>` — would have made wrong.
7942 pub fn cell_line_break(&mut self) -> bool {
7943 if self.read_only || !self.caret_in_table() {
7944 return false;
7945 }
7946 self.record_caret();
7947 match self.editor.insert_line_break(self.caret) {
7948 Ok(change) => {
7949 self.last_edit_kind = None;
7950 self.refresh();
7951 self.caret = change.new.end;
7952 self.anchor = None;
7953 self.goal_col = None;
7954 self.clamp_caret();
7955 self.dirty = self.source != self.clean_source;
7956 self.status = None;
7957 self.record_caret();
7958 }
7959 Err(twig::Error::UnsupportedFormat) => {
7960 self.status = Some(format!(
7961 "in-cell line breaks aren't supported in {}",
7962 self.format_name()
7963 ));
7964 }
7965 Err(_) => {}
7966 }
7967 true
7968 }
7969
7970 /// Rebuild the visual map at the width the last build used. A structural edit
7971 /// bumps the revision and swaps the source in, but leaves the *map* stale;
7972 /// when a single gesture edits and then moves over the result (Tab appending
7973 /// a row, then stepping into it), the move needs the map to already show the
7974 /// edit rather than waiting for the frontend's next frame.
7975 fn rebuild_map(&mut self) {
7976 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7977 self.build_map(wrap);
7978 }
7979
7980 /// Move the caret to the very start of the document (⌘↑ on macOS,
7981 /// Ctrl+Home on Windows/Linux).
7982 pub fn move_doc_start(&mut self, extend: bool) {
7983 self.goal_col = None;
7984 self.move_to(0, extend);
7985 }
7986
7987 /// Move the caret to the very end of the document (⌘↓ on macOS,
7988 /// Ctrl+End on Windows/Linux).
7989 pub fn move_doc_end(&mut self, extend: bool) {
7990 self.goal_col = None;
7991 let end = self.source.len();
7992 self.move_to(end, extend);
7993 }
7994
7995 /// Point the caret at the body cell `(row, col)` the mouse landed on —
7996 /// `col` being a cell of the terminal grid, which is what a display column
7997 /// is. A click on the far cell of a wide character lands at that
7998 /// character's start; the mapping's own doc-comments carry the rule.
7999 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
8000 self.goal_col = None;
8001 let target = match self.view {
8002 View::Source => row_col_to_offset(&self.source, row, col),
8003 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
8004 };
8005 let before = self.caret;
8006 self.move_to(target, extend);
8007 self.debug_assert_on_a_stop(before);
8008 }
8009
8010 /// A click in the blank space under the document's last block.
8011 ///
8012 /// Not a click *on* anything, so it lands on nothing in particular: the
8013 /// caret goes onto an empty paragraph under the last block, wherever the
8014 /// pointer was horizontally — and if the document does not end with one,
8015 /// one is opened, which is the only way to get out from under a block Enter
8016 /// cannot leave. Enter inside a fenced code block is a literal newline (see
8017 /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
8018 /// way out at all; and a click under any last block used to land at the
8019 /// pointer's x on the block's last line, which is what a click on that line
8020 /// means and not what a click under it does.
8021 ///
8022 /// "Ends with an empty paragraph" is two trailing newlines: the first closes
8023 /// the last line and the second opens the blank line the visual map lays
8024 /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
8025 /// document ending inside an *unclosed* fence gets the fence closed first,
8026 /// since a newline written there would only be another line of code. An
8027 /// empty document has nothing to be under, and the caret simply goes to its
8028 /// start.
8029 ///
8030 /// In the source view the gesture is the ordinary one: the caret goes to the
8031 /// end of the source, and nothing is written. A read-only document likewise.
8032 pub fn click_past_end(&mut self) {
8033 self.goal_col = None;
8034 let len = self.source.len();
8035 if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
8036 self.move_to(len, false);
8037 return;
8038 }
8039 let mut tail = String::new();
8040 if let Some(fence) = self.unclosed_fence_at_end() {
8041 if !self.source.ends_with('\n') {
8042 tail.push('\n');
8043 }
8044 tail.push_str(&fence);
8045 tail.push('\n');
8046 }
8047 let joined = format!("{}{tail}", self.source);
8048 let trailing = joined.len() - joined.trim_end_matches('\n').len();
8049 for _ in trailing..2 {
8050 tail.push('\n');
8051 }
8052 if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
8053 return;
8054 }
8055 let end = self.source.len();
8056 self.move_to(end, false);
8057 }
8058
8059 /// The closing fence a document ending inside an unclosed fenced code block
8060 /// needs — the opening fence's own run, behind the quote prefix the block
8061 /// wears — or `None` when the last block is closed, indented, or not a code
8062 /// block at all.
8063 ///
8064 /// Unclosed is when twig's content span reaches the block's end: a closing
8065 /// fence line would lie between the two. `code_info_span`'s read of the
8066 /// fence is not used because it starts at the block's span, which inside a
8067 /// quote is the quote marker rather than the fence.
8068 fn unclosed_fence_at_end(&mut self) -> Option<String> {
8069 let content_end = self.source.trim_end_matches('\n').len();
8070 let block = self
8071 .nodes()
8072 .into_iter()
8073 .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
8074 .max_by_key(|n| n.span.start)?;
8075 if block.content_span.as_ref()?.end < block.span.end {
8076 return None;
8077 }
8078 let line = self.source[block.span.start..].lines().next()?;
8079 let opening = line.trim_start_matches(['>', ' ', '\t']);
8080 let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
8081 let run: String = opening.chars().take_while(|&c| c == fence).collect();
8082 let prefix = self.quote_prefix_at(block.span.start);
8083 Some(format!("{prefix}{run}"))
8084 }
8085
8086 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
8087 /// screen if it has moved since the last frame, and never scroll past the
8088 /// last of `rows`.
8089 ///
8090 /// Only if it has *moved* — that's the whole point. Revealing the caret on
8091 /// every frame ties the viewport to it, and a scroll wheel that fights the
8092 /// caret for the viewport loses: the view snaps back the instant it tries to
8093 /// pass the caret's row, so the document can't be scrolled beyond what's
8094 /// already on screen. A caret move is the frontend's cue to follow; a scroll
8095 /// with the caret sitting still is the reader's cue to leave it alone.
8096 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
8097 if self.drawn_caret != Some(self.caret) {
8098 if caret_row < self.scroll {
8099 self.scroll = caret_row;
8100 } else if height > 0 && caret_row >= self.scroll + height {
8101 self.scroll = caret_row + 1 - height;
8102 }
8103 self.drawn_caret = Some(self.caret);
8104 }
8105 self.scroll = self.scroll.min(rows.saturating_sub(1));
8106 }
8107
8108 /// The caret's screen position `(row, col)` in the active view's grid, with
8109 /// `col` a display column: the cell to draw the caret in, which on a line of
8110 /// `你好` or emoji is not the count of characters before it.
8111 pub fn caret_pos(&self) -> (usize, usize) {
8112 match self.view {
8113 View::Source => offset_to_row_col(&self.source, self.caret),
8114 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
8115 }
8116 }
8117
8118 fn clamp_caret(&mut self) {
8119 if self.caret > self.source.len() {
8120 self.caret = self.source.len();
8121 }
8122 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
8123 // any selection anchor) to the first rendered offset.
8124 let floor = self.caret_floor();
8125 if self.caret < floor {
8126 self.caret = floor;
8127 }
8128 if let Some(a) = self.anchor
8129 && a < floor
8130 {
8131 self.anchor = Some(floor);
8132 }
8133 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
8134 self.caret -= 1;
8135 }
8136 }
8137}
8138
8139// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
8140
8141// Left/right motion and backspace/delete step by *grapheme cluster*, not
8142// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
8143// marks moves and deletes as the single character a user sees. Grapheme
8144// boundaries are a superset of char boundaries, so the caret stays valid for twig.
8145
8146/// How an insert of `text` groups for undo: a single typed character folds into
8147/// the run of typing around it, while a newline or a multi-character insert is a
8148/// step of its own.
8149fn typed_edit_kind(text: &str) -> EditKind {
8150 if text.chars().take(2).count() == 1 && text != "\n" {
8151 EditKind::Insert
8152 } else {
8153 EditKind::Other
8154 }
8155}
8156
8157fn prev_boundary(s: &str, i: usize) -> usize {
8158 let mut cursor = GraphemeCursor::new(i, s.len(), true);
8159 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
8160}
8161
8162fn next_boundary(s: &str, i: usize) -> usize {
8163 let mut cursor = GraphemeCursor::new(i, s.len(), true);
8164 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
8165}
8166
8167// ── word boundaries ──────────────────────────────────────────────────────────
8168// The shared primitive behind word-wise motion, word deletion, and
8169// double-click-to-select-a-word. A "word" is a maximal run of one character
8170// class; whitespace and punctuation are their own classes, so motion skips
8171// cleanly between them the way native text fields do.
8172
8173#[derive(PartialEq, Eq, Clone, Copy)]
8174enum Class {
8175 Word,
8176 Space,
8177 Other,
8178}
8179
8180/// The source range of an inline node's own visible text — the part of it a
8181/// WYSIWYG caret can reach, as against the delimiters that only spell it.
8182/// `None` for a node with no interior to empty (a `str`, a break).
8183///
8184/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
8185/// one delimiter in from the span — the same place the renderer maps it to. A
8186/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
8187/// against the source rather than trusted: a range guessed wrong here is text
8188/// deleted wrong.
8189fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
8190 if let Some(span) = n.content_span.clone() {
8191 return Some(span);
8192 }
8193 match n.kind.as_str() {
8194 "verbatim" | "inline_math" => {
8195 let text = n.text.as_ref()?;
8196 let start = n.span.start + 1;
8197 let range = start..start + text.len();
8198 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
8199 }
8200 _ => None,
8201 }
8202}
8203
8204/// The `id` a node declares, or `None` for one that declares none — the
8205/// attribute djot writes for a `{#v1}` and mints for a heading.
8206///
8207/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
8208/// names nothing, so it reads as absent rather than as the empty string.
8209fn declared_id(n: &FlatNode) -> Option<&str> {
8210 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
8211}
8212
8213/// A heading's words reduced to the form a link fragment spells them in:
8214/// lowercase, runs of anything else collapsed to a single `-`, with none left
8215/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
8216///
8217/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
8218/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
8219/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
8220/// any other language is still a heading someone will link to. Underscores
8221/// survive for the same reason they do on the web: they are word characters
8222/// wherever identifiers are written.
8223fn slug(text: &str) -> String {
8224 let mut out = String::new();
8225 let mut pending = false;
8226 for c in text.chars() {
8227 if c.is_alphanumeric() || c == '_' {
8228 if pending && !out.is_empty() {
8229 out.push('-');
8230 }
8231 pending = false;
8232 out.extend(c.to_lowercase());
8233 } else {
8234 pending = true;
8235 }
8236 }
8237 out
8238}
8239
8240fn is_block_container(kind: &Kind) -> bool {
8241 matches!(
8242 kind,
8243 Kind::Doc
8244 | Kind::Section
8245 | Kind::BlockQuote
8246 | Kind::BulletList
8247 | Kind::OrderedList
8248 | Kind::TaskList
8249 | Kind::ListItem
8250 | Kind::TaskListItem
8251 // Every `container` — a directive in any of its three forms, or a
8252 // promoted HTML element. A *text* directive is really inline, so
8253 // claiming it here is a small overreach, and the deliberate one this
8254 // function's kind-only peer `is_inline_kind` documents: the pair is
8255 // consulted together, and answering "block container" for something
8256 // inline is what keeps an ancestor walk from stopping short of the
8257 // paragraph that actually holds it.
8258 | Kind::Container
8259 )
8260}
8261
8262/// The `[start, end)` byte range of the source line containing `off` (newline
8263/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
8264/// line between paragraphs).
8265fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
8266 let off = off.min(s.len());
8267 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
8268 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
8269 start..end
8270}
8271
8272/// How many leading bytes an outdent takes off `line`: a whole indent level
8273/// where the line has one, and whatever it has where it has less.
8274///
8275/// A leading tab counts as a level on its own. It's indentation some other
8276/// editor wrote, and one tab is one level everywhere it came from — measuring it
8277/// in spaces it doesn't contain would leave it untouchable.
8278fn outdent_width(line: &str, unit: usize) -> usize {
8279 if line.starts_with('\t') {
8280 return 1;
8281 }
8282 line.bytes().take(unit).take_while(|b| *b == b' ').count()
8283}
8284
8285/// A list marker found at the head of a line, together with everything before it
8286/// that a sibling line has to repeat.
8287///
8288/// The three offsets differ only inside a block quote, where `> - b` opens with
8289/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
8290/// `line_start == marker_start`, and `text` is the plain `" - "`.
8291#[derive(Clone, Debug)]
8292struct ListMarker {
8293 /// The line's first byte.
8294 line_start: usize,
8295 /// Where the marker proper begins, past any quote prefix. The offset to hand
8296 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
8297 marker_start: usize,
8298 /// `line_start` through the marker's trailing space — quote prefix, indent
8299 /// and bullet together, which is what the next item's line opens with.
8300 text: String,
8301}
8302
8303impl ListMarker {
8304 /// Where the item's content starts — one past the marker's trailing space.
8305 fn content_start(&self) -> usize {
8306 self.line_start + self.text.len()
8307 }
8308}
8309
8310fn classify(c: char) -> Class {
8311 if c == '_' || c.is_alphanumeric() {
8312 Class::Word
8313 } else if c.is_whitespace() {
8314 Class::Space
8315 } else {
8316 Class::Other
8317 }
8318}
8319
8320/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
8321/// skip any leading separators, then consume the following word run.
8322fn next_word(s: &str, i: usize) -> usize {
8323 let mut off = i;
8324 let mut in_word = false;
8325 for c in s[i..].chars() {
8326 if classify(c) == Class::Word {
8327 in_word = true;
8328 } else if in_word {
8329 break;
8330 }
8331 off += c.len_utf8();
8332 }
8333 off
8334}
8335
8336/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
8337/// skip separators walking left, then consume the preceding word run.
8338fn prev_word(s: &str, i: usize) -> usize {
8339 let mut off = i;
8340 let mut in_word = false;
8341 for c in s[..i].chars().rev() {
8342 if classify(c) == Class::Word {
8343 in_word = true;
8344 } else if in_word {
8345 break;
8346 }
8347 off -= c.len_utf8();
8348 }
8349 off
8350}
8351
8352/// The `[start, end)` run of same-class characters surrounding `off` — the
8353/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
8354/// the run ending there is used.
8355fn word_range_at(s: &str, off: usize) -> (usize, usize) {
8356 if s.is_empty() {
8357 return (0, 0);
8358 }
8359 let off = off.min(s.len());
8360 let reference = if off < s.len() {
8361 s[off..].chars().next()
8362 } else {
8363 s[..off].chars().next_back()
8364 };
8365 let Some(rc) = reference else {
8366 return (off, off);
8367 };
8368 let class = classify(rc);
8369
8370 let mut start = off;
8371 for c in s[..start].chars().rev() {
8372 if classify(c) == class {
8373 start -= c.len_utf8();
8374 } else {
8375 break;
8376 }
8377 }
8378 let mut end = off;
8379 for c in s[end..].chars() {
8380 if classify(c) == class {
8381 end += c.len_utf8();
8382 } else {
8383 break;
8384 }
8385 }
8386 (start, end)
8387}
8388
8389/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
8390/// the line's start — terminal cells, not characters, so the column names the
8391/// cell the caret is drawn in even on a line of `你好` or emoji.
8392fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
8393 let off = off.min(s.len());
8394 let mut row = 0;
8395 let mut line_start = 0;
8396 for (i, &b) in s.as_bytes().iter().enumerate() {
8397 if i >= off {
8398 break;
8399 }
8400 if b == b'\n' {
8401 row += 1;
8402 line_start = i + 1;
8403 }
8404 }
8405 (row, wysiwyg::text_width(&s[line_start..off]))
8406}
8407
8408/// The byte offset at display column `col` of `row` (clamped to that line's
8409/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
8410///
8411/// A column landing *inside* a character — the second cell of `你`, or any cell
8412/// but the first of an emoji — resolves to that character's start, which is the
8413/// column the caret would have been drawn at to begin with. So both cells of a
8414/// wide character mean the character, and every offset survives the round trip
8415/// out to a column and back. The walk steps by grapheme cluster for the same
8416/// reason the caret does: a cluster is the character, and the cells belong to it
8417/// rather than to the codepoints spelling it.
8418fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
8419 let start = line_start(s, row);
8420 let end = line_end_from(s, start);
8421 let mut off = start;
8422 let mut at = 0; // the display column `off` sits at
8423 while off < end {
8424 let next = next_boundary(s, off).min(end);
8425 let cells = wysiwyg::text_width(&s[off..next]);
8426 if at + cells > col {
8427 break; // `col` is one of this cluster's own cells
8428 }
8429 at += cells;
8430 off = next;
8431 }
8432 off
8433}
8434
8435fn line_start(s: &str, row: usize) -> usize {
8436 if row == 0 {
8437 return 0;
8438 }
8439 let mut r = 0;
8440 for (i, &b) in s.as_bytes().iter().enumerate() {
8441 if b == b'\n' {
8442 r += 1;
8443 if r == row {
8444 return i + 1;
8445 }
8446 }
8447 }
8448 s.len()
8449}
8450
8451fn line_end_from(s: &str, start: usize) -> usize {
8452 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
8453}
8454
8455/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
8456/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
8457/// twig applies writing the mark out, so the toolbar can light the same button
8458/// that made the node.
8459///
8460/// `None` for every other kind, including the inline nodes that aren't marks at
8461/// all (`str`, `link`, `image`, the math and break kinds): they're things a
8462/// caret stands in, not formatting a button toggles.
8463/// Whether a match from an ancestor chain is an inline run whose delimiters
8464/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
8465/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
8466/// span is a [`Kind::Container`], which the kind alone cannot tell from a
8467/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
8468/// answer is the node's own. Every delete and caret step that walks over a
8469/// `**` walks over a span's tags by this test; without it Backspace after
8470/// `</span>` took the `>` and left the paragraph unparseable.
8471fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
8472 inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
8473}
8474
8475fn inline_kind(kind: &Kind) -> Option<InlineKind> {
8476 Some(match kind {
8477 Kind::Strong => InlineKind::Strong,
8478 Kind::Emph => InlineKind::Emph,
8479 Kind::Verbatim => InlineKind::Verbatim,
8480 Kind::Mark => InlineKind::Mark,
8481 Kind::Superscript => InlineKind::Superscript,
8482 Kind::Subscript => InlineKind::Subscript,
8483 Kind::Insert => InlineKind::Insert,
8484 Kind::Delete => InlineKind::Delete,
8485 _ => return None,
8486 })
8487}
8488
8489/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
8490/// a highlight's opening `==`.
8491///
8492/// Two enums for one closed vocabulary, and the duplication is the boundary
8493/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
8494/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
8495/// the editor writes. Spelled as a match rather than routed through the two
8496/// crates' name strings so that a colour added on either side is a compile
8497/// error here, where the pairing is decided, rather than a runtime `None` that
8498/// would read as "clear the colour".
8499fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
8500 match color {
8501 MarkColor::Red => twig::MarkColor::Red,
8502 MarkColor::Orange => twig::MarkColor::Orange,
8503 MarkColor::Yellow => twig::MarkColor::Yellow,
8504 MarkColor::Green => twig::MarkColor::Green,
8505 MarkColor::Blue => twig::MarkColor::Blue,
8506 MarkColor::Purple => twig::MarkColor::Purple,
8507 MarkColor::Brown => twig::MarkColor::Brown,
8508 }
8509}
8510
8511/// Where an offset lands after a splice it didn't make — twig's own rule, from
8512/// [`Change`]: shift anything at or past the replaced range's end by the length
8513/// the replacement gained or lost, and leave anything before it alone.
8514///
8515/// An offset *inside* the replaced range has no text of its own to ride any
8516/// more, and lands at the end of what replaced it: for
8517/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
8518/// the prefix is cleared, which then sits where the highlighted text begins.
8519/// One node's attribute list, twig's own `(key, value)` pairs owned — what
8520/// every presentation gesture reads, edits one key of, and passes back whole.
8521type Attrs = Vec<(String, Option<String>)>;
8522
8523/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
8524/// writes it and the walker draws it, and a frontend that paginates matches a
8525/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
8526/// stated once.
8527pub const PAGE_BREAK: &str = "page-break";
8528
8529/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
8530/// every other attribute kept in its place — the read-edit-write half of twig's
8531/// replace-not-merge contract for a `data-` key.
8532///
8533/// **A key that is already there is rewritten where it stands**, and only a key
8534/// the node did not have goes on the end. That is what makes the proposal's
8535/// worked example true: `class="lead center" id="intro"
8536/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
8537/// data-line-height="1.5"` — the same document with one token changed, and a
8538/// one-line diff. Removing the key and pushing it back would reorder the
8539/// author's attributes on every press, so a document that passed through the
8540/// editor came out shuffled even where nothing about it had changed.
8541///
8542/// A duplicate key — which no format leaf opens can spell, but twig reports
8543/// verbatim — collapses onto the first of its copies, since twig is handed one
8544/// value for one key either way.
8545fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
8546 let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
8547 let mut written = false;
8548 for (k, v) in attrs {
8549 if k != key {
8550 out.push((k.clone(), v.clone()));
8551 continue;
8552 }
8553 if let Some(new) = value.filter(|_| !written) {
8554 out.push((k.clone(), Some(new.to_string())));
8555 written = true;
8556 }
8557 }
8558 if let Some(new) = value.filter(|_| !written) {
8559 out.push((key.to_string(), Some(new.to_string())));
8560 }
8561 out
8562}
8563
8564/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
8565/// `token` added, with the rest of the list kept in order.
8566///
8567/// `class` is a space-separated token list, and leaf owns three of the tokens in
8568/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
8569/// goes out as `class="lead right"`; one whose last owned token goes and which
8570/// carried nothing else loses the key, so a block that has lost its whole
8571/// vocabulary is spelled bare again. `class` itself keeps its place among the
8572/// attributes, because [`with_attr`] does the writing.
8573fn with_class_token(
8574 attrs: &[(String, Option<String>)],
8575 mine: impl Fn(&str) -> bool,
8576 token: Option<&str>,
8577) -> Attrs {
8578 let kept: Vec<&str> = attrs
8579 .iter()
8580 .find(|(k, _)| k == "class")
8581 .and_then(|(_, v)| v.as_deref())
8582 .unwrap_or_default()
8583 .split_whitespace()
8584 .filter(|t| !mine(t))
8585 .collect();
8586 let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
8587 with_attr(
8588 attrs,
8589 "class",
8590 (!class.is_empty()).then_some(class.as_str()),
8591 )
8592}
8593
8594/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
8595///
8596/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
8597/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
8598/// (djot has no bare attribute, so no format reads one back everywhere), and
8599/// `hidden=""` is the same document where `hidden` is; dropping it instead
8600/// would lose what the author wrote, which is the one thing these gestures
8601/// promise not to do.
8602fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
8603 attrs
8604 .iter()
8605 .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
8606 .collect()
8607}
8608
8609fn reanchor(off: usize, change: &Change) -> usize {
8610 if off < change.old.start {
8611 return off;
8612 }
8613 if off < change.old.end {
8614 return change.new.end;
8615 }
8616 (off + change.new.end).saturating_sub(change.old.end)
8617}
8618
8619/// [`reanchor`] for an edit that respells the markup *around* a block and
8620/// leaves the block's own bytes alone — which is every attribute gesture.
8621///
8622/// `block` is that block's content span before and after the splice, so an
8623/// offset standing in the text keeps its distance from the text's start and how
8624/// many bytes twig wrote above it never enters the arithmetic. That is the whole
8625/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
8626/// on the same div lengthens the attribute line, clearing the last one takes the
8627/// div away entirely, and both are the same sum. `None` where the splice named
8628/// no block at either end, which is every djot case — the `{…}` line is written
8629/// above the block, and the block itself only shifts past it.
8630///
8631/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
8632/// shifted by its delta after it, and at the splice's end for an offset that
8633/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
8634/// text to keep.
8635fn reanchor_in_block(
8636 off: usize,
8637 change: &Change,
8638 block: Option<(&Range<usize>, &Range<usize>)>,
8639) -> usize {
8640 if let Some((was, now)) = block
8641 && was.start <= off
8642 && off <= was.end
8643 {
8644 return now.start + (off - was.start).min(now.end - now.start);
8645 }
8646 reanchor(off, change)
8647}
8648
8649/// A watermark for a file's contents (see `Doc::disk_hash`).
8650///
8651/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
8652/// watermark is compared only against one taken by the same process moments
8653/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
8654/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
8655fn hash_bytes(bytes: &[u8]) -> u64 {
8656 use std::hash::{Hash, Hasher};
8657 let mut h = std::collections::hash_map::DefaultHasher::new();
8658 bytes.hash(&mut h);
8659 h.finish()
8660}
8661
8662#[cfg(feature = "fs")]
8663fn detect_format(path: &Path) -> Result<Format> {
8664 let ext = path
8665 .extension()
8666 .and_then(|e| e.to_str())
8667 .unwrap_or("")
8668 .to_ascii_lowercase();
8669 Ok(match ext.as_str() {
8670 "dj" | "djot" => Format::Djot,
8671 "md" | "markdown" => Format::Markdown,
8672 "xml" => Format::Xml,
8673 "html" | "htm" => Format::Html,
8674 other => return Err(anyhow!("unknown document extension: .{other}")),
8675 })
8676}
8677
8678#[cfg(test)]
8679mod tests {
8680 use super::*;
8681 use crate::style::{FontFamily, LineSpacing, SizeStep};
8682
8683 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
8684 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
8685 /// without one is a view no user is ever in.
8686 fn doc_in(view: View, name: &str, body: &str) -> Doc {
8687 // The fixture name doubles as the temp file's, so two tests picking the
8688 // same one raced under the parallel runner and read each other's body —
8689 // a green suite proving the wrong thing. The counter makes that
8690 // unreachable rather than asking every future caller to notice.
8691 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8692 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8693 let mut p = std::env::temp_dir();
8694 p.push(format!("leaf_test_{name}_{seq}.md"));
8695 std::fs::write(&p, body).unwrap();
8696 let mut d = Doc::open(p).unwrap();
8697 d.view = view;
8698 if view == View::Wysiwyg {
8699 d.build_visual(80);
8700 }
8701 d
8702 }
8703
8704 // Source-view document for the source-behaviour tests. `Doc::open` now
8705 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
8706 // `wysiwyg_doc` builds the rich-text variant on top of this.
8707 fn doc_with(name: &str, body: &str) -> Doc {
8708 doc_in(View::Source, name, body)
8709 }
8710
8711 /// Every visual row's drawn text — what the reader actually sees, which is
8712 /// the only thing the reveal preference is supposed to change.
8713 fn drawn_rows(d: &Doc) -> Vec<String> {
8714 d.vmap
8715 .rows
8716 .iter()
8717 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8718 .collect()
8719 }
8720
8721 /// Put the caret at the first byte of `needle` and rebuild, so the row under
8722 /// it becomes the revealed line.
8723 fn caret_at(d: &mut Doc, needle: &str) {
8724 d.caret = d.source.find(needle).expect("needle in source");
8725 d.build_visual(80);
8726 }
8727
8728 #[test]
8729 fn blockquote_after_a_list_is_not_bulleted() {
8730 // twig nests a following top-level block quote under the `bullet_list`
8731 // (a direct child, not a `list_item`). The map must render it de-nested —
8732 // `│ quote`, never `• │ quote` — with a blank separator, like any block
8733 // that follows a list. Regression for the "combined list + blockquote" bug.
8734 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
8735 d.build_visual(80);
8736 let rows: Vec<String> = d
8737 .vmap
8738 .rows
8739 .iter()
8740 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8741 .collect();
8742 assert!(
8743 rows.iter().any(|r| r == "│ quote"),
8744 "block quote should render on its own gutter, got rows: {rows:?}"
8745 );
8746 assert!(
8747 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
8748 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
8749 );
8750 }
8751
8752 // ── the map is built at most once per (revision, wrap) ───────────────────
8753 //
8754 // A frontend repaints for reasons that have nothing to do with the text — a
8755 // blinking caret, a scroll — and rebuilding the map is O(document). These
8756 // pin *that the cache fires*, which a passing suite can't tell you: a cache
8757 // that never hits is invisible to every other test in this file.
8758 //
8759 // The probe is to wreck the built map and ask for it again. A rebuild
8760 // repairs it; a cache hit hands the wreckage straight back. Nothing else
8761 // can distinguish the two from outside.
8762
8763 #[test]
8764 fn a_rebuild_with_nothing_changed_reuses_the_map() {
8765 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
8766 d.build_visual(80);
8767 assert!(!d.vmap.rows.is_empty());
8768 d.vmap.rows.clear(); // wreck it
8769 d.build_visual(80);
8770 assert!(
8771 d.vmap.rows.is_empty(),
8772 "the map was rebuilt though nothing changed — the cache never fired"
8773 );
8774 }
8775
8776 #[test]
8777 fn an_edit_rebuilds_the_map() {
8778 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
8779 d.build_visual(80);
8780 let before = d.revision();
8781 d.vmap.rows.clear();
8782 d.insert("x");
8783 d.build_visual(80);
8784 assert!(d.revision() > before, "an edit must move the revision");
8785 assert!(
8786 !d.vmap.rows.is_empty(),
8787 "an edited document must not paint from a stale map"
8788 );
8789 }
8790
8791 #[test]
8792 fn a_width_change_rebuilds_the_map() {
8793 // The map is a function of the wrap width too, so a resize is a miss
8794 // even though the text is untouched.
8795 let mut d = doc_in(
8796 View::Wysiwyg,
8797 "cache_width",
8798 "one two three four five six\n",
8799 );
8800 d.build_visual(80);
8801 d.vmap.rows.clear();
8802 d.build_visual(12);
8803 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8804 // And the unwrapped map is its own key, not the same as any width.
8805 d.vmap.rows.clear();
8806 d.build_visual_unwrapped();
8807 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8808 }
8809
8810 #[test]
8811 fn a_motion_does_not_rebuild_the_map() {
8812 // The whole point: moving the caret changes nothing the map is built
8813 // from. If a motion bumped the revision, every arrow key would cost a
8814 // full rebuild and the cache would be worthless.
8815 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8816 d.build_visual(80);
8817 let rev = d.revision();
8818 d.move_right(false);
8819 d.move_right(true);
8820 d.move_down(false);
8821 assert_eq!(d.revision(), rev, "a motion must not move the revision");
8822 d.vmap.rows.clear();
8823 d.build_visual(80);
8824 assert!(
8825 d.vmap.rows.is_empty(),
8826 "a motion should not rebuild the map"
8827 );
8828 }
8829
8830 #[test]
8831 fn saving_does_not_rebuild_the_map() {
8832 // Saving changes `dirty`, not the text.
8833 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8834 d.insert("x");
8835 d.build_visual(80);
8836 let rev = d.revision();
8837 d.save();
8838 assert_eq!(d.revision(), rev, "a save must not move the revision");
8839 assert!(!d.dirty, "the save should have cleaned the document");
8840 }
8841
8842 #[test]
8843 fn a_reload_rebuilds_the_map() {
8844 // Reload replaces the text without going through `refresh`, so it has to
8845 // move the revision itself — else the editor paints the old file.
8846 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8847 d.build_visual(80);
8848 let rev = d.revision();
8849 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8850 d.reload();
8851 assert!(d.revision() > rev, "a reload must move the revision");
8852 d.build_visual(80);
8853 let text: String = d
8854 .vmap
8855 .rows
8856 .iter()
8857 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8858 .collect();
8859 assert!(
8860 text.contains("wholly new"),
8861 "the reloaded text should be on screen, got {text:?}"
8862 );
8863 }
8864
8865 // ── golden-case harness ──────────────────────────────────────────────────
8866 // The pattern the whole parity suite can reuse: write a fixture with the
8867 // caret marked by `|`, run one action, and compare the rendered result —
8868 // also caret-marked — against the expected string. One readable line per
8869 // behavior, and it exercises the exact `Doc` ops both frontends call.
8870
8871 /// Split a `|`-marked fixture into `(source, caret_offset)`.
8872 fn parse_caret(marked: &str) -> (String, usize) {
8873 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8874 (marked.replacen('|', "", 1), caret)
8875 }
8876
8877 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8878 /// selection) so a result reads like the fixtures.
8879 fn render_caret(d: &Doc) -> String {
8880 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8881 // so the caret always renders inside its own selection.
8882 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8883 if let Some((s, e)) = d.selection() {
8884 marks.push((s, 0, '['));
8885 marks.push((e, 2, ']'));
8886 }
8887 // Insert right-to-left: descending offset, then descending rank.
8888 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8889 let mut out = d.source.clone();
8890 for (at, _, ch) in marks {
8891 out.insert(at, ch);
8892 }
8893 out
8894 }
8895
8896 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8897 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8898 golden_in(View::Source, name, marked, action)
8899 }
8900
8901 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8902 /// the same fixture has to read the same way in both.
8903 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8904 let (src, caret) = parse_caret(marked);
8905 let mut d = doc_in(view, name, &src);
8906 d.caret = caret;
8907 action(&mut d);
8908 render_caret(&d)
8909 }
8910
8911 #[test]
8912 fn word_motion_walks_word_by_word() {
8913 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8914 assert_eq!(
8915 g("hello wor|ld", |d| d.move_word_left(false)),
8916 "hello |world"
8917 );
8918 assert_eq!(
8919 g("hello| world", |d| d.move_word_left(false)),
8920 "|hello world"
8921 );
8922 assert_eq!(
8923 g("hel|lo world", |d| d.move_word_right(false)),
8924 "hello| world"
8925 );
8926 assert_eq!(
8927 g("hello| world", |d| d.move_word_right(false)),
8928 "hello world|"
8929 );
8930 // Punctuation is its own class, so motion stops at the boundary.
8931 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8932 }
8933
8934 #[test]
8935 fn word_motion_extends_the_selection_when_asked() {
8936 assert_eq!(
8937 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8938 "hello [world|]"
8939 );
8940 }
8941
8942 #[test]
8943 fn delete_word_removes_a_whole_word() {
8944 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8945 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8946 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8947 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8948 }
8949
8950 // ── Home / End ───────────────────────────────────────────────────────────
8951
8952 #[test]
8953 fn home_toggles_between_the_line_s_text_and_its_margin() {
8954 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8955 // indent to the markup it spells everywhere it means one, so the fixture
8956 // with whitespace left to walk is a code block, which is verbatim.
8957 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8958 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
8959 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
8960 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
8961 // A line with no indentation has one place to go, so the toggle is a
8962 // no-op rather than a trip to nowhere.
8963 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8964 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8965
8966 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
8967 let indent = d.source.find(" indented").unwrap();
8968 d.caret = indent + 6; // inside "indented"
8969 d.move_home(false);
8970 assert_eq!(
8971 d.caret,
8972 indent + 4,
8973 "wysiwyg: Home aims at the code line's text"
8974 );
8975 d.move_home(false);
8976 assert_eq!(
8977 d.caret, indent,
8978 "wysiwyg: the second press takes the indent"
8979 );
8980 d.move_home(false);
8981 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8982 }
8983
8984 #[test]
8985 fn end_takes_the_line_the_view_is_showing() {
8986 // The line differs by view for the same document, and that is the point:
8987 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8988 // as a space on one row and the source view as two lines.
8989 let mut d = doc_with("end_src", "one two\nthree\n");
8990 d.caret = 1;
8991 d.move_end(false);
8992 assert_eq!(d.caret, 7, "source: the end of the source line");
8993
8994 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8995 d.caret = 1;
8996 d.move_end(false);
8997 assert_eq!(
8998 d.caret, 13,
8999 "wysiwyg: the end of the row, soft break and all"
9000 );
9001 }
9002
9003 #[test]
9004 fn home_and_end_extend_the_selection_when_asked() {
9005 for (view, tag) in VIEWS {
9006 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
9007 d.caret = 6;
9008 d.move_end(true);
9009 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
9010 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
9011 d.caret = 6;
9012 d.move_home(true);
9013 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
9014 }
9015 }
9016
9017 // ── kill to the line's start / end ───────────────────────────────────────
9018
9019 #[test]
9020 fn kill_to_the_line_start_and_end_in_both_views() {
9021 for (view, tag) in VIEWS {
9022 // The gap that reads as a paragraph break in each view: the source
9023 // view's lines are the renderer's rows only where the source says so.
9024 let gap = if view == View::Source { "\n" } else { "\n\n" };
9025 let mut d = doc_in(
9026 view,
9027 &format!("kill_end_{tag}"),
9028 &format!("one two{gap}three\n"),
9029 );
9030 d.caret = 3;
9031 d.delete_to_line_end();
9032 assert_eq!(
9033 d.source,
9034 format!("one{gap}three\n"),
9035 "{tag}: ^K to the line's end"
9036 );
9037 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
9038
9039 let mut d = doc_in(
9040 view,
9041 &format!("kill_start_{tag}"),
9042 &format!("one two{gap}three\n"),
9043 );
9044 d.caret = 7; // the end of the first line
9045 d.delete_to_line_start();
9046 assert_eq!(
9047 d.source,
9048 format!("{gap}three\n"),
9049 "{tag}: ⌘⌫ to the line's start"
9050 );
9051 assert_eq!(d.caret, 0, "{tag}");
9052 }
9053 }
9054
9055 #[test]
9056 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
9057 // The decision: at the boundary both kills do nothing, rather than
9058 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
9059 // at a soft wrap as often as at a newline, where there is nothing
9060 // written to delete — and a source newline is only half of the blank
9061 // line between two paragraphs, so taking it leaves a soft break rather
9062 // than the join it looks like. Backspace and Delete are the keys for it.
9063 for (view, tag) in VIEWS {
9064 let gap = if view == View::Source { "\n" } else { "\n\n" };
9065 let src = format!("one{gap}three\n");
9066 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
9067 d.caret = 3; // the end of "one"
9068 d.delete_to_line_end();
9069 assert_eq!(
9070 d.source, src,
9071 "{tag}: ^K at the line's end joined it to the next"
9072 );
9073
9074 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
9075 d.caret = 3 + gap.len(); // the start of "three"
9076 d.delete_to_line_start();
9077 assert_eq!(
9078 d.source, src,
9079 "{tag}: ⌘⌫ at the line's start joined it to the last"
9080 );
9081 }
9082 }
9083
9084 #[test]
9085 fn a_kill_takes_the_selection_when_there_is_one() {
9086 // What every other delete here does with one, so these two as well.
9087 for (view, tag) in VIEWS {
9088 for (name, kill) in [
9089 (
9090 "end",
9091 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
9092 ),
9093 ("start", |d: &mut Doc| d.delete_to_line_start()),
9094 ] {
9095 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
9096 d.anchor = Some(4);
9097 d.caret = 7; // "two"
9098 kill(&mut d);
9099 assert_eq!(
9100 d.source, "one three\n",
9101 "{tag}: {name} ignored the selection"
9102 );
9103 assert_eq!(d.selection(), None, "{tag}: {name}");
9104 }
9105 }
9106 }
9107
9108 #[test]
9109 fn a_kill_takes_the_markup_it_empties_with_it() {
9110 // The same hazard a word-delete has: a WYSIWYG range covers what the
9111 // user can see, which for `**bold**` is the word and never the
9112 // delimiters, so a kill that stopped at the text would leave `a ****` —
9113 // markup wrapped around nothing.
9114 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
9115 d.caret = d.source.find("bold").unwrap();
9116 d.delete_to_line_end();
9117 assert_eq!(d.source, "a \n");
9118 }
9119
9120 #[test]
9121 fn a_kill_is_undone_in_one_step() {
9122 for (view, tag) in VIEWS {
9123 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
9124 d.caret = 3;
9125 d.delete_to_line_end();
9126 assert_eq!(d.source, "one\n", "{tag}");
9127 d.undo();
9128 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
9129 }
9130 }
9131
9132 #[test]
9133 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
9134 // Regression: triple-click used move_home/move_end over visual rows, so
9135 // it only worked on a paragraph's first row (a wrap-boundary offset maps
9136 // to the earlier row). select_block_at reads the AST, so every offset in
9137 // the paragraph selects the whole thing.
9138 let body = "one two three four five six seven eight\n";
9139 let mut d = doc_with("sel_block", body);
9140 d.view = View::Wysiwyg;
9141 d.build_visual(12); // force the paragraph to wrap into several rows
9142 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
9143 let para = (0, "one two three four five six seven eight".len());
9144 for off in [0usize, 8, 19, 28, 38] {
9145 d.caret = 0;
9146 d.anchor = None;
9147 d.select_block_at(off);
9148 assert_eq!(
9149 d.selection(),
9150 Some(para),
9151 "offset {off} should select the paragraph"
9152 );
9153 }
9154 }
9155
9156 #[test]
9157 fn select_block_uses_content_span_for_a_heading() {
9158 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
9159 d.select_block_at(4); // inside "Title"
9160 // content_span excludes the "# " marker.
9161 assert_eq!(d.selected_text(), Some("Title"));
9162 d.select_block_at(10); // inside "body"
9163 assert_eq!(d.selected_text(), Some("body"));
9164 }
9165
9166 #[test]
9167 fn select_all_spans_the_document() {
9168 let mut d = doc_with("sel_all", "abc\n\ndef\n");
9169 d.select_all();
9170 assert_eq!(d.selection(), Some((0, d.source.len())));
9171 }
9172
9173 #[test]
9174 fn select_word_at_picks_the_surrounding_word() {
9175 let mut d = doc_with("sel_word", "hello world\n");
9176 d.select_word_at(8); // inside "world"
9177 assert_eq!(d.selection(), Some((6, 11)));
9178 // Double-clicking at end-of-word still grabs the word to its left.
9179 d.select_word_at(5); // the space between the words
9180 assert_eq!(d.selection(), Some((5, 6)));
9181 }
9182
9183 #[test]
9184 fn word_helpers_respect_utf8_boundaries() {
9185 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
9186 assert_eq!(
9187 golden("utf8", "|café ok", |d| d.move_word_right(false)),
9188 "café| ok"
9189 );
9190 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
9191 }
9192
9193 #[test]
9194 fn typing_inserts_at_the_caret_and_advances_it() {
9195 let mut d = doc_with("type", "hello\n");
9196 d.insert("Hi ");
9197 assert_eq!(d.source, "Hi hello\n");
9198 assert_eq!(d.caret, 3);
9199 assert!(d.dirty);
9200 }
9201
9202 #[test]
9203 fn backspace_deletes_the_char_before_the_caret() {
9204 let mut d = doc_with("bs", "hello\n");
9205 d.caret = 3; // after "hel"
9206 d.backspace();
9207 assert_eq!(d.source, "helo\n");
9208 assert_eq!(d.caret, 2);
9209 }
9210
9211 #[test]
9212 fn typing_replaces_the_selection() {
9213 let mut d = doc_with("replace", "a word b\n");
9214 d.anchor = Some(2);
9215 d.caret = 6; // "word" selected
9216 d.insert("X");
9217 assert_eq!(d.source, "a X b\n");
9218 assert_eq!(d.caret, 3);
9219 assert_eq!(d.anchor, None);
9220 }
9221
9222 #[test]
9223 fn toggle_bold_wraps_then_unwraps_the_selection() {
9224 let mut d = doc_with("bold", "a word b\n");
9225 d.anchor = Some(2);
9226 d.caret = 6;
9227 d.toggle(InlineKind::Strong);
9228 assert_eq!(d.source, "a **word** b\n");
9229 // The toggled region stays selected, so a second toggle reverses it.
9230 d.toggle(InlineKind::Strong);
9231 assert_eq!(d.source, "a word b\n");
9232 d.toggle(InlineKind::Strong);
9233 assert_eq!(d.source, "a **word** b\n");
9234 }
9235
9236 #[test]
9237 fn toggle_code_wraps_then_unwraps_the_selection() {
9238 let mut d = doc_with("code_rt", "a word b\n");
9239 d.anchor = Some(2);
9240 d.caret = 6;
9241 d.toggle(InlineKind::Verbatim);
9242 assert_eq!(d.source, "a `word` b\n");
9243 d.toggle(InlineKind::Verbatim);
9244 assert_eq!(d.source, "a word b\n");
9245 }
9246
9247 #[test]
9248 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
9249 // ⌘b at a bare caret, then type: the text comes out bold with no
9250 // selection ever made — the word-processor "start bold here" gesture.
9251 let mut d = doc_with("sticky_wrap", "xy\n");
9252 d.caret = 1; // between x and y
9253 d.toggle(InlineKind::Strong);
9254 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
9255 d.insert("A");
9256 assert_eq!(d.source, "x**A**y\n");
9257 }
9258
9259 #[test]
9260 fn sticky_bold_lights_the_toolbar_before_any_typing() {
9261 // The button must light the instant ⌘b is pressed, or the mode is
9262 // invisible until the first character lands.
9263 let mut d = doc_with("sticky_light", "xy\n");
9264 d.caret = 1;
9265 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9266 d.toggle(InlineKind::Strong);
9267 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9268 }
9269
9270 #[test]
9271 fn sticky_bold_toggled_off_types_normally_again() {
9272 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
9273 // in the flow of typing, the exact sequence the user described.
9274 let mut d = doc_with("sticky_off", "\n");
9275 d.caret = 0;
9276 d.toggle(InlineKind::Strong);
9277 d.insert("a");
9278 d.insert("b"); // continues inside the run, no re-arming
9279 assert_eq!(d.source, "**ab**\n");
9280 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
9281 d.insert("c");
9282 assert_eq!(d.source, "**ab**c\n");
9283 }
9284
9285 #[test]
9286 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
9287 // Once a mark is realised the caret sits inside the run, so plain typing
9288 // extends it rather than starting a second, adjacent bold span.
9289 let mut d = doc_with("sticky_cont", "\n");
9290 d.caret = 0;
9291 d.toggle(InlineKind::Emph);
9292 d.insert("h");
9293 d.insert("i");
9294 assert_eq!(d.source, "*hi*\n");
9295 }
9296
9297 #[test]
9298 fn moving_the_caret_disarms_a_sticky_mark() {
9299 // Arming a mark and then moving away must not style text elsewhere.
9300 let mut d = doc_with("sticky_disarm", "xy\n");
9301 d.caret = 0;
9302 d.toggle(InlineKind::Strong);
9303 d.move_right(false); // caret 0 → 1, disarms
9304 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9305 d.insert("A");
9306 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
9307 }
9308
9309 #[test]
9310 fn stacked_sticky_marks_apply_together() {
9311 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
9312 let mut d = doc_with("sticky_stack", "\n");
9313 d.caret = 0;
9314 d.toggle(InlineKind::Strong);
9315 d.toggle(InlineKind::Emph);
9316 d.insert("x");
9317 // Land the caret on the styled character and confirm both marks are live.
9318 d.anchor = Some(d.source.find('x').unwrap());
9319 d.caret = d.anchor.unwrap() + 1;
9320 let marks = d.active_inline_marks();
9321 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
9322 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
9323 }
9324
9325 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
9326
9327 #[test]
9328 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
9329 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
9330 // The space inside the run made `**bold **`, which is *not* bold — four
9331 // literal asterisks — so the rich view drew them, correctly and
9332 // uselessly, until the next character happened to close the run again.
9333 let mut d = wysiwyg_doc("edge_typing", "a \n");
9334 d.caret = 2;
9335 d.toggle(InlineKind::Strong);
9336 for c in "bold".chars() {
9337 d.insert(&c.to_string());
9338 }
9339 assert_eq!(d.source, "a **bold**\n");
9340 d.insert(" ");
9341 assert_eq!(
9342 d.source, "a **bold** \n",
9343 "the space belongs outside the run"
9344 );
9345 assert!(
9346 d.active_inline_marks().contains(InlineKind::Strong),
9347 "bold is still what's being typed, so the button stays lit"
9348 );
9349 // What the writer is looking at while all this happens: their words.
9350 d.build_visual(80);
9351 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9352 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
9353 for c in "hey".chars() {
9354 d.insert(&c.to_string());
9355 }
9356 assert_eq!(
9357 d.source, "a **bold hey**\n",
9358 "one bold phrase, not two runs"
9359 );
9360 }
9361
9362 #[test]
9363 fn typing_past_a_space_can_still_leave_the_bold_behind() {
9364 // The other half: the marks stay armed across the space, so ⌘b turns
9365 // them off again there and the next word is plain — the run isn't
9366 // rejoined by a caret that was told not to.
9367 let mut d = wysiwyg_doc("edge_shed", "\n");
9368 d.caret = 0;
9369 d.toggle(InlineKind::Strong);
9370 for c in "bold ".chars() {
9371 d.insert(&c.to_string());
9372 }
9373 assert_eq!(d.source, "**bold** \n");
9374 d.toggle(InlineKind::Strong);
9375 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9376 d.insert("x");
9377 assert_eq!(d.source, "**bold** x\n");
9378 }
9379
9380 #[test]
9381 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
9382 // ⌘b and then a space before any word: the space is not marked (nothing
9383 // is), and the word after it is.
9384 let mut d = wysiwyg_doc("edge_space_first", "a\n");
9385 d.caret = 1;
9386 d.toggle(InlineKind::Strong);
9387 d.insert(" ");
9388 assert_eq!(d.source, "a \n");
9389 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9390 d.insert("b");
9391 assert_eq!(d.source, "a **b**\n");
9392 }
9393
9394 #[test]
9395 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
9396 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
9397 d.caret = 8; // the caret's home at the end of the run's text
9398 d.insert(" ");
9399 assert_eq!(
9400 d.source, "x **bold** \n",
9401 "the space lands past the delimiters"
9402 );
9403 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
9404
9405 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
9406 d.caret = 4; // in front of the "b"
9407 d.insert(" ");
9408 assert_eq!(d.source, "x **bold** y\n");
9409 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
9410 }
9411
9412 #[test]
9413 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
9414 // Backspace over the last letter of a bold phrase.
9415 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
9416 d.caret = 10; // past the "h"
9417 d.backspace();
9418 assert_eq!(d.source, "a **bold** \n");
9419 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
9420 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9421 d.insert("x");
9422 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
9423 }
9424
9425 #[test]
9426 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
9427 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
9428 // mark, which is only text. The marks live on in the caret instead.
9429 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
9430 d.caret = 5;
9431 d.backspace();
9432 assert_eq!(d.source, "a c\n");
9433 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9434 d.insert("x");
9435 assert_eq!(d.source, "a **x** c\n");
9436 }
9437
9438 #[test]
9439 fn typing_over_a_whole_bold_word_keeps_it_bold() {
9440 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
9441 d.anchor = Some(4);
9442 d.caret = 8; // the word, not its delimiters
9443 d.insert("x");
9444 assert_eq!(d.source, "a **x** c\n");
9445 }
9446
9447 #[test]
9448 fn a_code_span_keeps_the_space_it_is_given() {
9449 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
9450 // is still verbatim, so nothing is re-spelt. The repair asks the parser
9451 // rather than a table of kinds, and this is the answer it gets.
9452 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
9453 d.caret = 7;
9454 d.insert(" ");
9455 assert_eq!(d.source, "a `code ` c\n");
9456 }
9457
9458 #[test]
9459 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
9460 // A run's closing delimiter has a caret home on each side of it, one
9461 // column apart on screen — and a plain ← off the space after a bold word
9462 // lands on the outer one. The character drawn behind the caret there is
9463 // still the last letter of the phrase, so that is what Backspace takes;
9464 // the byte behind it is a `*` nobody can see.
9465 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
9466 d.caret = 9;
9467 d.move_left(false);
9468 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
9469 d.backspace();
9470 assert_eq!(
9471 d.source, "**bol** x\n",
9472 "a letter of the phrase, not its `*`"
9473 );
9474 assert_eq!(d.caret, 5);
9475
9476 // And the mirror in front of the opening delimiter, where Delete's
9477 // character is the first letter of the run.
9478 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
9479 d.caret = 1;
9480 d.delete_forward();
9481 assert_eq!(d.source, "x**old**\n");
9482 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
9483 }
9484
9485 #[test]
9486 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
9487 // The byte beside the caret at either edge of a bold word is a `*` the
9488 // rich view draws nothing for. Taking it is not the character delete the
9489 // key was pressed for — it unspells the run and puts a literal asterisk
9490 // on screen (`a *bold** c`). The visible character is the one that goes.
9491 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
9492 d.caret = 4; // in front of the "b"
9493 d.backspace();
9494 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
9495
9496 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
9497 d.caret = 8; // past the "d"
9498 d.delete_forward();
9499 assert_eq!(d.source, "a **bold**c\n");
9500 assert_eq!(d.caret, 8, "and the caret stays inside the run");
9501 d.insert("x");
9502 assert_eq!(d.source, "a **boldx**c\n");
9503
9504 // A code span's backticks are hidden the same way, so they are covered
9505 // by the same rule and not by a list of kinds.
9506 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
9507 d.caret = 3;
9508 d.backspace();
9509 assert_eq!(d.source, "a`code` c\n");
9510 }
9511
9512 #[test]
9513 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
9514 // The asterisks are on the screen there and the caret can stand between
9515 // them, so a delete takes exactly the byte it is aimed at.
9516 let mut d = doc_with("edge_open_src", "a **bold** c\n");
9517 d.caret = 4;
9518 d.backspace();
9519 assert_eq!(d.source, "a *bold** c\n");
9520
9521 let mut d = doc_with("edge_close_src", "a **bold** c\n");
9522 d.caret = 8;
9523 d.delete_forward();
9524 assert_eq!(d.source, "a **bold* c\n");
9525 }
9526
9527 #[test]
9528 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
9529 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
9530 // The space had stepped outside the run (the mark-edge rule), taking the
9531 // caret with it, so the delete put it back down on the far side of the
9532 // closing `**` — one place on screen, and the wrong side of it. Typing
9533 // came out plain and the toolbar went dark, with nothing to see.
9534 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
9535 d.caret = 0;
9536 d.toggle(InlineKind::Strong);
9537 for c in "bold".chars() {
9538 d.insert(&c.to_string());
9539 }
9540 d.insert(" ");
9541 assert_eq!(d.source, "**bold** \n");
9542 d.backspace();
9543 assert_eq!(
9544 d.source, "**bold**\n",
9545 "the space goes, the delimiters stay"
9546 );
9547 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
9548 assert!(
9549 d.active_inline_marks().contains(InlineKind::Strong),
9550 "so the button is still lit"
9551 );
9552 d.insert("x");
9553 assert_eq!(
9554 d.source, "**boldx**\n",
9555 "and the next character is still bold"
9556 );
9557 }
9558
9559 #[test]
9560 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
9561 // What the stranded caret did next: the byte behind it was the closing
9562 // `*`, so a second press took that instead of a letter — `**bold*`, the
9563 // styling gone and an asterisk on the screen where the word had been.
9564 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
9565 d.caret = 0;
9566 d.toggle(InlineKind::Strong);
9567 for c in "bold ".chars() {
9568 d.insert(&c.to_string());
9569 }
9570 assert_eq!(d.source, "**bold** \n");
9571 d.backspace();
9572 d.backspace();
9573 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
9574 assert_eq!(d.caret, 5);
9575 }
9576
9577 #[test]
9578 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
9579 // `***both***` closes two runs with one stack of asterisks: the caret has
9580 // to walk in through all of them, or it lands between the emph and the
9581 // strong and types half-marked.
9582 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
9583 d.caret = 11;
9584 d.backspace();
9585 assert_eq!(d.source, "***both***\n");
9586 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
9587 d.insert("x");
9588 assert_eq!(d.source, "***bothx***\n");
9589 }
9590
9591 #[test]
9592 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
9593 // The settle only moves a caret a run actually closed over. Ordinary
9594 // deletes — inside a run, or in plain prose — are untouched.
9595 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
9596 d.caret = 8;
9597 d.backspace();
9598 assert_eq!(d.source, "a **bol** c\n");
9599 assert_eq!(d.caret, 7);
9600
9601 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
9602 d.caret = 5;
9603 d.backspace();
9604 assert_eq!(d.source, "plai\n");
9605 assert_eq!(d.caret, 4);
9606 }
9607
9608 #[test]
9609 fn the_source_view_leaves_a_delete_where_it_landed() {
9610 // The delimiters are on the screen there, so the offset past them is a
9611 // place the caret can be seen to be — nothing to settle.
9612 let mut d = doc_with("edge_bksp_src", "**bold** \n");
9613 d.caret = 9;
9614 d.backspace();
9615 assert_eq!(d.source, "**bold**\n");
9616 assert_eq!(d.caret, 8);
9617 }
9618
9619 #[test]
9620 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
9621 // `***both***` closes two runs with one stack of asterisks; a space that
9622 // clears only the inner one lands against the outer's and breaks that
9623 // instead.
9624 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
9625 d.caret = 9;
9626 d.insert(" ");
9627 assert_eq!(d.source, "a ***both*** \n");
9628 assert_eq!(d.caret, 13);
9629 d.insert("x");
9630 assert_eq!(d.source, "a ***both x***\n");
9631 }
9632
9633 #[test]
9634 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
9635 // The delimiter shuffle is not an edit the writer made, so it is not a
9636 // step they have to undo past.
9637 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
9638 d.caret = 8;
9639 d.insert(" ");
9640 assert_eq!(d.source, "a **bold** \n");
9641 d.undo();
9642 assert_eq!(d.source, "a **bold**\n");
9643 }
9644
9645 #[test]
9646 fn the_source_view_types_the_space_where_it_was_asked_to() {
9647 // The rule is a rich-view courtesy. In the source view the delimiters are
9648 // on the screen and the user is editing the bytes they can see.
9649 let mut d = doc_with("edge_src", "a **bold** c\n");
9650 d.caret = 8;
9651 d.insert(" ");
9652 assert_eq!(d.source, "a **bold ** c\n");
9653 }
9654
9655 #[test]
9656 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
9657 // Double-clicking a word takes the space after it; bolding that must not
9658 // spell `**word **`, which is not bold at all.
9659 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
9660 d.anchor = Some(2);
9661 d.caret = 7; // "word "
9662 d.toggle(InlineKind::Strong);
9663 assert_eq!(d.source, "a **word** b\n");
9664 d.toggle(InlineKind::Strong);
9665 assert_eq!(d.source, "a word b\n");
9666 d.toggle(InlineKind::Strong);
9667 assert_eq!(
9668 d.source, "a **word** b\n",
9669 "reapplying the mark must not wrap stale delimiter offsets"
9670 );
9671 // And a selection of nothing but whitespace has no word to mark.
9672 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
9673 d.anchor = Some(6);
9674 d.caret = 7;
9675 d.toggle(InlineKind::Strong);
9676 assert_eq!(d.source, "a word b\n");
9677 assert!(d.status.is_some());
9678 }
9679
9680 #[test]
9681 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
9682 let mut d = doc_with("head_set", "hello\n");
9683 d.caret = 2; // caret inside the paragraph, no selection
9684 d.set_block(BlockKind::Heading(1));
9685 assert_eq!(d.source, "# hello\n");
9686 }
9687
9688 #[test]
9689 fn set_block_heading_works_in_wysiwyg_view() {
9690 // The app defaults to WYSIWYG; the caret is a source offset either way.
9691 let mut d = wysiwyg_doc("head_wys", "hello\n");
9692 d.caret = 2;
9693 d.set_block(BlockKind::Heading(1));
9694 assert_eq!(d.source, "# hello\n");
9695 }
9696
9697 #[test]
9698 fn toggle_heading_applies_switches_and_reverts() {
9699 let mut d = doc_with("head_toggle", "hello\n");
9700 d.caret = 2;
9701 d.toggle_heading(1);
9702 assert_eq!(d.source, "# hello\n"); // paragraph → H1
9703 d.toggle_heading(2);
9704 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
9705 d.toggle_heading(2);
9706 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
9707 }
9708
9709 #[test]
9710 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
9711 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
9712 // the blank line but the caret rendered on the *next* line, because the
9713 // separator was a non-navigable decoration row. In Preserve flow that
9714 // blank line is a real caret home — the caret must resolve onto it, and
9715 // typing there makes the soft break that continues the paragraph.
9716 let src = "line one:\nsecond line\n";
9717 let mut d = wysiwyg_doc("pre_enter_lineend", src);
9718 d.set_line_flow(LineFlow::Preserve);
9719 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
9720 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
9721 d.newline();
9722 d.build_visual_unwrapped();
9723 assert_eq!(d.source, "line one:\n\nsecond line\n");
9724 assert_eq!(
9725 d.caret, 10,
9726 "caret sits on the new blank line, not the next line"
9727 );
9728 // The blank line is row 1, and the caret resolves onto it — not row 2.
9729 assert_eq!(
9730 d.vmap.pos_of_offset(10),
9731 (1, 0),
9732 "caret renders on the blank row"
9733 );
9734 assert!(
9735 !d.vmap.rows[1].decoration,
9736 "the blank line is navigable in Preserve"
9737 );
9738 // Typing there makes a soft break: one paragraph, three lines.
9739 d.insert("new clause,");
9740 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
9741 }
9742
9743 #[test]
9744 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
9745 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
9746 // that keeps it one paragraph — where Fold would open a second paragraph.
9747 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
9748 d.set_line_flow(LineFlow::Preserve);
9749 d.caret = 3;
9750 d.newline();
9751 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
9752
9753 // End-of-paragraph: Enter then typing continues the same paragraph on a
9754 // new line (a soft break), not a fresh paragraph.
9755 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
9756 d.set_line_flow(LineFlow::Preserve);
9757 d.caret = 3;
9758 d.newline();
9759 d.insert("def");
9760 assert_eq!(
9761 d.source, "abc\ndef\n",
9762 "end-of-line Enter + typing is a soft break"
9763 );
9764 }
9765
9766 #[test]
9767 fn preserve_double_enter_still_makes_a_paragraph() {
9768 // Two Enters in a row promote to a real paragraph break: the second lands
9769 // on the blank line the first opened and takes the empty-line branch.
9770 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
9771 d.set_line_flow(LineFlow::Preserve);
9772 d.caret = 3;
9773 d.newline();
9774 d.newline();
9775 d.insert("def");
9776 assert_eq!(
9777 d.source, "abc\n\ndef\n",
9778 "double Enter is a paragraph break"
9779 );
9780 }
9781
9782 #[test]
9783 fn preserve_backspace_joins_across_a_soft_break() {
9784 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
9785 // soft break it deletes the single newline and joins the two lines.
9786 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
9787 d.set_line_flow(LineFlow::Preserve);
9788 d.build_visual(80);
9789 d.caret = 4; // start of "def", just past the soft break
9790 d.backspace();
9791 assert_eq!(
9792 d.source, "abcdef\n",
9793 "Backspace joins across the soft break"
9794 );
9795 assert_eq!(d.caret, 3, "caret lands where the lines meet");
9796 }
9797
9798 #[test]
9799 fn fold_enter_still_starts_a_new_paragraph() {
9800 // The default flow is unchanged: a lone `\n` would render as an invisible
9801 // space, so Enter keeps opening the paragraph break that actually shows.
9802 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9803 d.caret = 3;
9804 d.newline();
9805 assert_eq!(
9806 d.source, "abc\n\ndef\n",
9807 "Fold mid-line Enter is a paragraph break"
9808 );
9809 }
9810
9811 #[test]
9812 fn wysiwyg_one_enter_starts_a_new_paragraph() {
9813 // Regression: one Enter left the caret between the two newlines, so typing
9814 // made a soft break (one paragraph) and you needed a second Enter.
9815 let mut d = wysiwyg_doc("wys_enter", "abc\n");
9816 d.caret = 3;
9817 d.newline();
9818 d.insert("def");
9819 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9820 }
9821
9822 #[test]
9823 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9824 // Regression: Enter at the caret's natural End-of-line resting place
9825 // after a bold run with nothing following it (on screen: right after
9826 // "bold", before the hidden closing "**") spliced the paragraph break
9827 // at that very byte offset — which sits *before* the closing "**" in
9828 // the source, since the delimiter is hidden and emits no glyph of its
9829 // own for `push_row`'s "end of row" fallback to count. That severed the
9830 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9831 // "**" alone on the new line instead of leaving "**bold**" intact with
9832 // a fresh empty paragraph after it.
9833 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9834 d.move_end(false); // the WYSIWYG End key, from caret 0
9835 assert_eq!(
9836 d.caret, 6,
9837 "caret rests right after \"bold\", before the hidden \"**\""
9838 );
9839 d.newline();
9840 assert!(
9841 d.source.starts_with("**bold**"),
9842 "the closing ** must stay attached to \"bold\": got {:?}",
9843 d.source
9844 );
9845 assert_eq!(
9846 d.source, "**bold**\n\n\n",
9847 "a fresh empty paragraph follows the still-intact bold run"
9848 );
9849 }
9850
9851 #[test]
9852 fn source_view_enter_is_a_single_newline() {
9853 let mut d = doc_with("src_enter", "abc\n");
9854 d.caret = 3;
9855 d.newline();
9856 assert_eq!(d.source, "abc\n\n");
9857 }
9858
9859 #[test]
9860 fn heading_applies_at_the_end_of_a_paragraph() {
9861 // The caret at a line end sits at the doc level; set_block must still find
9862 // the block on that line.
9863 let mut d = doc_with("head_end", "abc\n");
9864 d.caret = 3; // end of "abc"
9865 d.toggle_heading(1);
9866 assert_eq!(d.source, "# abc\n");
9867 }
9868
9869 #[test]
9870 fn heading_on_an_empty_new_paragraph_creates_one() {
9871 let mut d = wysiwyg_doc("head_empty", "abc\n");
9872 d.caret = 3;
9873 d.newline(); // caret now on a fresh, empty paragraph
9874 d.toggle_heading(1);
9875 d.insert("Title");
9876 assert!(d.source.contains("# Title"), "got {:?}", d.source);
9877 }
9878
9879 #[test]
9880 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9881 // The reported bug, end to end: click a blank line with another one under
9882 // it, press H1, type. The text landed in the heading and the caret's
9883 // offset was right (the source view drew it there), but the rich view
9884 // drew it two rows lower, on the trailing blank line — the empty `# `
9885 // heading had left every row below it short by the marker's two bytes,
9886 // and the blank line ended up claiming the heading's own end offset.
9887 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9888 d.build_visual_unwrapped();
9889 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9890 d.toggle_heading(1);
9891 for c in "title".chars() {
9892 d.insert(&c.to_string());
9893 d.build_visual_unwrapped(); // as a frontend does, one frame per key
9894 }
9895 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9896 assert_eq!(
9897 d.caret_pos(),
9898 (4, 5),
9899 "the caret draws at the end of the heading"
9900 );
9901 }
9902
9903 #[test]
9904 fn clicking_an_empty_heading_types_after_its_marker() {
9905 // The same anchor from the other side: the empty heading's row is its own
9906 // caret home, so a click on it must land past the hidden `# `. Landing in
9907 // front of the hashes made the first keystroke un-heading the line.
9908 let mut d = wysiwyg_doc("head_click", "# \n");
9909 d.build_visual_unwrapped();
9910 d.caret = d.vmap.offset_of_pos(0, 0);
9911 d.insert("x");
9912 assert_eq!(d.source, "# x\n");
9913 }
9914
9915 #[test]
9916 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9917 let mut d = wysiwyg_doc("head_enter", "# Title\n");
9918 d.caret = 7; // end of the heading
9919 d.newline();
9920 d.insert("body");
9921 assert_eq!(d.source, "# Title\n\nbody\n");
9922 }
9923
9924 #[test]
9925 fn wysiwyg_enter_continues_a_bullet_list() {
9926 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9927 d.caret = 6; // end of "item"
9928 d.newline();
9929 d.insert("two");
9930 assert_eq!(d.source, "- item\n- two\n");
9931 }
9932
9933 #[test]
9934 fn wysiwyg_enter_increments_an_ordered_list() {
9935 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9936 d.caret = 6; // end of "one"
9937 d.newline();
9938 d.insert("two");
9939 assert_eq!(d.source, "1. one\n2. two\n");
9940 }
9941
9942 #[test]
9943 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9944 // Regression for the "extra newline" left between a list and the paragraph
9945 // below it. Enter, Enter leaves the list on a fresh empty paragraph
9946 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9947 // Backspace should then take the caret cleanly back to the end of the list
9948 // item, `- item\n\nnext`, not delete a single newline and strand it on the
9949 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9950 // no caret can land on. The map is rebuilt between keystrokes exactly as a
9951 // frontend does, since Backspace reads the stop table to place the delete.
9952 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9953 d.caret = 6; // end of "item"
9954 d.newline();
9955 d.build_visual(80);
9956 d.newline(); // leave the list onto a fresh empty paragraph
9957 d.build_visual(80);
9958 assert_eq!(
9959 d.source, "- item\n\n\n\nnext\n",
9960 "double-Enter opens the empty paragraph"
9961 );
9962 d.backspace();
9963 assert_eq!(
9964 d.source, "- item\n\nnext\n",
9965 "one Backspace collapses the whole gap"
9966 );
9967 assert_eq!(
9968 d.caret, 6,
9969 "and lands the caret back at the end of the list item"
9970 );
9971 }
9972
9973 #[test]
9974 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9975 // The stop-wise delete must not over-reach when there is no block boundary
9976 // to cross: two blank lines in a row are one caret stop apart, so pressing
9977 // Enter on an empty line and then Backspace removes exactly the one newline
9978 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9979 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9980 d.caret = 5; // the empty paragraph the first Enter already opened
9981 d.build_visual(80);
9982 d.newline();
9983 d.build_visual(80);
9984 assert_eq!(
9985 d.source, "abc\n\n\n\n",
9986 "Enter on the blank line adds one newline"
9987 );
9988 d.backspace();
9989 assert_eq!(
9990 d.source, "abc\n\n\n",
9991 "Backspace takes back exactly that one newline"
9992 );
9993 }
9994
9995 #[test]
9996 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9997 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9998 d.caret = 6; // end of the empty "- " item
9999 d.newline();
10000 d.insert("p");
10001 assert_eq!(d.source, "- a\n\np\n");
10002 }
10003
10004 #[test]
10005 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
10006 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
10007 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
10008 // not take the list-exit path (which would splice the `- ` away as if
10009 // leaving an empty item); the AST guard sends it to a normal break and
10010 // leaves the underline intact.
10011 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
10012 assert!(
10013 d.nodes().iter().any(|n| n.kind == Kind::Heading),
10014 "precondition: twig parses this as a heading, not a list",
10015 );
10016 d.caret = 7; // on the `- ` underline line
10017 d.newline();
10018 assert!(
10019 d.source.contains("- "),
10020 "the setext underline survives, not spliced away as a list item: {:?}",
10021 d.source,
10022 );
10023 }
10024
10025 #[test]
10026 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
10027 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
10028 d.caret = 7; // end of "abc" inside the fence
10029 d.newline();
10030 d.insert("def");
10031 assert_eq!(d.source, "```\nabc\ndef\n```\n");
10032 }
10033
10034 #[test]
10035 fn wysiwyg_enter_continues_a_block_quote() {
10036 // Enter opens a new *paragraph* inside the quote, not a second line of
10037 // the same one. `> quote\n> more` is a soft break, which under
10038 // `LineFlow::Fold` renders as a space — the keystroke would look like it
10039 // did nothing. The quoted blank line is what makes the break visible, and
10040 // it's the same thing Enter does in running prose.
10041 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
10042 d.caret = 7; // end of "quote"
10043 d.newline();
10044 d.insert("more");
10045 assert_eq!(d.source, "> quote\n>\n> more\n");
10046 // Still one quote, now holding two paragraphs — not a quote and a stray
10047 // line that fell out of it.
10048 let quotes = d
10049 .nodes()
10050 .iter()
10051 .filter(|n| n.kind == Kind::BlockQuote)
10052 .count();
10053 assert_eq!(quotes, 1);
10054 }
10055
10056 #[test]
10057 fn set_block_makes_a_heading_at_the_caret() {
10058 let mut d = doc_with("head", "Title\n\nbody\n");
10059 d.caret = 0;
10060 d.set_block(BlockKind::Heading(2));
10061 assert_eq!(d.source, "## Title\n\nbody\n");
10062 d.set_block(BlockKind::Paragraph);
10063 assert_eq!(d.source, "Title\n\nbody\n");
10064 }
10065
10066 // ── block containers (quote / list) ──────────────────────────────────────
10067
10068 #[test]
10069 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
10070 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
10071 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
10072 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
10073 // A caret at a line end sits at the doc level; the block is still found.
10074 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
10075 }
10076
10077 #[test]
10078 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
10079 // Every source line of the paragraph gets its own `> `, so a caret left
10080 // on its old byte offset falls one prefix per line above it too far
10081 // back — inside the markup it just asked for rather than in its word.
10082 assert_eq!(
10083 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
10084 "> aaa\n> b|bb\n> ccc\n"
10085 );
10086 }
10087
10088 #[test]
10089 fn toggle_blockquote_works_in_wysiwyg_view() {
10090 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10091 assert_eq!(
10092 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
10093 "> hel|lo\n"
10094 );
10095 assert_eq!(
10096 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
10097 "hel|lo\n"
10098 );
10099 }
10100
10101 #[test]
10102 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
10103 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
10104 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10105 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10106 // The *other* kind converts in place instead of nesting, which is what
10107 // makes the two buttons one three-state control.
10108 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10109 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10110 // Its own kind, over the only item the list holds, takes it off.
10111 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
10112 }
10113
10114 #[test]
10115 fn toggle_list_works_in_wysiwyg_view() {
10116 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10117 assert_eq!(
10118 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
10119 "1. hel|lo\n"
10120 );
10121 assert_eq!(
10122 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
10123 "- hel|lo\n"
10124 );
10125 assert_eq!(
10126 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
10127 "hel|lo\n"
10128 );
10129 }
10130
10131 #[test]
10132 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
10133 // The selection has to grow with the markup: twig takes a container off
10134 // only a range covering every block it holds, so the second press can
10135 // reverse the first only if the result is what's selected.
10136 let mut d = doc_with("list_sel", "abc\n\ndef\n");
10137 d.select_all();
10138 d.toggle_list(true);
10139 assert_eq!(d.source, "1. abc\n\n2. def\n");
10140 assert_eq!(d.selection(), Some((0, d.source.len())));
10141 d.toggle_list(true);
10142 assert_eq!(d.source, "abc\n\ndef\n");
10143 }
10144
10145 #[test]
10146 fn toggle_blockquote_nests_a_partly_covered_quote() {
10147 // twig's rule: covering only some of a container's blocks nests, because
10148 // taking the quote off would drag its uncovered siblings out with it.
10149 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
10150 d.caret = 2; // in the first quoted paragraph only
10151 d.toggle_blockquote();
10152 assert_eq!(d.source, "> > a\n>\n> b\n");
10153 }
10154
10155 #[test]
10156 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
10157 // A blank line used to be no block for twig to wrap —
10158 // `toggle_block_container` answered `NotFound` — so Quote and the list
10159 // buttons did nothing on the very line the H1 button works on, and leaf
10160 // lent twig a scratch paragraph to wrap and took it back out again.
10161 // twig 3.2.0 opens an empty container there itself, so what is left here
10162 // is where the caret lands: inside the marker that was just written.
10163 let mut d = doc_with("quote_blank", "\nabc\n");
10164 d.caret = 0;
10165 d.toggle_blockquote();
10166 assert_eq!(d.source, "> \nabc\n");
10167 assert_eq!(
10168 d.caret, 2,
10169 "the caret belongs inside the quote it just opened"
10170 );
10171 assert!(d.status.is_none(), "{:?}", d.status);
10172 assert!(d.dirty);
10173
10174 // And the paragraph below is still its own block: an empty container one
10175 // soft break from `abc` would take that paragraph into the quote with it.
10176 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
10177 d.caret = 0;
10178 d.toggle_blockquote();
10179 d.build_visual(80);
10180 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
10181
10182 // The same from the other side: a blank line directly under a paragraph
10183 // earns the blank line an empty block needs, rather than being read as a
10184 // soft break inside that paragraph.
10185 let mut d = doc_with("list_blank_below", "abc\n");
10186 d.caret = 4;
10187 d.toggle_list(false);
10188 assert_eq!(d.source, "abc\n\n- ");
10189 assert_eq!(d.caret, 7);
10190 }
10191
10192 #[test]
10193 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
10194 // The gesture the rendering fix is for. `newline` inside a quote already
10195 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
10196 // spelling — but the two marker lines it adds belonged to no node until
10197 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
10198 // just made drew as plain prose under the quote.
10199 let mut d = wysiwyg_doc("quote_enter", "> a\n");
10200 d.caret = 3; // past `a`, at the end of the quoted line
10201 d.newline();
10202 assert_eq!(d.source, "> a\n>\n> \n");
10203 d.build_visual(80);
10204 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
10205 // And the caret is on the new line, not stranded on the old one.
10206 assert_eq!(d.caret, 8);
10207 }
10208
10209 #[test]
10210 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
10211 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
10212 // now it is twig's single edit. Either way one ⌘z has to put the blank
10213 // line back rather than undoing into a half-built document.
10214 for open in [
10215 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
10216 &|d: &mut Doc| d.toggle_list(false),
10217 &|d: &mut Doc| d.toggle_list(true),
10218 ] {
10219 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
10220 d.caret = 3;
10221 open(&mut d);
10222 assert_ne!(d.source, "a\n\n\n\nb\n");
10223 d.undo();
10224 assert_eq!(d.source, "a\n\n\n\nb\n");
10225 }
10226 }
10227
10228 #[test]
10229 fn a_container_toggle_is_one_undo_step() {
10230 let mut d = doc_with("quote_undo", "hello\n");
10231 d.caret = 3;
10232 d.insert("X"); // a typing run the structural edit must not fold into
10233 d.toggle_blockquote();
10234 assert_eq!(d.source, "> helXlo\n");
10235 d.undo();
10236 assert_eq!(d.source, "helXlo\n");
10237 }
10238
10239 // ── links ────────────────────────────────────────────────────────────────
10240
10241 #[test]
10242 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
10243 let mut d = doc_with("link_sel", "word here\n");
10244 d.anchor = Some(0);
10245 d.caret = 4;
10246 d.insert_link("http://x.dev");
10247 assert_eq!(d.source, "[word](http://x.dev) here\n");
10248 // The text, not the destination — so a second press re-points the link
10249 // the first one made rather than nesting one inside it.
10250 assert_eq!(d.selected_text(), Some("word"));
10251 d.insert_link("http://y.dev");
10252 assert_eq!(d.source, "[word](http://y.dev) here\n");
10253 assert_eq!(d.selected_text(), Some("word"));
10254 }
10255
10256 #[test]
10257 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
10258 let mut d = doc_with("img_caret", "before after\n");
10259 d.caret = 7; // between "before " and "after"
10260 d.insert_image("cat.png", "a cat");
10261 assert_eq!(d.source, "before after\n");
10262 // The caret sits just past the inserted image, nothing selected.
10263 assert_eq!(d.selection(), None);
10264 assert_eq!(d.caret, 7 + "".len());
10265 }
10266
10267 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
10268 /// destination at the first space, so the `format!` this used to be wrote
10269 /// something that was not an image at all — and the reader saw the markup as
10270 /// text. twig owns the spelling now, and moves it into the angle form.
10271 #[test]
10272 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
10273 let mut d = doc_with("img_space", "x\n");
10274 d.caret = 0;
10275 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
10276 assert_eq!(
10277 d.source,
10278 "x\n"
10279 );
10280 // And it reads back as an image pointing at the unescaped path — the angle
10281 // brackets are spelling, not part of the destination.
10282 d.caret = 2;
10283 assert_eq!(
10284 d.image_destination_at_caret(),
10285 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
10286 );
10287 }
10288
10289 /// A `)` in a caption or a filename must not close the image early.
10290 #[test]
10291 fn insert_image_escapes_a_paren_in_either_half() {
10292 let mut d = doc_with("img_paren", "x\n");
10293 d.caret = 0;
10294 d.insert_image("a)b.png", "");
10295 assert_eq!(d.source, "b.png)x\n");
10296 d.caret = 2;
10297 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
10298 }
10299
10300 #[test]
10301 fn insert_image_uses_the_selection_as_alt_text() {
10302 let mut d = doc_with("img_sel", "caption here\n");
10303 d.anchor = Some(0);
10304 d.caret = 7; // "caption"
10305 d.insert_image("p.png", "ignored fallback");
10306 assert_eq!(d.source, " here\n");
10307 }
10308
10309 #[test]
10310 fn insert_image_with_no_alt_leaves_empty_brackets() {
10311 let mut d = doc_with("img_noalt", "\n");
10312 d.caret = 0;
10313 d.insert_image("logo.svg", "");
10314 assert_eq!(d.source, "\n");
10315 }
10316
10317 // ── move_block ─────────────────────────────────────────────────────────────
10318
10319 /// A move, then its undo: one step takes the whole thing back.
10320 fn moved(name: &str, body: &str, from: usize, to: usize) -> (String, Doc) {
10321 let mut d = doc_with(name, body);
10322 d.caret = from;
10323 let steps = d.undo_steps;
10324 d.move_block(from, to);
10325 let after = d.source.clone();
10326 assert_eq!(d.undo_steps, steps + 1, "one undo step");
10327 assert!(d.dirty);
10328 d.undo();
10329 assert_eq!(d.source, body, "one undo restores the original");
10330 (after, d)
10331 }
10332
10333 #[test]
10334 fn move_block_carries_a_paragraph_between_two_paragraphs_and_to_the_end() {
10335 let body = "a\n\nb\n\nc\n";
10336 assert_eq!(moved("mv_p1", body, 6, 3).0, "a\n\nc\n\nb\n");
10337 assert_eq!(moved("mv_p2", body, 0, body.len()).0, "b\n\nc\n\na\n");
10338 assert_eq!(moved("mv_p3", body, 3, 0).0, "b\n\na\n\nc\n");
10339 }
10340
10341 #[test]
10342 fn move_block_carries_an_image_block() {
10343 let body = "a\n\n\n\nc\n";
10344 assert_eq!(moved("mv_img1", body, 4, 0).0, "\n\na\n\nc\n");
10345 assert_eq!(
10346 moved("mv_img2", body, 4, body.len()).0,
10347 "a\n\nc\n\n\n"
10348 );
10349 }
10350
10351 #[test]
10352 fn move_block_carries_a_table() {
10353 let body = "a\n\n| h |\n|---|\n| c |\n\nc\n";
10354 assert_eq!(
10355 moved("mv_tbl1", body, 5, 0).0,
10356 "| h |\n|---|\n| c |\n\na\n\nc\n"
10357 );
10358 assert_eq!(
10359 moved("mv_tbl2", body, 5, body.len()).0,
10360 "a\n\nc\n\n| h |\n|---|\n| c |\n"
10361 );
10362 }
10363
10364 #[test]
10365 fn move_block_carries_a_code_block() {
10366 let body = "a\n\n```rs\nx\n```\n\nc\n";
10367 assert_eq!(moved("mv_code1", body, 8, 0).0, "```rs\nx\n```\n\na\n\nc\n");
10368 assert_eq!(
10369 moved("mv_code2", body, 8, body.len()).0,
10370 "a\n\nc\n\n```rs\nx\n```\n"
10371 );
10372 }
10373
10374 #[test]
10375 fn move_block_onto_its_own_boundary_is_a_quiet_no_op() {
10376 let mut d = doc_with("mv_noop", "a\n\nb\n");
10377 let steps = d.undo_steps;
10378 d.move_block(0, 0);
10379 d.move_block(0, 3);
10380 assert_eq!(d.source, "a\n\nb\n");
10381 assert_eq!(d.undo_steps, steps);
10382 assert_eq!(d.status, None);
10383 assert!(!d.dirty);
10384 }
10385
10386 #[test]
10387 fn move_block_into_a_fence_is_refused_with_a_status() {
10388 let mut d = doc_with("mv_fence", "a\n\n```\nx\ny\n```\n");
10389 d.move_block(0, 7);
10390 assert_eq!(d.source, "a\n\n```\nx\ny\n```\n");
10391 assert!(
10392 d.status
10393 .as_deref()
10394 .is_some_and(|s| s.starts_with("move block"))
10395 );
10396 }
10397
10398 #[test]
10399 fn move_block_rides_the_caret_with_the_block() {
10400 // Down: "second" is line 0 of its block, caret 3 bytes from its end.
10401 let mut d = doc_with("mv_caret1", "first\n\nsecond\n\nthird\n");
10402 d.caret = 10; // "sec|ond"
10403 d.move_block(10, d.source.len());
10404 assert_eq!(d.source, "first\n\nthird\n\nsecond\n");
10405 assert_eq!(&d.source[d.caret..], "ond\n");
10406 // Up, across a wrapped paragraph's second line.
10407 let mut d = doc_with("mv_caret2", "first\n\nsecond\nline two\n");
10408 d.caret = 16; // "li|ne two"
10409 d.move_block(16, 0);
10410 assert_eq!(d.source, "second\nline two\n\nfirst\n");
10411 assert_eq!(&d.source[d.caret..], "ne two\n\nfirst\n");
10412 assert_eq!(d.selection(), None);
10413 }
10414
10415 #[test]
10416 fn move_block_keeps_the_caret_on_its_line_through_a_quotes_prefix() {
10417 let mut d = doc_with("mv_quote_caret", "para\n\n> a\n");
10418 d.caret = 2; // "pa|ra"
10419 d.move_block(2, 9); // after a, inside the quote
10420 assert_eq!(d.source, "> a\n>\n> para\n");
10421 assert_eq!(&d.source[d.caret..], "ra\n");
10422 }
10423
10424 #[test]
10425 fn move_block_up_and_down_step_over_siblings() {
10426 let mut d = doc_with("mv_updown", "a\n\nb\n\nc\n");
10427 d.caret = 3;
10428 d.move_block_down();
10429 assert_eq!(d.source, "a\n\nc\n\nb\n");
10430 assert_eq!(&d.source[d.caret..], "b\n");
10431 d.move_block_down();
10432 assert_eq!(d.source, "a\n\nc\n\nb\n", "nothing below the last block");
10433 assert_eq!(d.status.as_deref(), Some("move block: nothing below"));
10434 d.move_block_up();
10435 d.move_block_up();
10436 assert_eq!(d.source, "b\n\na\n\nc\n");
10437 assert_eq!(&d.source[d.caret..], "b\n\na\n\nc\n");
10438 d.move_block_up();
10439 assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10440 }
10441
10442 #[test]
10443 fn move_block_up_and_down_reorder_list_items_with_their_children() {
10444 let mut d = doc_with("mv_items", "- a\n - x\n- b\n- c\n");
10445 d.caret = 12; // in "b"
10446 d.move_block_up();
10447 assert_eq!(d.source, "- b\n- a\n - x\n- c\n");
10448 assert_eq!(&d.source[d.caret..], "b\n- a\n - x\n- c\n");
10449 d.caret = 6; // in "a"
10450 d.move_block_down();
10451 assert_eq!(d.source, "- b\n- c\n- a\n - x\n");
10452 assert_eq!(&d.source[d.caret..], "a\n - x\n");
10453 // A nested item leaves its list upward, as an item of the outer one.
10454 d.caret = 16; // in "x"
10455 d.move_block_up();
10456 assert_eq!(d.source, "- b\n- c\n- x\n- a\n");
10457 }
10458
10459 #[test]
10460 fn move_block_on_a_lone_item_that_stays_a_bullet_is_no_step() {
10461 let mut d = doc_with("mv_lone_item", "x\n\n- b\n\ny\n");
10462 d.caret = 5;
10463 let steps = d.undo_steps;
10464 d.move_block_up();
10465 assert_eq!(d.source, "x\n\n- b\n\ny\n");
10466 assert_eq!(
10467 d.undo_steps, steps,
10468 "a move that rewrote nothing is no undo step"
10469 );
10470 assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10471 }
10472
10473 #[test]
10474 fn move_block_up_leaves_a_container_at_its_first_block_and_down_at_its_last() {
10475 let mut d = doc_with("mv_leave", "x\n\n> a\n>\n> b\n\ny\n");
10476 d.caret = 5; // "a"
10477 d.move_block_up();
10478 assert_eq!(d.source, "x\n\na\n\n> b\n\ny\n");
10479 d.caret = 8; // "b"
10480 d.move_block_down();
10481 assert_eq!(d.source, "x\n\na\n\nb\n\ny\n");
10482 // A tail block leaves its item into the next item's tail, then the list.
10483 let mut d = doc_with("mv_tail", "- a\n\n t\n- b\n");
10484 d.caret = 7;
10485 d.move_block_down();
10486 assert_eq!(d.source, "- a\n- b\n\n t\n");
10487 d.move_block_down();
10488 assert_eq!(d.source, "- a\n- b\n\nt\n");
10489 // And a paragraph after a quote steps over the whole quote, not into it.
10490 let mut d = doc_with("mv_over", "x\n\n> a\n>\n> b\n\ny\n");
10491 d.caret = 14;
10492 d.move_block_up();
10493 assert_eq!(d.source, "x\n\ny\n\n> a\n>\n> b\n");
10494 assert_eq!(d.caret, 3);
10495 d.move_block_down();
10496 assert_eq!(d.status, None);
10497 assert_eq!(d.source, "x\n\n> a\n>\n> b\n\ny\n");
10498 // Above a quote that opens the document is offset 0 — before the
10499 // quote, not inside it.
10500 let mut d = doc_with("mv_over_top", "> a\n\ny\n");
10501 d.caret = 5;
10502 d.move_block_up();
10503 assert_eq!(d.source, "y\n\n> a\n");
10504 assert_eq!(d.caret, 0);
10505 }
10506
10507 #[test]
10508 fn move_block_up_from_the_first_block_of_a_quote_that_opens_the_document_leaves_it() {
10509 let mut d = doc_with("mv_top_quote", "> a\n>\n> b\n");
10510 d.caret = 2;
10511 d.move_block_up();
10512 assert_eq!(d.source, "a\n\n> b\n");
10513 assert_eq!(d.caret, 0);
10514 assert_eq!(d.status, None);
10515 }
10516
10517 #[test]
10518 fn move_block_on_a_blank_line_or_read_only_does_nothing() {
10519 let mut d = doc_with("mv_blank", "a\n\nb\n");
10520 d.caret = 2;
10521 d.move_block_down();
10522 assert_eq!(d.source, "a\n\nb\n");
10523 assert_eq!(d.status.as_deref(), Some("move block: no block here"));
10524 d.read_only = true;
10525 d.caret = 0;
10526 d.move_block_down();
10527 d.move_block(0, 5);
10528 assert_eq!(d.source, "a\n\nb\n");
10529 }
10530
10531 #[test]
10532 fn move_block_never_lands_above_hidden_frontmatter() {
10533 let mut d = wysiwyg_doc("mv_fm", "---\nt: x\n---\n\na\n\nb\n");
10534 let b = d.source.find('b').unwrap();
10535 d.move_block(b, 0);
10536 assert_eq!(d.source, "---\nt: x\n---\n\nb\n\na\n");
10537 }
10538
10539 #[test]
10540 fn block_range_at_is_the_block_a_move_picks_up() {
10541 let mut d = doc_with("mv_range", "a\n\n- b\n - c\n\nd\n");
10542 assert_eq!(d.block_range_at(0), Some(0..1));
10543 assert_eq!(d.block_range_at(5), Some(3..12), "the item with its child");
10544 assert_eq!(d.block_range_at(10), Some(7..12), "the nested item alone");
10545 assert_eq!(d.block_range_at(2), None, "a blank line");
10546 }
10547
10548 #[test]
10549 fn drop_target_at_splits_a_block_at_its_middle_row_and_ends_below_everything() {
10550 let long = "two ".repeat(40).trim_end().to_string(); // wraps to three rows at 80
10551 let mut d = wysiwyg_doc("drop", &format!("one\n\n{long} four\n\nfive\n"));
10552 let rows = d.vmap.rows.len();
10553 assert_eq!(rows, 7, "one, gap, three wrapped rows, gap, five");
10554 assert_eq!(d.drop_target_at(0), Some(DropTarget { offset: 0, row: 0 }));
10555 let two = d.source.find("two").unwrap();
10556 assert_eq!(
10557 d.drop_target_at(1),
10558 Some(DropTarget {
10559 offset: two,
10560 row: 2
10561 }),
10562 "the gap resolves to the block under it"
10563 );
10564 assert_eq!(
10565 d.drop_target_at(2),
10566 Some(DropTarget {
10567 offset: two,
10568 row: 2
10569 })
10570 );
10571 assert_eq!(
10572 d.drop_target_at(3),
10573 Some(DropTarget {
10574 offset: two,
10575 row: 2
10576 })
10577 );
10578 let four_end = d.source.find("four").unwrap() + 4;
10579 assert_eq!(
10580 d.drop_target_at(4),
10581 Some(DropTarget {
10582 offset: four_end,
10583 row: 5
10584 })
10585 );
10586 assert_eq!(
10587 d.drop_target_at(rows + 3),
10588 Some(DropTarget {
10589 offset: d.source.len(),
10590 row: rows
10591 })
10592 );
10593 // And the offsets are ones move_block takes.
10594 let five = d.source.find("five").unwrap();
10595 let t = d.drop_target_at(2).unwrap();
10596 d.move_block(five, t.offset);
10597 assert_eq!(d.source, format!("one\n\nfive\n\n{long} four\n"));
10598 }
10599
10600 #[test]
10601 fn append_media_lands_at_the_end_as_its_own_block() {
10602 let mut d = doc_with("append_mid", "first word and more\n");
10603 d.caret = 0; // an editor nobody has tapped: the caret is at the start
10604 d.append_media(MediaKind::Image, "cat.png", "");
10605 assert_eq!(d.source, "first word and more\n\n");
10606 assert_eq!(d.selection(), None);
10607 assert_eq!(d.caret, "first word and more\n\n".len());
10608 }
10609
10610 #[test]
10611 fn append_media_needs_no_separator_after_a_blank_line_or_in_an_empty_document() {
10612 let mut d = doc_with("append_blank", "para\n\n");
10613 d.append_media(MediaKind::Image, "a.png", "");
10614 assert_eq!(d.source, "para\n\n");
10615
10616 let mut e = doc_with("append_empty", "");
10617 e.append_media(MediaKind::Image, "b.png", "");
10618 assert_eq!(e.source, "");
10619
10620 let mut f = doc_with("append_noeol", "no newline at end");
10621 f.anchor = Some(0);
10622 f.caret = 2; // a selection, which the verb ignores
10623 f.append_media(MediaKind::Video, "clip.mp4", "");
10624 assert_eq!(
10625 f.source,
10626 "no newline at end\n\n<video src=\"clip.mp4\" controls></video>"
10627 );
10628 }
10629
10630 #[test]
10631 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
10632 // The round trip is the point: it's no use writing markup the reader
10633 // can't pick up again. This is the pair that only holds from twig 2.5.1
10634 // on — before it, the one-line form went in fine and came back as a
10635 // paragraph of raw tags, publishing no media at all.
10636 let mut d = doc_with("vid_rt", "\n");
10637 d.caret = 0;
10638 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
10639 assert_eq!(
10640 d.source,
10641 "<video src=\"clip.mp4\" controls>a clip</video>\n"
10642 );
10643
10644 d.build_visual(80);
10645 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
10646 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
10647 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
10648 assert_eq!(d.vmap.media[0].alt, "a clip");
10649 }
10650
10651 #[test]
10652 fn insert_media_spells_audio_with_its_own_tag() {
10653 let mut d = doc_with("aud_rt", "\n");
10654 d.caret = 0;
10655 d.insert_media(MediaKind::Audio, "take.mp3", "");
10656 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
10657 d.build_visual(80);
10658 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
10659 }
10660
10661 #[test]
10662 fn insert_media_uses_the_selection_as_fallback_text() {
10663 // The same courtesy `insert_image` does with alt: select a caption,
10664 // insert, and the caption labels the thing rather than being replaced.
10665 let mut d = doc_with("vid_sel", "the talk here\n");
10666 d.anchor = Some(0);
10667 d.caret = 8; // "the talk"
10668 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
10669 assert_eq!(
10670 d.source,
10671 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
10672 );
10673 }
10674
10675 #[test]
10676 fn insert_media_with_an_image_kind_is_just_insert_image() {
10677 let mut d = doc_with("img_via_media", "\n");
10678 d.caret = 0;
10679 d.insert_media(MediaKind::Image, "logo.svg", "x");
10680 assert_eq!(d.source, "\n");
10681 }
10682
10683 // ── thematic breaks ─────────────────────────────────────────────────────
10684
10685 /// The node the source parses as at `caret` — what confirms an inserted
10686 /// `---` actually reads back as a rule, not stray text or a setext heading.
10687 ///
10688 /// The *narrowest* node covering the offset. Every ancestor covers it too,
10689 /// and since twig 2.8 that includes the `doc` root, which now carries a real
10690 /// span (it reported none before, so taking the first match used to land on
10691 /// the block by luck and now always answers `"doc"`).
10692 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
10693 d.nodes()
10694 .into_iter()
10695 .filter(|n| n.span.start <= caret && caret < n.span.end)
10696 .min_by_key(|n| n.span.end - n.span.start)
10697 .map(|n| n.kind)
10698 }
10699
10700 #[test]
10701 fn a_task_box_toggles_at_the_caret_and_reads_back() {
10702 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
10703 d.caret = 8; // inside "todo"
10704 assert_eq!(d.task_checked_at_caret(), Some(false));
10705 d.toggle_task_checked();
10706 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
10707 assert_eq!(d.task_checked_at_caret(), Some(true));
10708 d.toggle_task_checked();
10709 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
10710 }
10711
10712 #[test]
10713 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
10714 // The whole reason `toggle_task_at` exists apart from the caret form:
10715 // ticking a box elsewhere must not move the cursor out of what's being
10716 // typed.
10717 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
10718 d.caret = 8; // inside "first"
10719 let second = d.source.find("second").unwrap();
10720 d.toggle_task_at(second);
10721 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
10722 assert_eq!(d.caret, 8, "the caret stayed in the first item");
10723 }
10724
10725 #[test]
10726 fn a_plain_item_gains_and_loses_a_box() {
10727 let mut d = doc_with("task_mint", "- plain\n");
10728 d.caret = 4;
10729 assert_eq!(d.task_checked_at_caret(), None);
10730 d.toggle_task_item();
10731 assert_eq!(d.source, "- [ ] plain\n");
10732 assert_eq!(
10733 d.task_checked_at_caret(),
10734 Some(false),
10735 "a new box arrives unticked"
10736 );
10737 d.toggle_task_item();
10738 assert_eq!(d.source, "- plain\n");
10739 }
10740
10741 #[test]
10742 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
10743 // `set checked` must not silently convert a bullet into a task — that is
10744 // `toggle_task_item`'s job, and twig refuses it here.
10745 let mut d = doc_with("task_none", "- plain\n");
10746 d.caret = 4;
10747 d.toggle_task_checked();
10748 assert_eq!(d.source, "- plain\n", "nothing written");
10749 assert!(
10750 d.status.is_some(),
10751 "the refusal should reach the status line"
10752 );
10753 }
10754
10755 #[test]
10756 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
10757 let mut d = doc_with("task_quote", "> - [ ] nested\n");
10758 d.caret = d.source.find("nested").unwrap();
10759 assert_eq!(d.task_checked_at_caret(), Some(false));
10760 d.toggle_task_checked();
10761 assert_eq!(d.source, "> - [x] nested\n");
10762 }
10763
10764 #[test]
10765 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
10766 // A rule is a block, so twig's `insert_thematic_break` alone lands it
10767 // after the whole paragraph. `split_block` parts the paragraph first and
10768 // the rule is aimed at the *first* half, which is what a rule button is
10769 // understood to do — and what leaf spelled by hand until twig grew both
10770 // halves of the gesture.
10771 let mut d = doc_with("hr_mid", "before after\n");
10772 d.caret = 7; // between "before " and "after"
10773 d.insert_thematic_break();
10774 assert_eq!(d.source, "before \n\n---\n\nafter\n");
10775 assert_eq!(d.selection(), None);
10776 assert_eq!(
10777 kind_at(&mut d, "before \n\n".len()),
10778 Some(Kind::ThematicBreak)
10779 );
10780 }
10781
10782 #[test]
10783 fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
10784 // At the end there is nothing to part, and a split there writes the
10785 // separator anyway — a blank line and the empty slot the next paragraph
10786 // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
10787 // two blank lines nothing fills. Now the rule lands after the paragraph,
10788 // where the split-and-aim was sending it regardless. Both formats, and
10789 // both shapes of a last line — terminated, and still being typed —
10790 // because the two reach the split through different doors: Markdown's
10791 // paragraph span stops before its newline, so `para\n` at 4 never split
10792 // there, but `para` at 4 did.
10793 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10794 for src in ["para\n", "para"] {
10795 let mut d = Doc::from_source(src.into(), fmt).unwrap();
10796 d.caret = 4;
10797 d.insert_thematic_break();
10798 assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
10799 assert_eq!(d.caret, d.source.len());
10800 }
10801 // Mid-document the slot sat between the rule and the next block.
10802 let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
10803 d.caret = 4;
10804 d.insert_thematic_break();
10805 assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
10806 // Trailing whitespace is nothing to part either.
10807 let mut d = Doc::from_source("para \n".into(), fmt).unwrap();
10808 d.caret = 4;
10809 d.insert_thematic_break();
10810 assert_eq!(d.source, format!("para \n\n{rule}\n"), "{fmt:?}");
10811 }
10812 }
10813
10814 #[test]
10815 fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
10816 // The split at the start parts nothing, but it is kept on purpose:
10817 // `|para` becomes `\npara` with the caret on a blank line, and twig
10818 // (3.5.2) writes a rule aimed at a blank line ON that line — the only
10819 // way "before the paragraph" is reachable through a gesture that only
10820 // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
10821 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10822 let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
10823 d.caret = 0;
10824 d.insert_thematic_break();
10825 assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
10826 let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
10827 d.caret = 6;
10828 d.insert_thematic_break();
10829 assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
10830 }
10831 }
10832
10833 #[test]
10834 fn insert_thematic_break_on_a_blank_line_takes_that_line() {
10835 // The gap between two blocks is where a click lands the caret; the
10836 // rule goes on the blank, one blank each side.
10837 let mut d = doc_with("hr_gap", "a\n\nb\n");
10838 d.caret = 2;
10839 d.insert_thematic_break();
10840 assert_eq!(d.source, "a\n\n---\n\nb\n");
10841 }
10842
10843 #[test]
10844 fn insert_table_at_a_paragraph_s_end_splits_nothing() {
10845 // The same door as the rule's, through the placement they share.
10846 let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10847 d.caret = 4;
10848 d.insert_table(1, 1);
10849 assert_eq!(d.source, "para\n\n| |\n|---|\n| |\n");
10850 let mut d = doc_with("table_end_typed", "para");
10851 d.caret = 4;
10852 d.insert_table(1, 1);
10853 assert_eq!(d.source, "para\n\n| |\n| --- |\n| |\n");
10854 assert!(d.caret_in_table());
10855 }
10856
10857 #[test]
10858 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
10859 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
10860 // and leaf wrote the first into both until twig started spelling it.
10861 let mut md = doc_with("hr_md", "para\n");
10862 md.caret = 2;
10863 md.insert_thematic_break();
10864 assert_eq!(md.source, "pa\n\n---\n\nra\n");
10865
10866 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10867 dj.caret = 2;
10868 dj.insert_thematic_break();
10869 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
10870 }
10871
10872 #[test]
10873 fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
10874 // The table goes *at* the caret the way the rule does: the paragraph is
10875 // parted first, and twig writes the grid after its first half. The
10876 // caret then sits in the first header cell — selected, as Tab would
10877 // leave it — so the next keystroke is the heading.
10878 let mut d = doc_with("table_mid", "before after\n");
10879 d.caret = 7;
10880 d.insert_table(2, 3);
10881 assert_eq!(
10882 d.source,
10883 "before \n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nafter\n"
10884 );
10885 assert!(d.caret_in_table());
10886 let first_bar = d.source.find('|').unwrap();
10887 assert!(
10888 d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
10889 "caret {} is not in the header row",
10890 d.caret
10891 );
10892 d.insert("Name");
10893 assert!(d.source.starts_with("before \n\n| Name | | |\n"));
10894 // And the grid the table was written into is one the table keys walk
10895 // (over the map a frontend rebuilds after every edit).
10896 d.build_visual(80);
10897 assert!(d.cell_tab(true));
10898 d.insert("Qty");
10899 assert!(d.source.starts_with("before \n\n| Name | Qty | |\n"));
10900 }
10901
10902 #[test]
10903 fn insert_table_spells_the_grid_the_format_s_own_way() {
10904 // Djot's delimiter row is unpadded, and leaf never has to know that.
10905 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10906 dj.caret = 2;
10907 dj.insert_table(1, 2);
10908 assert_eq!(dj.source, "pa\n\n| | |\n|---|---|\n| | |\n\nra\n");
10909 assert!(dj.caret_in_table());
10910 }
10911
10912 #[test]
10913 fn insert_table_refuses_where_the_format_spells_no_table() {
10914 let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
10915 d.caret = 4;
10916 d.insert_table(1, 1);
10917 assert_eq!(d.source, "<p>ab</p>\n");
10918 assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
10919 assert!(!d.capabilities().table);
10920 }
10921
10922 #[test]
10923 fn insert_table_reports_a_zero_shape_and_writes_nothing() {
10924 let mut d = doc_with("table_zero", "para\n");
10925 d.caret = 2;
10926 d.insert_table(0, 2);
10927 assert_eq!(d.source, "para\n");
10928 assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
10929 }
10930
10931 #[test]
10932 fn clicking_below_a_final_thematic_break_can_type_after_it() {
10933 let mut d = wysiwyg_doc("hr_final_click", "---\n");
10934 d.build_visual(80);
10935 d.click(d.vmap.num_rows() + 2, 0, false);
10936 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
10937 d.insert("after");
10938 assert_eq!(d.source, "---\nafter");
10939 }
10940
10941 #[test]
10942 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
10943 // The same bytes are two documents. In Markdown ` - b` is a nested item
10944 // and the next one belongs beside it, at its indent. In Djot a list
10945 // marker can't interrupt a paragraph, so those bytes are literal text in
10946 // item `a` and there is only one item — writing ` - ` under it would add
10947 // no item at all, just more text, and the new sibling has to go to
10948 // column zero. Both spellings come out of the *enclosing item's* line.
10949 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
10950 md.caret = "- a\n - b".len();
10951 md.newline();
10952 assert_eq!(md.source, "- a\n - b\n - \n");
10953 assert_eq!(list_items(&mut md), 3);
10954
10955 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
10956 dj.view = View::Wysiwyg;
10957 dj.build_visual(80);
10958 dj.caret = "- a\n - b".len();
10959 dj.newline();
10960 assert_eq!(dj.source, "- a\n - b\n- \n");
10961 assert_eq!(list_items(&mut dj), 2);
10962
10963 // Where Djot's nesting is real — opened by a blank line — the indent is
10964 // reproduced there too, and the two formats agree again.
10965 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
10966 dj.view = View::Wysiwyg;
10967 dj.build_visual(80);
10968 dj.caret = "- a\n\n - b".len();
10969 dj.newline();
10970 assert_eq!(dj.source, "- a\n\n - b\n - \n");
10971 assert_eq!(list_items(&mut dj), 3);
10972 }
10973
10974 #[test]
10975 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
10976 // Tab replaces the line's whole prefix with the one twig spells, so the
10977 // quote markers, the parent's indent and an ordered marker's extra
10978 // column are all its answer rather than leaf's arithmetic.
10979 for (name, body, caret, want) in [
10980 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
10981 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
10982 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
10983 // A checkbox is markup the item's own text wraps past, but a nested
10984 // list may only open at the *list* marker's column — four in from
10985 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
10986 // parses as one item, not two.
10987 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
10988 (
10989 "quoted task",
10990 "> - [ ] a\n> - [ ] b\n",
10991 18,
10992 "> - [ ] a\n> - [ ] b\n",
10993 ),
10994 ] {
10995 let mut doc = wysiwyg_doc(name, body);
10996 doc.caret = caret;
10997 doc.indent();
10998 assert_eq!(doc.source, want, "{name}");
10999 // The nesting is real, not just indented text.
11000 assert_eq!(list_items(&mut doc), 2, "{name}");
11001 }
11002 }
11003
11004 #[test]
11005 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
11006 // The same bytes, the two formats disagreeing, and a gesture that used
11007 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
11008 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
11009 // so those bytes are literal text inside item `a` — there is nothing to
11010 // outdent, and treating them as a marker turned one item into two, a
11011 // structural edit from a keystroke that should delete one character.
11012 //
11013 // twig's `line_prefix` is what tells them apart: it reports the marker
11014 // on the Markdown line and nothing on the Djot one, which is a
11015 // continuation. No byte scan can reach that answer.
11016 let src = "- a\n - b\n";
11017 let at = "- a\n - ".len();
11018
11019 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
11020 md.view = View::Wysiwyg;
11021 md.build_visual(80);
11022 md.caret = at;
11023 md.backspace();
11024 assert_eq!(md.source, "- a\n- b\n");
11025 assert_eq!(list_items(&mut md), 2);
11026
11027 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
11028 dj.view = View::Wysiwyg;
11029 dj.build_visual(80);
11030 dj.caret = at;
11031 dj.backspace();
11032 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
11033 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
11034 }
11035
11036 #[test]
11037 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
11038 // Leaf used to spell the next item from the marker bytes it scanned, and
11039 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
11040 // and dropped out of the checklist. twig reproduces the whole
11041 // continuation, and a fresh item is always unticked however the one above
11042 // it stands.
11043 for (name, body, want) in [
11044 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
11045 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
11046 ] {
11047 let mut doc = wysiwyg_doc(name, body);
11048 doc.caret = body.trim_end_matches('\n').len();
11049 doc.newline();
11050 assert_eq!(doc.source, want, "{name}");
11051 // Both items are checklist items — the new one is a box, not the
11052 // plain bullet the old marker scan left behind — and it is unticked
11053 // whichever way the one above it faces.
11054 let boxes: Vec<Option<bool>> = doc
11055 .nodes()
11056 .iter()
11057 .filter(|n| n.kind == Kind::TaskListItem)
11058 .map(|n| n.checked)
11059 .collect();
11060 assert_eq!(boxes.len(), 2, "{name}");
11061 assert_eq!(boxes[1], Some(false), "{name}");
11062 }
11063 }
11064
11065 #[test]
11066 fn a_split_takes_the_space_the_caret_was_in_front_of() {
11067 // Splicing a break at the caret strands the space the words were parted
11068 // at on the head of the second block, where it reads as an indent nobody
11069 // typed. twig's split consumes it.
11070 for (name, body, caret, want) in [
11071 ("para", "one two\n", 3, "one\n\ntwo\n"),
11072 ("item", "- one two\n", 5, "- one\n- two\n"),
11073 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
11074 // A heading takes leaf's own path, which has to match.
11075 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
11076 ] {
11077 let mut doc = wysiwyg_doc(name, body);
11078 doc.caret = caret;
11079 doc.newline();
11080 assert_eq!(doc.source, want, "{name}");
11081 }
11082 }
11083
11084 #[test]
11085 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
11086 // The one place leaf keeps its own break: `split_block` repeats the `#`,
11087 // and Enter after a title is how the body under it is asked for.
11088 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
11089 doc.caret = "# Title".len();
11090 doc.newline();
11091 doc.insert("body");
11092 assert_eq!(doc.source, "# Title\n\nbody\n");
11093 assert_eq!(
11094 doc.nodes()
11095 .iter()
11096 .filter(|n| n.kind == Kind::Heading)
11097 .count(),
11098 1
11099 );
11100 }
11101
11102 #[test]
11103 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
11104 // A quoted item's marker doesn't open its line, so a scan that starts at
11105 // column zero finds a `>` where it wanted a bullet, calls the line "not a
11106 // list" and hands Enter to the plain-quote branch — which writes `> ` and
11107 // drops the list. The next item has to carry the whole prefix.
11108 for (name, body, want) in [
11109 ("flat", "> - a\n", "> - a\n> - \n"),
11110 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
11111 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
11112 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
11113 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
11114 ] {
11115 let mut doc = wysiwyg_doc(name, body);
11116 doc.caret = body.trim_end_matches('\n').len();
11117 doc.newline();
11118 assert_eq!(doc.source, want, "{name}");
11119 // The marker isn't just spelled right, it parses as an item.
11120 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
11121 }
11122 }
11123
11124 #[test]
11125 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
11126 // Double-Enter exits the list. Unquoted that means a blank line, but a
11127 // *bare* blank line would end the quote too and drop the caret out of it,
11128 // so the separator keeps its `>` and the caret's line keeps its `> `.
11129 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
11130 doc.caret = "> - a\n> - ".len();
11131 doc.newline();
11132 assert_eq!(doc.source, "> - a\n>\n> \n");
11133 assert_eq!(list_items(&mut doc), 1);
11134 // What "still in the quote" means for the next keystroke: the caret sits
11135 // behind the prefix, and what's typed there lands inside the quote as a
11136 // paragraph of its own — not as more of item `a`.
11137 doc.insert("x");
11138 assert_eq!(doc.source, "> - a\n>\n> x\n");
11139 assert!(
11140 doc.editor
11141 .ancestors_at(doc.caret - 1)
11142 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
11143 );
11144 }
11145
11146 #[test]
11147 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
11148 // The marker is hidden block markup, so Backspace over it is structural —
11149 // but only the marker is the list's. Splicing from the line start would
11150 // take the `>` with it and silently unquote the line.
11151 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
11152 doc.caret = "> - ".len();
11153 doc.backspace();
11154 assert_eq!(doc.source, "> a\n");
11155 assert_eq!(list_items(&mut doc), 0);
11156
11157 // A nested one outdents instead, moving the bullet within the quote
11158 // rather than moving the quote.
11159 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
11160 doc.caret = "> - a\n> - ".len();
11161 doc.backspace();
11162 assert_eq!(doc.source, "> - a\n> - b\n");
11163 assert_eq!(list_items(&mut doc), 2);
11164 }
11165
11166 #[test]
11167 fn only_a_bare_paragraph_is_parted_around_the_caret() {
11168 // The split is deliberately narrow. Parting a fenced block would leave
11169 // two fences with a rule between them, and parting a list item would
11170 // mint an item nobody asked for on the way to a rule that lands after
11171 // the list either way — so both keep the whole block intact and take the
11172 // rule after it. A caret in a quote is likewise left alone.
11173 for (name, body, caret, want) in [
11174 (
11175 "code",
11176 "```\nfn x() {}\n```\n",
11177 8,
11178 "```\nfn x() {}\n```\n\n---\n",
11179 ),
11180 ("list", "- one two\n", 6, "- one two\n\n---\n"),
11181 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
11182 ] {
11183 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
11184 d.caret = caret;
11185 d.insert_thematic_break();
11186 assert_eq!(d.source, want, "{name}: the block should stay whole");
11187 }
11188 }
11189
11190 #[test]
11191 fn insert_thematic_break_replaces_the_selection() {
11192 // Now that the rule lands *at* the caret again, replacing the selection
11193 // is coherent once more: the text goes, and the rule takes its place.
11194 // The space the deletion left leading the second half is consumed by the
11195 // split rather than opening the new paragraph with it.
11196 let mut d = doc_with("hr_sel", "one two three\n");
11197 d.anchor = Some(4);
11198 d.caret = 7; // "two"
11199 d.insert_thematic_break();
11200 assert_eq!(d.source, "one \n\n---\n\nthree\n");
11201 assert_eq!(d.selection(), None);
11202 }
11203
11204 #[test]
11205 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
11206 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
11207 // because writing `---` into one is code, not a rule — twig now walks out
11208 // to the block that owns the caret's line, so there is nothing to refuse.
11209 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
11210 code.caret = 5; // inside the fenced code
11211 code.insert_thematic_break();
11212 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
11213 assert_eq!(code.status, None, "no refusal to report any more");
11214
11215 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
11216 table.caret = 3; // in the header row
11217 table.insert_thematic_break();
11218 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
11219 }
11220
11221 #[test]
11222 fn insert_thematic_break_in_a_list_item_ends_the_list() {
11223 // The un-indented rule cannot continue the list, so it closes the list
11224 // and lands at the top level rather than nested inside it.
11225 let mut d = doc_with("hr_list", "- one\n- two\n");
11226 d.caret = "- one\n- tw".len(); // mid "two"
11227 d.insert_thematic_break();
11228 d.build_visual(80);
11229 let rule_at = d.source.find("---").unwrap();
11230 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11231 assert!(
11232 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
11233 && n.span.start <= rule_at
11234 && rule_at < n.span.end),
11235 "the rule must not be nested inside the list"
11236 );
11237 }
11238
11239 #[test]
11240 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
11241 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
11242 // which is the document the gesture was actually asked for.
11243 let mut d = doc_with("hr_quote", "> hello\n");
11244 d.caret = 4; // inside the quoted text
11245 d.insert_thematic_break();
11246 assert_eq!(d.source, "> hello\n>\n> ---\n");
11247 d.build_visual(80);
11248 let rule_at = d.source.find("---").unwrap();
11249 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11250 assert!(
11251 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
11252 && n.span.start <= rule_at
11253 && rule_at < n.span.end),
11254 "the rule belongs to the quote it was asked for"
11255 );
11256 }
11257
11258 // ── typing against a block picture ────────────────────────────────────────
11259
11260 /// A rendered-view document with the caret parked on one of the picture's two
11261 /// stops, and the map already built — the state a frontend is in between
11262 /// drawing a frame and the next keystroke.
11263 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
11264 let mut d = doc_in(View::Wysiwyg, name, src);
11265 d.build_visual_unwrapped();
11266 let start = src.find("".len(),
11270 };
11271 d
11272 }
11273
11274 /// The block media the map publishes, after rebuilding it — "is this still a
11275 /// picture, or has it become a line of text with an image in it?"
11276 fn media_count(d: &mut Doc) -> usize {
11277 d.build_visual_unwrapped();
11278 d.vmap.media.len()
11279 }
11280
11281 #[test]
11282 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
11283 // The accident this prevents: tap the blank page under a photo (which
11284 // lands on the picture's trailing stop), type, and `xy` is a
11285 // paragraph with an *inline* image — the photo stops being drawn.
11286 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
11287 d.insert("xy");
11288 assert_eq!(d.source, "hi\n\n\n\nxy\n");
11289 assert_eq!(media_count(&mut d), 1, "still a picture");
11290 }
11291
11292 #[test]
11293 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
11294 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
11295 d.insert("xy");
11296 assert_eq!(d.source, "hi\n\nxy\n\n\n");
11297 assert_eq!(media_count(&mut d), 1);
11298 }
11299
11300 #[test]
11301 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
11302 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
11303 d.insert("x");
11304 assert_eq!(d.source, "x\n\n\n");
11305 assert_eq!(media_count(&mut d), 1);
11306 }
11307
11308 #[test]
11309 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
11310 // The opened paragraph is part of the keystroke, not an edit the writer
11311 // made — so it undoes with the character, not a step later.
11312 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
11313 d.insert("x");
11314 assert_eq!(d.source, "hi\n\n\n\nx\n");
11315 d.undo();
11316 assert_eq!(d.source, "hi\n\n\n");
11317 }
11318
11319 #[test]
11320 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
11321 // ⌘V dissolves the picture exactly as a keystroke does.
11322 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
11323 d.paste("pasted");
11324 assert_eq!(d.source, "hi\n\n\n\npasted\n");
11325 assert_eq!(media_count(&mut d), 1);
11326 }
11327
11328 #[test]
11329 fn typing_beside_an_inline_image_is_ordinary_editing() {
11330 // An inline image has no placeholder row and no stops of its own. Opening
11331 // a paragraph mid-sentence would be the bug, not the fix.
11332 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
11333 d.build_visual_unwrapped();
11334 d.caret = "see ".len();
11335 d.insert("!");
11336 assert_eq!(d.source, "see ! here\n");
11337 }
11338
11339 #[test]
11340 fn source_view_types_raw_markup_against_an_image_untouched() {
11341 // Source view is for writing the markup itself; a break inserted behind
11342 // the writer's back there would be the editor arguing with them.
11343 let mut d = doc_in(View::Source, "pic_src", "\n");
11344 d.caret = "".len();
11345 d.insert("x");
11346 assert_eq!(d.source, "x\n");
11347 }
11348
11349 #[test]
11350 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
11351 // A selection is replaced, not joined into, so there is nothing to
11352 // protect: the range takes the picture with it.
11353 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
11354 d.anchor = Some(d.caret);
11355 d.caret = d.source.find("".len();
11356 d.insert("x");
11357 assert_eq!(d.source, "hi\n\nx\n");
11358 }
11359
11360 #[test]
11361 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
11362 // What this actually cost: a real vault's photo, to one stray Backspace.
11363 // The caret past `` was deleting the closing paren — invisible
11364 // in the rendered view — and the photo became the text `\n", MediaStop::After);
11366 d.backspace();
11367 assert_eq!(d.source, "hi\n");
11368 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
11369 d.undo();
11370 assert_eq!(
11371 d.source, "hi\n\n\n",
11372 "and comes back in one piece"
11373 );
11374 }
11375
11376 #[test]
11377 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
11378 // Deleting the break here would join the picture to the paragraph above,
11379 // where it is an *inline* image and stops being drawn. Step over the
11380 // boundary; the next press deletes in the paragraph the caret reached.
11381 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
11382 d.backspace();
11383 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
11384 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
11385 d.backspace();
11386 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
11387 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
11388 }
11389
11390 #[test]
11391 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
11392 // The mirror. A byte-step here eats the `!` and leaves a link.
11393 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
11394 d.delete_forward();
11395 assert_eq!(d.source, "hi\n\nbye\n");
11396 assert_eq!(media_count(&mut d), 0);
11397 }
11398
11399 #[test]
11400 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
11401 let mut d = doc_at_picture(
11402 "pic_del_after",
11403 "hi\n\n\n\nbye\n",
11404 MediaStop::After,
11405 );
11406 d.delete_forward();
11407 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
11408 assert_eq!(
11409 d.caret,
11410 d.source.find("bye").unwrap(),
11411 "the caret stepped down to `bye`"
11412 );
11413 }
11414
11415 #[test]
11416 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
11417 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
11418 d.backspace();
11419 assert_eq!(d.source, "\n");
11420 assert_eq!(media_count(&mut d), 0);
11421 }
11422
11423 #[test]
11424 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
11425 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
11426 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
11427 d.delete_word_back();
11428 assert_eq!(d.source, "hi there\n");
11429
11430 // And in front of one it runs *through* the paragraph break into the
11431 // prose above, which merges the picture inline — so it steps out first,
11432 // and the second press deletes the word it was aimed at.
11433 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
11434 d.delete_word_back();
11435 assert_eq!(d.source, "hi there\n\n\n");
11436 d.delete_word_back();
11437 assert_eq!(
11438 d.source, "hi \n\n\n",
11439 "the word above went, the picture stayed"
11440 );
11441 assert_eq!(media_count(&mut d), 1);
11442 }
11443
11444 #[test]
11445 fn source_view_deletes_raw_markup_against_an_image_untouched() {
11446 let mut d = doc_in(View::Source, "pic_src_del", "\n");
11447 d.caret = "".len();
11448 d.backspace();
11449 assert_eq!(d.source, ";
11450 }
11451
11452 #[test]
11453 fn image_destination_at_caret_reads_the_image_under_the_caret() {
11454 let mut d = doc_with("img_read", "\n");
11455 d.caret = 3; // inside the image markup
11456 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
11457 // Past the image, the caret is in no image.
11458 d.caret = "".len();
11459 assert_eq!(d.image_destination_at_caret(), None);
11460 }
11461
11462 #[test]
11463 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
11464 // The image is one placeholder row by default, and `set_media_rows` grows
11465 // it to the height the frontend measured: the label row plus blank
11466 // `decoration` fillers that hold the vertical space a raster is drawn into.
11467 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
11468 assert_eq!(d.vmap.media.len(), 1);
11469 let img_row = d.vmap.media[0].rows_span.start;
11470 assert_eq!(
11471 d.vmap.media[0].rows_span,
11472 img_row..img_row + 1,
11473 "default is one row"
11474 );
11475
11476 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
11477 d.build_visual(80);
11478 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
11479 let span = d.vmap.media[0].rows_span.clone();
11480 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
11481 // The label row carries the mark and its glyphs; the three below are blank
11482 // decoration — drawn, but no caret and no text.
11483 assert!(
11484 d.vmap.rows[span.start].media.is_some(),
11485 "mark rides the first row"
11486 );
11487 for r in (span.start + 1)..span.end {
11488 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
11489 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
11490 assert!(
11491 d.vmap.rows[r].media.is_none(),
11492 "only the first row is marked"
11493 );
11494 }
11495 }
11496
11497 #[test]
11498 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
11499 // The extra rows are pure spacers: the caret's only homes stay the stop in
11500 // front of the image and the one just past it, so walking the document top
11501 // to bottom visits the same offsets whether the image is 1 row or 5.
11502 let body = "ab\n\n\n\ncd\n";
11503 let stops_at = |rows: usize| -> Vec<usize> {
11504 let mut d = wysiwyg_doc("img_stops", body);
11505 if rows > 1 {
11506 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
11507 d.build_visual(80);
11508 }
11509 d.caret = 0;
11510 let mut seen = vec![d.caret];
11511 loop {
11512 d.move_right(false);
11513 if *seen.last().unwrap() == d.caret {
11514 break;
11515 }
11516 seen.push(d.caret);
11517 }
11518 seen
11519 };
11520 assert_eq!(
11521 stops_at(1),
11522 stops_at(5),
11523 "reserving rows must not add stops"
11524 );
11525 }
11526
11527 #[test]
11528 fn insert_link_repoints_the_link_at_a_bare_caret() {
11529 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
11530 d.caret = 3; // in the link's text, nothing selected
11531 d.insert_link("http://y.dev");
11532 assert_eq!(d.source, "[word](http://y.dev)\n");
11533 assert_eq!(d.selected_text(), Some("word"));
11534 }
11535
11536 #[test]
11537 fn insert_link_on_an_empty_range_autolinks_a_url() {
11538 // A link with no text of its own is an autolink, and twig spells it —
11539 // `<…>` is the canonical form and needs no text typed into it, so the
11540 // caret lands after it rather than selecting a finished link.
11541 let mut d = doc_with("link_empty", "\n");
11542 d.caret = 0;
11543 d.insert_link("http://x.dev");
11544 assert_eq!(d.source, "<http://x.dev>\n");
11545 assert_eq!(d.selection(), None);
11546 assert_eq!(d.caret, 14);
11547 }
11548
11549 #[test]
11550 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
11551 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
11552 // in Markdown, so a destination that can't autolink doubles as the text
11553 // instead — which is then selected, ready to be typed over.
11554 let mut d = doc_with("link_rel", "\n");
11555 d.caret = 0;
11556 d.insert_link("./notes.md");
11557 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
11558 assert_eq!(d.selection(), Some((1, 11)));
11559 d.insert("Notes");
11560 assert_eq!(d.source, "[Notes](./notes.md)\n");
11561 }
11562
11563 #[test]
11564 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
11565 // The autolink's text is its URL, so re-pointing replaces the whole
11566 // node — the caret must not splice a second link inside the first.
11567 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
11568 d.caret = 10;
11569 d.insert_link("https://y.dev");
11570 assert_eq!(d.source, "see <https://y.dev> ok\n");
11571 }
11572
11573 #[test]
11574 fn code_language_reads_and_edits_through_the_fence() {
11575 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
11576 d.caret = 10; // inside the code body
11577 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
11578 assert!(d.caret_in_fenced_code());
11579
11580 d.set_code_language("python");
11581 assert!(
11582 d.source.starts_with("```python\n"),
11583 "source: {:?}",
11584 d.source
11585 );
11586 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
11587
11588 // Clearing it leaves a bare fence and no label.
11589 d.set_code_language("");
11590 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
11591 assert_eq!(d.code_language_at_caret(), None);
11592
11593 // A caret outside any code block edits nothing.
11594 let mut p = doc_with("code_lang_none", "just prose\n");
11595 assert!(!p.caret_in_fenced_code());
11596 p.set_code_language("rust");
11597 assert_eq!(p.source, "just prose\n");
11598 }
11599
11600 #[test]
11601 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
11602 // Markdown's info string ends at whitespace, so `two words` would write
11603 // a fence that reads back with a different language than the one asked
11604 // for. twig refuses it; leaf reports that and leaves the source alone.
11605 // The old splice trimmed the ends and wrote whatever was left.
11606 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
11607 d.caret = 10;
11608 d.set_code_language("two words");
11609 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
11610 assert!(d.status.is_some(), "the refusal should be reported");
11611 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
11612 }
11613
11614 #[test]
11615 fn link_destination_at_caret_reads_both_spellings() {
11616 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
11617 d.caret = 5;
11618 assert_eq!(
11619 d.link_destination_at_caret().as_deref(),
11620 Some("https://x.dev")
11621 );
11622 d.caret = 0;
11623 assert_eq!(d.link_destination_at_caret(), None);
11624
11625 // An autolink has no `destination`; its text is the URL.
11626 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
11627 a.caret = 10;
11628 assert_eq!(
11629 a.link_destination_at_caret().as_deref(),
11630 Some("https://x.dev")
11631 );
11632 a.caret = 21;
11633 assert_eq!(a.link_destination_at_caret(), None);
11634 }
11635
11636 #[test]
11637 fn locate_finds_the_block_a_declared_id_names() {
11638 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
11639 // verse. The locator has to land on the *verse*, which is the whole
11640 // reason a link carries one.
11641 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
11642 {#v2}\nYea, I make a record in the language of my father.\n";
11643 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11644 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
11645 assert_eq!(
11646 d.source[v2.start..v2.end].trim_end(),
11647 "Yea, I make a record in the language of my father."
11648 );
11649 // The attribute line is not part of it: `start` is a place to put a
11650 // caret, and `{#v2}` is markup the caret has no business landing in.
11651 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
11652 assert_eq!(d.locate("v99"), None);
11653 }
11654
11655 #[test]
11656 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
11657 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
11658 // Markdown heading is literal text. So `#the-second-part` can only be
11659 // the heading's own words, which is the rule every Markdown renderer
11660 // already follows and therefore the one a link was authored against.
11661 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
11662 let mut d = doc_with("locate_md", src);
11663 let hit = d.locate("the-second-part").expect("the heading's slug");
11664 assert!(d.source[hit.start..].starts_with("## The Second Part"));
11665 // Bounded by the next heading that isn't under it, so a peek shows the
11666 // section rather than only its title.
11667 assert_eq!(
11668 &d.source[hit.start..hit.end],
11669 "## The Second Part\n\nbody\n\n"
11670 );
11671
11672 // A subsection does not end its parent: `# Title` runs to `## Third`'s
11673 // sibling only because there is no other `#`, so it covers the lot.
11674 let title = d.locate("title").expect("the top heading");
11675 assert_eq!(title.end, d.source.len());
11676 }
11677
11678 #[test]
11679 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
11680 // djot mints `Some-Heading-Here`; a link to it is written
11681 // `#some-heading-here` by nearly everything that writes links. Both
11682 // spellings are one question.
11683 let src = "## Some Heading Here\n\nbody\n";
11684 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11685 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
11686 let slugged = d.locate("some-heading-here").expect("the link's spelling");
11687 assert_eq!(exact, slugged);
11688 // The section, not the heading line — there is more to show than a title.
11689 assert_eq!(&d.source[exact.start..exact.end], src);
11690 }
11691
11692 #[test]
11693 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
11694 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
11695 assert_eq!(d.locate(""), None);
11696 assert_eq!(d.locate(" "), None);
11697 // All punctuation: it names nothing, and must not be read as "match the
11698 // first heading whose slug is also empty".
11699 assert_eq!(d.locate("!!!"), None);
11700 }
11701
11702 #[test]
11703 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
11704 // The document's mistake, and the answer every other anchor
11705 // implementation gives — the alternative is for a link to mean whichever
11706 // of the two a walk happened to reach first.
11707 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
11708 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11709 let hit = d.locate("dup").expect("the first `{#dup}`");
11710 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
11711 }
11712
11713 #[test]
11714 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
11715 // The button's whole job: a reference where the caret was, a definition
11716 // to give it meaning, and the caret waiting in the empty note so the
11717 // next keystroke is the note's first word.
11718 let mut d = doc_with("fn_insert", "A claim and more.\n");
11719 d.caret = 7; // just past "A claim"
11720 d.insert_footnote();
11721 assert!(
11722 d.source.starts_with("A claim[^1] and more."),
11723 "{:?}",
11724 d.source
11725 );
11726 assert!(
11727 d.source.contains("[^1]:"),
11728 "the definition too: {:?}",
11729 d.source
11730 );
11731 assert_eq!(d.status, None);
11732
11733 let reference = d.source.find("[^1]").unwrap();
11734 let note = d
11735 .footnote_at(reference + 2)
11736 .expect("the reference just written");
11737 assert_eq!(note.label, "1");
11738 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
11739 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
11740 // …and typing there is typing into the note, not near it.
11741 d.insert("the note");
11742 assert_eq!(
11743 d.footnote_at(reference + 2).and_then(|f| f.text),
11744 Some("the note".to_string())
11745 );
11746 }
11747
11748 #[test]
11749 fn insert_footnote_numbers_past_the_notes_already_written() {
11750 // A second press must not hand back a label somebody else is using: twig
11751 // reuses a defined label rather than appending a rival definition, so a
11752 // repeat of `1` would quietly point the new reference at the old note.
11753 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
11754 d.caret = 7; // past `[^1]`, before " two."
11755 d.insert_footnote();
11756 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
11757 assert_eq!(d.source.matches("[^2]:").count(), 1);
11758 }
11759
11760 #[test]
11761 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
11762 // `[^2]` with no definition is still a 2 that means something to whoever
11763 // wrote it — stepping over it would mint a note for their reference. A
11764 // word label takes no number, so it blocks none.
11765 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
11766 d.caret = d.source.find(" c").unwrap();
11767 d.insert_footnote();
11768 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
11769 assert!(
11770 d.source.starts_with("a[^2] b[^why][^1] c"),
11771 "{:?}",
11772 d.source
11773 );
11774 }
11775
11776 #[test]
11777 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
11778 // A reference annotates the words before it. Consuming the selection —
11779 // which is what an insert normally does — would delete the very claim
11780 // the author selected in order to footnote.
11781 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
11782 d.anchor = Some(2);
11783 d.caret = 7; // "claim" selected
11784 d.insert_footnote();
11785 assert!(
11786 d.source.starts_with("A claim[^1] and more."),
11787 "{:?}",
11788 d.source
11789 );
11790 }
11791
11792 #[test]
11793 fn a_note_just_written_still_knows_where_its_reference_is() {
11794 // The authoring loop in one test: press the button, type the note, ask to
11795 // go back. The caret ends at the note's last byte — which is the *end* of
11796 // the definition's span, the one offset the query used to exclude — so
11797 // this is where the round trip either works or doesn't.
11798 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
11799 d.caret = 7;
11800 d.insert_footnote();
11801 d.insert("the note");
11802 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
11803 let back = d
11804 .footnote_definition_at_caret()
11805 .expect("still in the note we just typed");
11806 assert_eq!(back.label, "1");
11807 // …and following it lands on the reference's label, where a reader's
11808 // return leg lands.
11809 assert_eq!(back.offset, Some(9));
11810 assert_eq!(&d.source[9..10], "1");
11811 }
11812
11813 #[test]
11814 fn insert_footnote_takes_one_undo_for_both_halves() {
11815 // twig writes the pair as a single edit; the point of that is here.
11816 let before = "A claim and more.\n";
11817 let mut d = doc_with("fn_insert_undo", before);
11818 d.caret = 7;
11819 d.insert_footnote();
11820 assert_ne!(d.source, before);
11821 d.undo();
11822 assert_eq!(d.source, before, "one undo takes back both halves");
11823 }
11824
11825 #[test]
11826 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
11827 // HTML is authorable — it spells the inline marks — and has no footnote.
11828 // The refusal says so rather than writing brackets that would render as
11829 // brackets.
11830 let src = "<p>A claim.</p>\n";
11831 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
11832 assert!(!Capabilities::of(Format::Html).footnote);
11833 d.caret = 5;
11834 d.insert_footnote();
11835 assert_eq!(d.source, src, "nothing written");
11836 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
11837 }
11838
11839 #[test]
11840 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
11841 // The empty body is the one place this could go wrong: the definition
11842 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
11843 // byte early would draw up in the paragraph above the note it belongs to.
11844 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
11845 d.place_caret(7, false);
11846 d.insert_footnote();
11847 d.build_visual(80); // the frame a frontend draws after the edit
11848 assert_eq!(
11849 d.vmap.snap_to_stop(d.caret),
11850 d.caret,
11851 "the caret sits on a stop"
11852 );
11853 let (row, _) = d.caret_pos();
11854 assert!(
11855 drawn_rows(&d)[row].contains("[1]"),
11856 "the caret is on the note's row, not above it: {:?}",
11857 drawn_rows(&d)
11858 );
11859 }
11860
11861 #[test]
11862 fn footnote_at_caret_resolves_a_reference_to_its_note() {
11863 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
11864 // blank line, as one has to.
11865 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
11866 d.caret = 9;
11867 let f = d
11868 .footnote_at_caret()
11869 .expect("the caret stands in a reference");
11870 assert_eq!(f.label, "1");
11871 assert_eq!(f.text.as_deref(), Some("the note"));
11872 // The offset points at the note's first word, not at the definition's
11873 // `[` — the marker is decoration with no caret stop on it.
11874 assert_eq!(f.offset, Some(29));
11875 assert_eq!(&d.source[29..37], "the note");
11876 // …and `end` closes the range, so a frontend can ask which rendered rows
11877 // the note occupies rather than re-deriving them from the text.
11878 assert_eq!(f.end, Some(37));
11879 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
11880 }
11881
11882 /// Two definitions in a row: each is its own note, and neither reaches into
11883 /// the other.
11884 ///
11885 /// A djot definition's span used to run past the blank line into the first
11886 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
11887 /// offsets named the *next* note's rows too, showing a reader two footnotes
11888 /// when they had asked about one. twig 3.1 ends the span after the block's
11889 /// own last line; the test outlives the workaround leaf carried for it.
11890 #[test]
11891 fn footnote_at_stops_a_note_at_the_definition_after_it() {
11892 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
11893 for format in [Format::Markdown, Format::Djot] {
11894 let mut d = Doc::from_source(src.to_string(), format).unwrap();
11895 d.caret = 7;
11896 let f = d.footnote_at_caret().expect("a reference");
11897 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
11898 assert_eq!(
11899 &src[f.offset.unwrap()..f.end.unwrap()],
11900 "first note.",
11901 "in {format:?}"
11902 );
11903 }
11904 }
11905
11906 /// The other side of that boundary: a blank line *inside* a definition is
11907 /// interior to it, and the note keeps its second paragraph.
11908 ///
11909 /// This is what the old body scan cost. It stopped at the first line not
11910 /// indented under the note — a blank line is not — so a two-paragraph note
11911 /// came back as its first paragraph, and "go to note" framed half of it.
11912 /// Reading the span twig gives is both simpler and right.
11913 #[test]
11914 fn footnote_at_keeps_a_notes_second_paragraph() {
11915 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
11916 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11917 d.caret = 7;
11918 let f = d.footnote_at_caret().expect("a reference");
11919 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
11920 // And it stops there — `After.` is the next block, not more note.
11921 assert_eq!(
11922 &src[f.offset.unwrap()..f.end.unwrap()],
11923 f.text.as_deref().unwrap()
11924 );
11925 assert!(!f.text.as_deref().unwrap().contains("After"));
11926 }
11927
11928 #[test]
11929 fn footnote_at_bounds_a_note_whose_body_is_empty() {
11930 // `[^1]:` with nothing after it. The range is empty rather than
11931 // inverted, and still points inside the definition — which is what keeps
11932 // a frontend's row lookup from walking off into the block above.
11933 let src = "A claim[^1].\n\n[^1]:\n";
11934 let mut d = doc_with("fn_empty_body", src);
11935 d.caret = 9;
11936 let f = d.footnote_at_caret().expect("a reference");
11937 assert_eq!(f.text.as_deref(), Some(""));
11938 assert_eq!(f.offset, f.end, "an empty note is an empty range");
11939 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
11940 }
11941
11942 #[test]
11943 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
11944 let mut d = doc_with(
11945 "fn_at_caret_none",
11946 "A claim[^1] and more.\n\n[^1]: the note\n",
11947 );
11948 d.caret = 2; // in the prose
11949 assert_eq!(d.footnote_at_caret(), None);
11950 }
11951
11952 #[test]
11953 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
11954 // The two are deliberately separate: a reference names a note in this
11955 // document, a link names somewhere to leave for, and answering one with
11956 // the other is what made a reference click do nothing at all.
11957 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
11958 d.caret = 3; // the `1` of `[^1]`
11959 assert!(d.footnote_at_caret().is_some());
11960 assert_eq!(
11961 d.link_destination_at_caret(),
11962 None,
11963 "a reference is not a link"
11964 );
11965
11966 d.caret = 10; // inside the link's label
11967 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
11968 assert_eq!(
11969 d.link_destination_at_caret().as_deref(),
11970 Some("https://x.dev")
11971 );
11972 }
11973
11974 #[test]
11975 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
11976 // A `[^99]` the document never defines is a real state — a note deleted
11977 // out from under its reference — and the label is what lets a frontend
11978 // say so. `None` here would be indistinguishable from "not on a
11979 // reference", which is the wrong thing to tell a reader.
11980 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
11981 d.caret = 9;
11982 let f = d
11983 .footnote_at_caret()
11984 .expect("the reference is still a reference");
11985 assert_eq!(f.label, "99");
11986 assert_eq!(f.text, None);
11987 assert_eq!(f.offset, None);
11988 }
11989
11990 #[test]
11991 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
11992 // Labels are not always numbers, and a note's body runs past its first
11993 // line — the indented continuation belongs to the note, so it comes back
11994 // with it (source bytes, verbatim, as documented).
11995 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
11996 let mut d = doc_with("fn_word_label", src);
11997 d.caret = 6;
11998 let f = d
11999 .footnote_at_caret()
12000 .expect("the caret stands in a reference");
12001 assert_eq!(f.label, "note");
12002 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
12003 }
12004
12005 #[test]
12006 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
12007 // The point of the offset form: a pointer hovering a reference asks what
12008 // note it names, and must not drag the caret along to ask.
12009 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
12010 d.caret = 0;
12011 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
12012 assert_eq!(f.label, "1");
12013 assert_eq!(f.text.as_deref(), Some("the note"));
12014 assert_eq!(d.caret, 0, "asking must not move the caret");
12015 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
12016 }
12017
12018 #[test]
12019 fn footnote_definition_at_caret_points_back_at_the_reference() {
12020 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
12021 // the caret can rest on — is at 9.
12022 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
12023 d.caret = 30; // inside the note's body
12024 let f = d
12025 .footnote_definition_at_caret()
12026 .expect("the caret stands in a definition");
12027 assert_eq!(f.label, "1");
12028 assert_eq!(f.offset, Some(9));
12029 assert_eq!(&d.source[7..11], "[^1]");
12030 }
12031
12032 #[test]
12033 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
12034 // The two legs have to meet: wherever `footnote_at` sends the caret, the
12035 // definition query must answer for — otherwise arriving at a note leaves
12036 // the reader somewhere the way back isn't offered.
12037 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
12038 let mut d = doc_with("fn_def_marker", src);
12039 let landed = d.footnote_at(9).unwrap().offset.unwrap();
12040 assert_eq!(
12041 d.footnote_definition_at(landed).and_then(|f| f.offset),
12042 Some(9),
12043 "the note a reference sends you to offers the way back"
12044 );
12045 }
12046
12047 #[test]
12048 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
12049 // The two queries answer for disjoint places, which is what lets one
12050 // gesture mean "down to the note" in one and "back up" in the other
12051 // without either having to remember which way the reader is going.
12052 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
12053 d.caret = 2; // prose
12054 assert_eq!(d.footnote_definition_at_caret(), None);
12055 d.caret = 9; // the reference
12056 assert_eq!(d.footnote_definition_at_caret(), None);
12057 assert!(
12058 d.footnote_at_caret().is_some(),
12059 "which is the reference's own query"
12060 );
12061 }
12062
12063 #[test]
12064 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
12065 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
12066 // note", which is false and leaves a frontend unable to explain why the
12067 // way back is missing.
12068 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
12069 let mut d = doc_with("fn_def_orphan", src);
12070 d.caret = src.find("orphan").unwrap();
12071 let f = d
12072 .footnote_definition_at_caret()
12073 .expect("an orphan is still a definition");
12074 assert_eq!(f.label, "2");
12075 assert_eq!(f.offset, None);
12076 }
12077
12078 #[test]
12079 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
12080 // One label, cited twice. The first is where the reader most likely came
12081 // from, and the only answer that doesn't depend on how they got here.
12082 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
12083 let mut d = doc_with("fn_def_repeat", src);
12084 d.caret = src.find("the note").unwrap();
12085 let f = d.footnote_definition_at_caret().expect("a definition");
12086 assert_eq!(
12087 f.offset,
12088 Some(5),
12089 "the first `[^a]`'s label, not the second's"
12090 );
12091 assert_eq!(&src[3..7], "[^a]");
12092 }
12093
12094 #[test]
12095 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
12096 // Down and back up, each leg found from the document rather than from a
12097 // memory of the other — so it still works for a reader who scrolled to
12098 // the notes instead of jumping there.
12099 //
12100 // `place_caret` rather than assigning `caret`, because that is what a
12101 // frontend calls: it snaps to a real caret stop, and a jump that lands
12102 // on a byte the caret can't rest on would arrive somewhere the return
12103 // leg no longer answers for. `build_map` first, since snapping is a
12104 // no-op until the map exists — which is exactly how this went unnoticed
12105 // when the offsets pointed at the `[^` markers.
12106 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
12107 d.build_map(None);
12108 d.place_caret(9, false);
12109 let down = d
12110 .footnote_at_caret()
12111 .expect("a reference")
12112 .offset
12113 .expect("a note");
12114 d.place_caret(down, false);
12115 let up = d
12116 .footnote_definition_at_caret()
12117 .expect("a definition")
12118 .offset
12119 .expect("a reference");
12120 d.place_caret(up, false);
12121 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
12122 assert_eq!(
12123 d.footnote_at_caret().expect("back on the reference").label,
12124 "1"
12125 );
12126 }
12127
12128 #[test]
12129 fn insert_link_hands_the_destination_to_twig_raw() {
12130 // Escaping is twig's, and format-specific: Markdown ends a destination
12131 // at the first space and needs the `<…>` form, where djot would read
12132 // those angle brackets as part of the URL.
12133 let mut d = doc_with("link_space", "word\n");
12134 d.anchor = Some(0);
12135 d.caret = 4;
12136 d.insert_link("a b");
12137 assert_eq!(d.source, "[word](<a b>)\n");
12138 }
12139
12140 #[test]
12141 fn insert_link_reports_a_destination_no_format_can_carry() {
12142 let mut d = doc_with("link_bad", "word\n");
12143 d.anchor = Some(0);
12144 d.caret = 4;
12145 d.insert_link("a\nb");
12146 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
12147 assert!(
12148 d.status.is_some(),
12149 "InvalidArgument should reach the status line"
12150 );
12151 assert!(!d.dirty);
12152 }
12153
12154 #[test]
12155 fn insert_link_works_in_wysiwyg_view() {
12156 let mut d = wysiwyg_doc("link_wys", "word here\n");
12157 d.anchor = Some(0);
12158 d.caret = 4;
12159 d.insert_link("http://x.dev");
12160 assert_eq!(d.source, "[word](http://x.dev) here\n");
12161 assert_eq!(d.selected_text(), Some("word"));
12162 // The map the caret has to keep riding is rebuilt each frame; motion
12163 // over the fresh one must still land on a real stop (the debug_assert).
12164 d.build_visual(80);
12165 d.move_right(false);
12166 d.move_left(false);
12167 }
12168
12169 #[test]
12170 fn click_maps_a_row_col_to_a_byte_offset() {
12171 let mut d = doc_with("click", "ab\ncd\n");
12172 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
12173 assert_eq!(d.caret, 4);
12174 }
12175
12176 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
12177 // stop just as the `(row, col)` click path does, so the caret can never come
12178 // to rest in the blank gap between two paragraphs — where it would draw in one
12179 // place and type in another.
12180 #[test]
12181 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
12182 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
12183 // caret stop (stops are 0,1,3,4).
12184 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
12185 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
12186 d.place_caret(2, false);
12187 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12188 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
12189 }
12190
12191 #[test]
12192 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
12193 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
12194 d.place_caret(0, false); // anchor at the start of "A"
12195 d.place_caret(2, true); // drag into the gap
12196 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12197 let (s, e) = d.selection().expect("a selection");
12198 assert!(
12199 d.vmap.is_stop(s) && d.vmap.is_stop(e),
12200 "selection {s}..{e} off a stop"
12201 );
12202 }
12203
12204 #[test]
12205 fn place_caret_on_a_real_stop_is_left_untouched() {
12206 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
12207 d.place_caret(3, false); // the start of "B" — a genuine stop
12208 assert_eq!(d.caret, 3);
12209 }
12210
12211 // An *empty paragraph* (two blank lines, an intentional blank line the user
12212 // opened) is a real caret stop, unlike the gap — a click into it must stay.
12213 #[test]
12214 fn place_caret_rests_in_an_empty_paragraph() {
12215 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
12216 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
12217 assert!(d.vmap.is_stop(empty));
12218 d.place_caret(empty, false);
12219 assert_eq!(d.caret, empty);
12220 }
12221
12222 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
12223 // a drag over the word `bold` ends there, and a caret placed there stays.
12224 #[test]
12225 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
12226 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
12227 let mut d = wysiwyg_doc("place_mark_end", src);
12228 let start = src.find("bold").unwrap();
12229 d.place_caret(start, false);
12230 d.place_caret(start + 4, true);
12231 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
12232 d.toggle(InlineKind::Strong);
12233 assert_eq!(d.source, src.replace("**bold**", "bold"));
12234 }
12235
12236 #[test]
12237 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
12238 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
12239 d.caret = 7; // before the `d`
12240 d.move_right(false);
12241 assert_eq!(d.caret, 8, "onto the end of the bold");
12242 assert!(d.active_inline_marks().contains(InlineKind::Strong));
12243 d.move_right(false);
12244 assert_eq!(d.caret, 10, "past the closing `**`");
12245 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
12246 d.move_left(false);
12247 assert_eq!(d.caret, 8);
12248 d.move_left(false);
12249 assert_eq!(d.caret, 7);
12250 // Typing at the inner home extends the bold.
12251 d.caret = 8;
12252 d.insert("!");
12253 assert_eq!(d.source, "a **bold!** b");
12254 }
12255
12256 #[test]
12257 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
12258 // The splice path shifts the home with the block it is in, and the
12259 // re-rendered block finds its own again.
12260 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
12261 d.build_visual_unwrapped();
12262 d.edit(0, 0, "zz");
12263 d.build_visual_unwrapped();
12264 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
12265 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12266 let at = d.source.find("bold").unwrap();
12267 d.edit(at, at, "very ");
12268 d.build_visual_unwrapped();
12269 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
12270 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12271 }
12272
12273 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
12274 doc_in(View::Wysiwyg, name, body)
12275 }
12276
12277 /// How many list items the source actually parses into — the check that a
12278 /// marker Leaf wrote is a marker the format agrees is one.
12279 fn list_items(doc: &mut Doc) -> usize {
12280 doc.editor
12281 .nodes()
12282 .unwrap()
12283 .iter()
12284 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
12285 .count()
12286 }
12287
12288 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
12289 /// incremental (`build_spliced` / `build_cached`) path must always match.
12290 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
12291 reference_map_revealing(source, None)
12292 }
12293
12294 /// [`reference_map`] with a reveal line — the ground truth for the
12295 /// `MarkupMode::Full` builds, where the map is a function of the caret's
12296 /// line as well as the text.
12297 fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
12298 // The same parse `Doc` uses. With twig's plain defaults instead, the two
12299 // sides disagree on what the *document* is before the renderer is even
12300 // reached — a bare `:word` is a text directive to one and prose to the
12301 // other — and the mismatch reads as a splice bug that isn't one.
12302 let mut ed =
12303 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
12304 let nodes = ed.nodes().unwrap();
12305 crate::wysiwyg::build(
12306 &nodes,
12307 source,
12308 None,
12309 false,
12310 &wysiwyg::Surface::default(),
12311 reveal,
12312 )
12313 }
12314
12315 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
12316 if a.rows.len() != b.rows.len() {
12317 return true;
12318 }
12319 for (ra, rb) in a.rows.iter().zip(&b.rows) {
12320 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
12321 return true;
12322 }
12323 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
12324 if ga.ch != gb.ch || ga.src != gb.src {
12325 return true;
12326 }
12327 }
12328 }
12329 false
12330 }
12331
12332 #[test]
12333 fn incremental_build_matches_a_fresh_build_across_edits() {
12334 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
12335 // fast path, gated on twig's `dirty_range`) or falls back to
12336 // `build_cached`. After each edit the map must be byte-identical to a
12337 // from-scratch build — this is the correctness net under the splice.
12338 let docs = [
12339 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
12340 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
12341 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
12342 // A footnote definition is a root beside `doc`, merged back into the
12343 // top-level list by `wysiwyg::top_blocks`. The random edits below
12344 // make and unmake definitions as they go (a deleted `:` turns one
12345 // back into a paragraph, and vice versa), which is exactly the
12346 // structural churn the splice path has to notice and bail out of.
12347 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
12348 // A comment is a top-level block that draws no rows — a layout entry
12349 // at zero rows either side of blocks that do. The edits below type
12350 // into the blocks around it (a splice past a hidden block), and
12351 // break the comment open into prose and back (a structural change).
12352 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
12353 // Link reference definitions: a hidden block that an edit can turn
12354 // into a paragraph (a deleted `:`) and back, and whose own bytes an
12355 // edit can land in.
12356 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
12357 ];
12358 // A deterministic mix: mostly single characters (which stay inside one
12359 // block → splice), plus edits that reshape structure (a paragraph break,
12360 // a heading marker, a code fence → fallback), so both paths are exercised.
12361 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
12362 for src in docs {
12363 let mut d = wysiwyg_doc("diff", src);
12364 d.build_visual_unwrapped();
12365 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
12366
12367 for step in 0..60usize {
12368 let len = d.source.len();
12369 let raw = (step * 13 + 5) % (len + 1);
12370 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
12371 let pre = d.source.clone();
12372 let action;
12373 if step % 3 == 0 && pos < len {
12374 let end = (pos + 1..=len)
12375 .find(|&i| d.source.is_char_boundary(i))
12376 .unwrap();
12377 action = format!("delete [{pos},{end})");
12378 d.edit(pos, end, "");
12379 } else {
12380 let ins = inserts[step % inserts.len()];
12381 action = format!("insert {ins:?} @ {pos}");
12382 d.edit(pos, pos, ins);
12383 }
12384 d.build_visual_unwrapped();
12385 if maps_differ(&d.vmap, &reference_map(&d.source)) {
12386 panic!(
12387 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
12388 d.source
12389 );
12390 }
12391 }
12392 }
12393 }
12394
12395 /// A frontend is handed [`Doc::vmap`] and may present it differently:
12396 /// leaf-ratatui splices blank filler rows under an oversized heading so the
12397 /// raster it paints there has somewhere to stand, and leaves them in the map
12398 /// because the caret and the mouse both read it between frames. The splice
12399 /// path addresses that map by *row index*, against the block layout the last
12400 /// build recorded — so handed a map with rows in it that no block owns, it
12401 /// laid the re-rendered block over one of the fillers and carried the rows
12402 /// the block really occupied into the suffix. One stranded copy of the
12403 /// edited line, and everything below it a row further down, per keystroke.
12404 ///
12405 /// A map that isn't the one the layout describes is a map this path can't
12406 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
12407 #[test]
12408 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
12409 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
12410 d.build_visual_unwrapped();
12411
12412 // Stand in for the heading filler rows: two blank rows past the heading
12413 // that no block accounts for. Cloning a real row keeps every field
12414 // plausible — it is the row *count* the splice can't survive.
12415 let filler = d.vmap.rows[0].clone();
12416 d.vmap.rows.insert(1, filler.clone());
12417 d.vmap.rows.insert(1, filler);
12418
12419 // An edit inside the last block: the single-block case the splice path
12420 // is for, and the one the frontend hits on every keystroke.
12421 let at = d.source.len() - 1;
12422 d.edit(at, at, "!");
12423 d.build_visual_unwrapped();
12424
12425 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
12426 }
12427
12428 /// A glyph's [`FaceId`] has to mean the same thing however its row was
12429 /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
12430 /// cloned at a shifted offset, and a previous map's rows a splice kept
12431 /// untouched — and only the first of those walks a `data-font` at all. An
12432 /// index into a per-build table would have had the same glyph naming two
12433 /// families the moment a second one appeared; the id is the name's own
12434 /// hash, so nothing is remapped and the table is merged rather than rebuilt.
12435 ///
12436 /// Two families, because one cannot tell a wrong id from a right one.
12437 ///
12438 /// [`FaceId`]: crate::style::FaceId
12439 /// [`BlockCache`]: crate::wysiwyg::BlockCache
12440 #[test]
12441 fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
12442 use crate::style::{FaceId, FaceRef};
12443 let garamond = FaceId::of("Garamond");
12444 let futura = FaceId::of("Futura");
12445 let mut d = wysiwyg_doc(
12446 "two_faces",
12447 "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
12448 );
12449 d.build_visual_unwrapped();
12450
12451 // What the map has to keep saying, whichever path built it.
12452 let check = |d: &Doc, ctx: &str| {
12453 let face_of = |ch: char| {
12454 d.vmap
12455 .rows
12456 .iter()
12457 .flat_map(|r| r.glyphs.iter())
12458 .find(|g| g.ch == ch)
12459 .map(|g| g.style.font)
12460 };
12461 assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
12462 assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
12463 assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
12464 assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
12465 assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
12466 };
12467 check(&d, "fresh");
12468
12469 // An edit inside the second block: the single-block case the splice
12470 // path is for. The first block's rows are carried over untouched, so
12471 // its glyphs' ids are the previous build's and the table has to be too.
12472 let at = d.source.find("beta").unwrap();
12473 d.edit(at, at, "z");
12474 d.build_visual_unwrapped();
12475 check(&d, "after an edit in the second block");
12476 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
12477
12478 // And the other way round, so the block that was kept is the one that
12479 // is now re-rendered.
12480 let at = d.source.find("alpha").unwrap();
12481 d.edit(at, at, "z");
12482 d.build_visual_unwrapped();
12483 check(&d, "after an edit in the first block");
12484 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
12485
12486 // A structural edit is one the splice bails out of, so the map is
12487 // reassembled by `build_cached` — where an untouched block is a *cache
12488 // hit* and its rows are cloned without a `data-font` being walked
12489 // again. The names the entry stored are what keeps the table honest
12490 // there.
12491 let at = d.source.find("\n\ny ").unwrap();
12492 d.edit(at, at, "\n\nmiddle");
12493 d.build_visual_unwrapped();
12494 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
12495 // Twice, because that first `build_cached` is what stores the entries:
12496 // this one is the build where the Garamond block is a *hit*, its rows
12497 // cloned with their ids and no attribute walked to explain them.
12498 let at = d.source.len() - 1;
12499 d.edit(at, at, "\n\ntail");
12500 d.build_visual_unwrapped();
12501 check(&d, "after a structural edit, through the block cache");
12502 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
12503
12504 // A family the edit took the last glyph of leaves the glyphs with no
12505 // face and the table with a name nothing asks for — harmless, and the
12506 // price of not walking the rows the splice exists to avoid walking.
12507 let span = d.source.find("<span data-font=\"Futura\">").unwrap();
12508 let end = d.source.rfind("</span>").unwrap() + "</span>".len();
12509 d.edit(span, end, "beta");
12510 d.build_visual_unwrapped();
12511 assert!(!d.source.contains("Futura"), "{:?}", d.source);
12512 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
12513 }
12514
12515 #[test]
12516 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
12517 // The same correctness net as `incremental_build_matches_a_fresh_build_
12518 // across_edits`, under `MarkupMode::Full` — where the map depends on
12519 // the caret's *line* as well as the text, so the two caches have a new
12520 // way to be wrong. Both are exercised: the block cache can hand back
12521 // rows built for a line that is no longer the revealed one, and the
12522 // splice path can reuse a suffix that still has yesterday's line raw.
12523 //
12524 // Caret motion is interleaved with the edits deliberately, because a
12525 // caret that only ever moved with the edit would never cross a line
12526 // without also dirtying it — the case where a stale reveal survives.
12527 let docs = [
12528 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
12529 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
12530 ];
12531 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
12532 for src in docs {
12533 let mut d = wysiwyg_doc("reveal_diff", src);
12534 d.set_markup_mode(MarkupMode::Full);
12535
12536 for step in 0..60usize {
12537 let len = d.source.len();
12538 let raw = (step * 13 + 5) % (len + 1);
12539 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
12540 let pre = d.source.clone();
12541 let action;
12542 if step % 3 == 0 && pos < len {
12543 let end = (pos + 1..=len)
12544 .find(|&i| d.source.is_char_boundary(i))
12545 .unwrap();
12546 action = format!("delete [{pos},{end})");
12547 d.edit(pos, end, "");
12548 } else {
12549 let ins = inserts[step % inserts.len()];
12550 action = format!("insert {ins:?} @ {pos}");
12551 d.edit(pos, pos, ins);
12552 }
12553 // Walk the caret somewhere else in the document, independently
12554 // of where the edit landed.
12555 let want = (step * 29 + 11) % (d.source.len() + 1);
12556 d.caret = (want..=d.source.len())
12557 .find(|&i| d.source.is_char_boundary(i))
12558 .unwrap();
12559 d.build_visual_unwrapped();
12560
12561 let want = reference_map_revealing(&d.source, d.reveal_line());
12562 if maps_differ(&d.vmap, &want) {
12563 panic!(
12564 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
12565 d.caret, d.source
12566 );
12567 }
12568 }
12569 }
12570 }
12571
12572 #[test]
12573 fn caret_motion_across_lines_rebuilds_only_under_full() {
12574 // The cache-key change has to earn its keep in both directions: `Full`
12575 // must rebuild when the caret changes line (or the reveal would never
12576 // move), and the hidden modes must *not* (or every arrow key would pay
12577 // for a feature they don't use). The existing `cache_motion` test pins
12578 // the second for the default mode; this pins the pair against a mode
12579 // change alone.
12580 let body = "*one* here\n\n*two* there\n";
12581
12582 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
12583 full.set_markup_mode(MarkupMode::Full);
12584 caret_at(&mut full, "one");
12585 let before = full.revision();
12586 caret_at(&mut full, "two");
12587 assert_eq!(full.revision(), before, "motion is not an edit");
12588 assert!(
12589 drawn_rows(&full).iter().any(|r| r == "*two* there"),
12590 "the map followed the caret: {:?}",
12591 drawn_rows(&full)
12592 );
12593
12594 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
12595 caret_at(&mut hidden, "one");
12596 let key = hidden.vmap_key.clone();
12597 caret_at(&mut hidden, "two");
12598 assert_eq!(
12599 hidden.vmap_key, key,
12600 "a hidden mode rebuilds nothing on motion"
12601 );
12602 }
12603
12604 #[test]
12605 fn wysiwyg_down_crosses_a_paragraph_boundary() {
12606 // Regression: the blank separator row used to share the previous
12607 // paragraph's end offset, so Down got pinned at the boundary (while Up
12608 // still crossed). Both directions must step through it symmetrically.
12609 //
12610 // It's now stepped *over* rather than onto: the blank line between two
12611 // paragraphs is the boundary being drawn, not a line of the document, so
12612 // one press of Down crosses it. The goal column survives the crossing —
12613 // col 3 at the end of "abc" is col 3 at the end of "def".
12614 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
12615 d.caret = 3; // end of "abc" (row 0)
12616 d.move_down(false);
12617 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
12618 assert_eq!(d.caret, 8); // end of "def", col 3 kept
12619 d.move_up(false);
12620 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
12621 assert_eq!(d.caret, 3);
12622 }
12623
12624 #[test]
12625 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
12626 // The second Up and the second Down here run off the ends of the
12627 // document, which is no longer a place a press is swallowed: they carry
12628 // the caret to the start and the end of the text. The claim in the
12629 // middle — that a Down retraces the Up that crossed the paragraph gap —
12630 // is the one this test is for, and it is asserted where it is made.
12631 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
12632 d.caret = 5; // start of "def"
12633 let start = d.caret_pos();
12634 d.move_up(false);
12635 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
12636 d.move_up(false);
12637 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
12638 d.move_down(false);
12639 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
12640 d.move_down(false);
12641 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
12642 }
12643
12644 #[test]
12645 fn wysiwyg_new_paragraph_shows_before_typing() {
12646 // Regression: two Enters at the end of a paragraph produced trailing
12647 // newlines with no AST node, so the caret appeared stuck on the old line
12648 // until a character was typed. It must ride down onto the new line now.
12649 let mut d = doc_with("wys_newpara", "abc\n");
12650 d.view = View::Wysiwyg;
12651 d.caret = 3;
12652 d.insert("\n");
12653 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
12654 assert_eq!(d.source, "abc\n\n\n");
12655 d.build_visual(80);
12656 let (row, _) = d.caret_pos();
12657 assert!(
12658 row >= 2,
12659 "caret should have moved down to the new line, got row {row}"
12660 );
12661 assert!(
12662 d.vmap.num_rows() >= 3,
12663 "the blank lines should render as rows"
12664 );
12665 }
12666
12667 #[test]
12668 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
12669 // The reported bug: Enter at the end of a paragraph that has another
12670 // paragraph below put the caret at the *start of the next paragraph* —
12671 // the empty paragraph it opened had no row, so the caret snapped onto
12672 // "World". It must now sit on its own empty line, with a blank spacer
12673 // above it (the paragraph gap).
12674 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
12675 d.caret = 5; // end of "Hello"
12676 d.newline();
12677 d.build_visual(80);
12678 let (row, col) = d.caret_pos();
12679 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
12680 assert_eq!(
12681 d.vmap.row_width(row),
12682 0,
12683 "caret's row must be empty, not 'World'"
12684 );
12685 assert!(
12686 row >= 2,
12687 "a blank spacer row should sit above the caret, got row {row}"
12688 );
12689 // The row above the caret is a real (empty) gap, and "Hello" stays put.
12690 assert_eq!(
12691 d.vmap.row_width(row - 1),
12692 0,
12693 "the row above the caret is a gap"
12694 );
12695 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
12696 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
12697 }
12698
12699 #[test]
12700 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
12701 // At the document end a single Enter must also show the paragraph gap —
12702 // a blank spacer row above the caret — so the layout already matches how
12703 // it will look once the new paragraph has text.
12704 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
12705 d.caret = 5; // end of "Hello", no trailing newline
12706 d.newline(); // source becomes "Hello\n\n"
12707 d.build_visual(80);
12708 let (row, col) = d.caret_pos();
12709 assert_eq!(col, 0);
12710 assert!(
12711 row >= 2,
12712 "caret should sit below a blank spacer, got row {row}"
12713 );
12714 assert_eq!(
12715 d.vmap.row_width(row - 1),
12716 0,
12717 "the row above the caret is a gap"
12718 );
12719 }
12720
12721 #[test]
12722 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
12723 // The spacer is view-only: typing the new paragraph must not reflow the
12724 // caret onto a different row — the transient view already matched the
12725 // settled one.
12726 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
12727 d.caret = 5;
12728 d.newline();
12729 d.build_visual(80);
12730 let before = d.caret_pos();
12731 d.insert("New");
12732 d.build_visual(80);
12733 let after = d.caret_pos();
12734 assert_eq!(
12735 after.0, before.0,
12736 "typing must not move the caret to another row ({before:?} -> {after:?})"
12737 );
12738 }
12739
12740 #[test]
12741 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
12742 // Return at the end of the block's last line writes an empty line the
12743 // map used to drop, so the caret landed on `after` and the next
12744 // keystroke went into the paragraph below instead of into the code.
12745 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
12746 d.caret = d.source.find("beta").unwrap() + "beta".len();
12747 d.build_visual(80);
12748 let before = d.caret_pos().0;
12749
12750 d.newline();
12751 d.build_visual(80);
12752 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
12753
12754 let (row, col) = d.caret_pos();
12755 assert_eq!(row, before + 1, "the caret moves down one row");
12756 assert_eq!(col, 0, "onto the head of the empty line");
12757 let span = d.vmap.code_blocks[0].rows_span.clone();
12758 assert!(
12759 span.contains(&row),
12760 "caret row {row} is outside the block's rows {span:?}"
12761 );
12762
12763 // The whole point: what is typed next is code.
12764 d.insert("gamma");
12765 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
12766 }
12767
12768 #[test]
12769 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
12770 let fm = "---\ntitle: hi\n---\n";
12771 let body = format!("{fm}# leaf\n\nbody\n");
12772 let mut d = wysiwyg_doc("wys_fm", &body);
12773 // Opening lifts the caret out of the now-hidden frontmatter.
12774 assert_eq!(
12775 d.caret,
12776 fm.len(),
12777 "caret should start at the first real block"
12778 );
12779 // Left at the content start can't step back into frontmatter.
12780 d.move_left(false);
12781 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
12782 // Doc-start lands on the content floor, not offset 0.
12783 d.move_doc_start(false);
12784 assert_eq!(d.caret, fm.len());
12785 // Select-all + copy never include the frontmatter bytes.
12786 d.select_all();
12787 let sel = d.selected_text().unwrap().to_string();
12788 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
12789 assert!(
12790 sel.starts_with("# leaf"),
12791 "selection should begin at content: {sel:?}"
12792 );
12793 }
12794
12795 #[test]
12796 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
12797 // A fresh note is frontmatter and nothing else. With no rendered block
12798 // to floor the caret it opened at offset 0 — before the opening `---` —
12799 // so the first keystroke wrote itself in front of the metadata and the
12800 // file came out as `This---\ntitle: …`.
12801 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
12802 let mut d = wysiwyg_doc("wys_fm_only", fm);
12803 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
12804 // Nothing is rendered, so the caret draws at the origin of an empty view
12805 // — the same place an empty document puts it.
12806 assert_eq!(d.caret_pos(), (0, 0));
12807 d.insert("This");
12808 assert_eq!(d.source, format!("{fm}This"));
12809 }
12810
12811 /// `select_range` is the verb for a range a host already knows the bytes of,
12812 /// so it must not snap — and must still hold every invariant `place_caret`
12813 /// holds, the frontmatter floor above all.
12814 #[test]
12815 fn select_range_takes_the_range_as_given_but_still_floors_it() {
12816 let fm = "---\ntitle: foo\n---\n\n";
12817 let body = format!("{fm}body foo here\n");
12818 let mut d = wysiwyg_doc("wys_select_range", &body);
12819
12820 // The `foo` in the body: taken exactly, not snapped to a caret stop.
12821 let at = body.rfind("foo").unwrap();
12822 d.select_range(at, at + 3);
12823 assert_eq!(d.selection(), Some((at, at + 3)));
12824 assert_eq!(d.selected_text(), Some("foo"));
12825
12826 // The `foo` in the hidden frontmatter: below the floor, so both ends
12827 // come up to it rather than parking the caret in the metadata, where a
12828 // later keystroke would rewrite `title:`.
12829 let hidden = body.find("foo").unwrap();
12830 assert!(hidden < d.vmap.content_start);
12831 d.select_range(hidden, hidden + 3);
12832 assert!(
12833 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
12834 "a range under the floor must not leave the caret in the frontmatter"
12835 );
12836
12837 // Past the end, and mid-character, are both brought back to something
12838 // sliceable rather than panicking the next reader of the range.
12839 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
12840 let mut d = multi;
12841 d.select_range(2, 9_999);
12842 assert_eq!(d.caret, d.source.len());
12843 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
12844 assert!(d.source.is_char_boundary(d.caret));
12845 }
12846
12847 /// The bug `select_range` exists for: a match butting up against a hidden
12848 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
12849 /// the one before the `**`.
12850 #[test]
12851 fn select_range_does_not_snap_off_a_hidden_delimiter() {
12852 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
12853 let at = d.source.find("needle").unwrap();
12854 d.select_range(at, at + 6);
12855 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
12856 }
12857
12858 #[test]
12859 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
12860 // Backspace deletes `prev_boundary..caret` directly; at the first real
12861 // block that boundary is inside the hidden frontmatter, so it must be a
12862 // no-op rather than eating the closing `---`.
12863 let fm = "---\ntitle: hi\n---\n";
12864 let body = format!("{fm}leaf\n");
12865 let mut d = wysiwyg_doc("wys_fm_bs", &body);
12866 assert_eq!(d.caret, fm.len());
12867 d.backspace();
12868 assert_eq!(d.source, body, "backspace must not touch frontmatter");
12869 d.delete_word_back();
12870 assert_eq!(
12871 d.source, body,
12872 "word-delete must not touch frontmatter either"
12873 );
12874 }
12875
12876 #[test]
12877 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
12878 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
12879 // fenced div, really, since core parses these on for every document
12880 // now (`parse_extensions`). The container is a `directive` node, an
12881 // `is_block_container` kind like `block_quote`, so the caret works
12882 // inside its child paragraph exactly as it would inside a quote: typing
12883 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
12884 // untouched.
12885 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
12886 let mut d = wysiwyg_doc("wys_vis", body);
12887 d.caret = body.find("hello").unwrap() + "hello".len();
12888 d.insert("!");
12889 assert_eq!(
12890 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
12891 "typing inside the block edits its content in place"
12892 );
12893 assert!(
12894 d.source.contains(":::vis{.public .family}"),
12895 "opening fence survives"
12896 );
12897 assert!(d.source.contains(":::\nafter"), "closing fence survives");
12898 }
12899
12900 #[test]
12901 fn source_view_still_reaches_frontmatter() {
12902 // The metadata is only *hidden*, never lost: the source view edits and
12903 // selects it in full, and it's always preserved on save.
12904 let fm = "---\ntitle: hi\n---\n";
12905 let body = format!("{fm}# leaf\n");
12906 let mut d = doc_with("src_fm", &body);
12907 d.select_all();
12908 let sel = d.selected_text().unwrap();
12909 assert!(
12910 sel.contains("title"),
12911 "source view should select everything"
12912 );
12913 d.move_doc_start(false);
12914 assert_eq!(d.caret, 0, "source view can reach offset 0");
12915 }
12916
12917 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
12918
12919 #[test]
12920 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
12921 // The border and padding between two cells all share one source offset,
12922 // so a column-stepping caret would sit on `│` and then stall there
12923 // forever. Right must step: end of "Name" -> start of "Qty".
12924 let mut d = wysiwyg_doc("tbl_right", TABLE);
12925 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
12926 d.move_right(false);
12927 assert_eq!(
12928 d.caret,
12929 TABLE.find("Qty").unwrap(),
12930 "should land in the next cell"
12931 );
12932 let (r, c) = d.caret_pos();
12933 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
12934 }
12935
12936 #[test]
12937 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
12938 let mut d = wysiwyg_doc("tbl_left", TABLE);
12939 d.caret = TABLE.find("Qty").unwrap();
12940 d.move_left(false);
12941 assert_eq!(
12942 d.caret,
12943 TABLE.find("Name").unwrap() + 4,
12944 "end of the previous cell"
12945 );
12946 }
12947
12948 #[test]
12949 fn wysiwyg_down_steps_over_a_table_rule() {
12950 // Between the header and the first body row sits a `├───┼───┤` rule.
12951 // It's drawn but holds no caret, so one Down must reach "Pear".
12952 let mut d = wysiwyg_doc("tbl_down", TABLE);
12953 d.caret = TABLE.find("Name").unwrap();
12954 d.move_down(false);
12955 assert_eq!(
12956 d.caret,
12957 TABLE.find("Pear").unwrap(),
12958 "one Down reaches the body row"
12959 );
12960 d.move_down(false);
12961 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
12962 }
12963
12964 #[test]
12965 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
12966 let mut d = wysiwyg_doc("tbl_tab", TABLE);
12967 d.caret = TABLE.find("Name").unwrap();
12968 // A hop lands with the destination cell's whole content selected, the
12969 // caret at its end — so typing replaces the cell like a form field.
12970 assert!(d.cell_hop(true));
12971 assert_eq!(
12972 d.selected_text(),
12973 Some("Qty"),
12974 "the target cell comes up selected"
12975 );
12976 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
12977 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
12978 assert_eq!(d.selected_text(), Some("Pear"));
12979 assert!(d.cell_hop(false));
12980 assert_eq!(d.selected_text(), Some("Qty"));
12981 }
12982
12983 #[test]
12984 fn tab_outside_a_table_is_not_a_cell_hop() {
12985 // `cell_hop` reports false so the frontend can indent as usual.
12986 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
12987 d.caret = 4;
12988 assert!(!d.cell_hop(true));
12989 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
12990 }
12991
12992 #[test]
12993 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
12994 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12995 d.caret = TABLE.rfind("12").unwrap(); // the final cell
12996 assert!(!d.cell_hop(true), "no cell after the last one");
12997 d.caret = TABLE.find("Name").unwrap();
12998 assert!(!d.cell_hop(false), "no cell before the first one");
12999 }
13000
13001 #[test]
13002 fn wysiwyg_vertical_cell_motion_holds_the_column() {
13003 // Down/Up step to the cell above/below in the *same column*, not back to
13004 // the top-left the way a naive row/col motion over the picture would.
13005 let mut d = wysiwyg_doc("tbl_vert", TABLE);
13006 d.caret = TABLE.find("Qty").unwrap();
13007 // Each vertical hop selects the destination cell, holding the column.
13008 assert!(d.cell_move_vertical(true));
13009 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
13010 assert!(d.cell_move_vertical(true));
13011 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
13012 assert!(!d.cell_move_vertical(true), "no row below the last");
13013 assert!(d.cell_move_vertical(false));
13014 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
13015 assert!(d.cell_move_vertical(false));
13016 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
13017 assert!(!d.cell_move_vertical(false), "no row above the header");
13018 }
13019
13020 #[test]
13021 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
13022 let mut d = wysiwyg_doc("tbl_grow", TABLE);
13023 d.caret = TABLE.rfind("12").unwrap();
13024 let rows_before = d.source.matches('\n').count();
13025 assert!(d.cell_tab(true), "acts as a table key");
13026 assert_eq!(
13027 d.source.matches('\n').count(),
13028 rows_before + 1,
13029 "a fresh row was appended"
13030 );
13031 assert!(d.caret_in_table(), "the caret entered the new row");
13032 // The caret sits in the new row's first cell — past the old last cell.
13033 assert!(d.caret > TABLE.rfind("12").unwrap());
13034 }
13035
13036 #[test]
13037 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
13038 let mut d = wysiwyg_doc("tbl_ret", TABLE);
13039 d.caret = TABLE.find("Name").unwrap();
13040 assert!(d.cell_return(), "acts as a table key");
13041 assert_eq!(
13042 d.selected_text(),
13043 Some("Pear"),
13044 "Return drops one cell, selecting it"
13045 );
13046 // From the last row, Return appends a row and enters it.
13047 d.caret = TABLE.rfind("Fig").unwrap();
13048 let rows_before = d.source.matches('\n').count();
13049 assert!(d.cell_return());
13050 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
13051 assert!(d.caret_in_table());
13052 }
13053
13054 #[test]
13055 fn return_and_tab_outside_a_table_decline() {
13056 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
13057 d.caret = 4;
13058 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
13059 assert!(!d.cell_tab(true), "no table: the frontend indents");
13060 assert!(
13061 !d.cell_line_break(),
13062 "no table: the frontend breaks the line"
13063 );
13064 }
13065
13066 #[test]
13067 fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
13068 // A document that ends in a table used to end *inside* it: nothing
13069 // past the last cell was a caret stop, so a click in the blank space
13070 // under the grid snapped back into the table and there was no way to
13071 // write a line after it. The bottom border's end is that stop now.
13072 let mut d = wysiwyg_doc("tbl_trail", TABLE);
13073 let rows = d.vmap.num_rows();
13074 d.click(rows + 3, 0, false);
13075 let end = TABLE.trim_end_matches('\n').len();
13076 assert_eq!(d.caret, end, "the caret stands just past the table");
13077 assert!(!d.caret_in_table(), "past the table is outside it");
13078 assert!(!d.cell_return(), "Return there is the frontend's newline");
13079 d.newline();
13080 d.insert("after");
13081 assert_eq!(
13082 d.source,
13083 format!("{TABLE}\nafter\n"),
13084 "Enter opens a paragraph under the table"
13085 );
13086 }
13087
13088 #[test]
13089 fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
13090 // The stop sits at the end of the table's last source line, and a
13091 // line glued under a table is a row of it — `| Fig | 12 |x` would be a
13092 // three-cell row. So the text gets a paragraph of its own, as it does
13093 // beside a block picture.
13094 let mut d = wysiwyg_doc("tbl_type", TABLE);
13095 d.caret = TABLE.trim_end_matches('\n').len();
13096 d.insert("x");
13097 assert_eq!(d.source, format!("{TABLE}\nx\n"));
13098 assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
13099 // And a paste, which joins the block exactly as typing would.
13100 let mut d = wysiwyg_doc("tbl_paste", TABLE);
13101 d.caret = TABLE.trim_end_matches('\n').len();
13102 d.paste("pasted");
13103 assert_eq!(d.source, format!("{TABLE}\npasted\n"));
13104 }
13105
13106 #[test]
13107 fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
13108 let mut d = wysiwyg_doc("tbl_edge", TABLE);
13109 let last_cell_end = TABLE.rfind("12").unwrap() + 2;
13110 let end = TABLE.trim_end_matches('\n').len();
13111 d.caret = last_cell_end;
13112 d.move_right(false);
13113 assert_eq!(d.caret, end, "Right from the last cell leaves the table");
13114 // Backspace there takes no byte: the one behind the caret is the row's
13115 // closing `|`, which the rich view never drew. It steps back instead.
13116 d.backspace();
13117 assert_eq!(d.source, TABLE, "nothing deleted");
13118 assert_eq!(d.caret, last_cell_end, "back into the last cell");
13119 // Down from the last row lands on the same stop, and Up returns.
13120 d.move_down(false);
13121 assert_eq!(d.caret, end, "Down from the last row leaves the table");
13122 d.move_up(false);
13123 assert_eq!(d.caret, last_cell_end);
13124 }
13125
13126 #[test]
13127 fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
13128 // With prose under the table, the stop is one hop between the last
13129 // cell and the paragraph — the shape a block picture's second stop has.
13130 let src = format!("{TABLE}\nafter\n");
13131 let mut d = wysiwyg_doc("tbl_mid", &src);
13132 d.caret = TABLE.rfind("12").unwrap() + 2;
13133 d.move_right(false);
13134 assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
13135 d.move_right(false);
13136 assert_eq!(d.caret, src.find("after").unwrap());
13137 // Typing at the stop still opens a paragraph, and the text below keeps
13138 // its own.
13139 d.move_left(false);
13140 d.insert("x");
13141 assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
13142 }
13143
13144 #[test]
13145 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
13146 let mut d = wysiwyg_doc("tbl_break", TABLE);
13147 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
13148 assert!(d.cell_line_break(), "acts as a table key");
13149 assert!(
13150 d.source.contains("Pear<br>"),
13151 "spelled as an inline <br>: {}",
13152 d.source
13153 );
13154 assert!(d.caret_in_table(), "still in the cell, past the break");
13155 // The break renders as a real line: the "Pear" cell now draws two lines,
13156 // so the table's picture is one row taller than a single-line table.
13157 d.build_visual(80);
13158 let table = &d.vmap.tables[0];
13159 let cell = &table.grid[1].cells[0]; // first body row, first column
13160 assert!(
13161 cell.glyphs.iter().any(|g| g.ch == '\n'),
13162 "the cell carries the break as a newline glyph for the frontend to split"
13163 );
13164 }
13165
13166 #[test]
13167 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
13168 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
13169 // back as structure — the whole point of routing through insert_line_break
13170 // instead of splicing raw `<br>` bytes.
13171 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
13172 d.caret = TABLE.find("Pear").unwrap() + 4;
13173 assert!(d.cell_line_break());
13174 let kinds: Vec<Kind> = d
13175 .editor
13176 .nodes()
13177 .unwrap()
13178 .iter()
13179 .map(|n| n.kind.clone())
13180 .collect();
13181 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
13182 assert!(
13183 !kinds.contains(&Kind::RawInline),
13184 "still raw HTML: {kinds:?}"
13185 );
13186 }
13187
13188 #[test]
13189 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
13190 // The `<br>` draws as one newline glyph, so Backspace over it must take
13191 // all four bytes — a one-byte delete would strand a visible `<br` in the
13192 // cell (the reported bug).
13193 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
13194 d.caret = TABLE.find("Pear").unwrap() + 4;
13195 assert!(d.cell_line_break());
13196 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
13197 d.backspace(); // caret sits just past the break
13198 assert!(
13199 !d.source.contains("<br"),
13200 "no half-deleted <br left: {}",
13201 d.source
13202 );
13203 assert!(
13204 d.source.contains("| Pear |"),
13205 "the cell is back to one line: {}",
13206 d.source
13207 );
13208 }
13209
13210 #[test]
13211 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
13212 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
13213 d.caret = TABLE.find("Pear").unwrap() + 4;
13214 assert!(d.cell_line_break());
13215 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
13216 d.delete_forward();
13217 assert!(
13218 !d.source.contains("<br"),
13219 "no half-deleted <br: {}",
13220 d.source
13221 );
13222 assert!(
13223 d.source.contains("| Pear |"),
13224 "cell back to one line: {}",
13225 d.source
13226 );
13227 }
13228
13229 #[test]
13230 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
13231 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
13232 // still consumed (a real newline would split the one-line row), but the
13233 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
13234 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
13235 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13236 d.caret = src.find("Pear").unwrap() + 4;
13237 assert!(d.caret_in_table(), "caret should be inside the djot table");
13238 assert!(
13239 d.cell_line_break(),
13240 "the key is consumed, not passed to the frontend"
13241 );
13242 assert_eq!(d.source, src, "the djot cell is left untouched");
13243 assert!(
13244 !d.source.contains("<br>"),
13245 "no non-idiomatic <br> spliced into djot"
13246 );
13247 assert!(
13248 d.status.is_some(),
13249 "the refusal is surfaced on the status line"
13250 );
13251 }
13252
13253 #[test]
13254 fn typing_in_a_cell_edits_that_cell() {
13255 // Editing comes free once offsets map correctly: the caret is a source
13256 // offset, so a normal splice lands inside the pipe table.
13257 let mut d = wysiwyg_doc("tbl_type", TABLE);
13258 d.caret = TABLE.find("Pear").unwrap() + 4;
13259 d.insert("s");
13260 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
13261 }
13262
13263 #[test]
13264 fn motion_and_delete_treat_an_emoji_as_one_character() {
13265 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
13266 // bytes, several codepoints. Right-arrow must clear it in one step, and
13267 // backspace must remove the whole cluster, not a stray joiner.
13268 let family = "👨👩👧";
13269 let mut d = doc_with("emoji", &format!("a{family}b\n"));
13270 d.caret = 1; // just after 'a', before the emoji
13271 d.move_right(false);
13272 assert_eq!(
13273 d.caret,
13274 1 + family.len(),
13275 "one step clears the whole cluster"
13276 );
13277 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
13278
13279 d.backspace(); // delete the emoji as a unit
13280 assert_eq!(d.source, "ab\n");
13281 assert_eq!(d.caret, 1);
13282 }
13283
13284 #[test]
13285 fn motion_handles_a_combining_accent_as_one_character() {
13286 // "e" + U+0301 (combining acute) renders as one é.
13287 let mut d = doc_with("combining", "e\u{0301}x\n");
13288 d.caret = 0;
13289 d.move_right(false);
13290 assert_eq!(
13291 d.caret,
13292 "e\u{0301}".len(),
13293 "steps past base + combining mark"
13294 );
13295 }
13296
13297 #[test]
13298 fn undo_then_redo_round_trips_an_edit() {
13299 let mut d = doc_with("undo", "hello\n");
13300 d.caret = 5;
13301 d.insert("!");
13302 assert_eq!(d.source, "hello!\n");
13303 d.undo();
13304 assert_eq!(d.source, "hello\n");
13305 assert_eq!(d.caret, 5, "undo restores the caret");
13306 d.redo();
13307 assert_eq!(d.source, "hello!\n");
13308 }
13309
13310 #[test]
13311 fn a_run_of_typing_undoes_as_one_step() {
13312 let mut d = doc_with("coalesce", "\n");
13313 d.caret = 0;
13314 d.insert("a");
13315 d.insert("b");
13316 d.insert("c");
13317 assert_eq!(d.source, "abc\n");
13318 d.undo(); // the whole typed run, not just "c"
13319 assert_eq!(d.source, "\n");
13320 d.undo(); // nothing left — the run was one step
13321 assert_eq!(d.source, "\n");
13322 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
13323 }
13324
13325 // ── IME composition ──────────────────────────────────────────────────────
13326
13327 #[test]
13328 fn a_composition_run_undoes_as_one_step() {
13329 let mut d = doc_with("compose", "\n");
13330 d.caret = 0;
13331 // What an IME does: each step replaces the last one's provisional bytes.
13332 d.edit_composing(0, 0, "k");
13333 d.edit_composing(0, 1, "か");
13334 d.edit_composing(0, 3, "かん");
13335 d.edit_composing(0, 6, "感"); // the commit
13336 d.end_composition();
13337 assert_eq!(d.source, "感\n");
13338 d.undo(); // the whole composition, not its last keystroke
13339 assert_eq!(d.source, "\n");
13340 assert_eq!(d.status.as_deref(), None, "the run was a single step");
13341 }
13342
13343 #[test]
13344 fn two_compositions_are_two_undo_steps() {
13345 let mut d = doc_with("compose_two", "\n");
13346 d.caret = 0;
13347 d.edit_composing(0, 0, "か");
13348 d.edit_composing(0, 3, "蚊");
13349 d.end_composition();
13350 d.edit_composing(3, 3, "き");
13351 d.edit_composing(3, 6, "木");
13352 d.end_composition();
13353 assert_eq!(d.source, "蚊木\n");
13354 d.undo();
13355 assert_eq!(d.source, "蚊\n", "only the second composition");
13356 d.undo();
13357 assert_eq!(d.source, "\n");
13358 }
13359
13360 #[test]
13361 fn a_composition_does_not_fold_into_the_typing_around_it() {
13362 let mut d = doc_with("compose_typing", "\n");
13363 d.caret = 0;
13364 d.insert("a");
13365 d.insert("b");
13366 d.edit_composing(2, 2, "か");
13367 d.edit_composing(2, 5, "蚊");
13368 d.end_composition();
13369 d.insert("c");
13370 assert_eq!(d.source, "ab蚊c\n");
13371 d.undo();
13372 assert_eq!(d.source, "ab蚊\n");
13373 d.undo();
13374 assert_eq!(d.source, "ab\n");
13375 d.undo();
13376 assert_eq!(d.source, "\n");
13377 }
13378
13379 #[test]
13380 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
13381 let mut d = doc_with("compose_spurious", "\n");
13382 d.caret = 0;
13383 d.insert("a");
13384 d.end_composition(); // an IME unmarking unprompted
13385 d.insert("b");
13386 assert_eq!(d.source, "ab\n");
13387 d.undo();
13388 assert_eq!(d.source, "\n", "still one typed run");
13389 }
13390
13391 // ── the clipboard's rich flavor ──────────────────────────────────────────
13392
13393 #[test]
13394 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
13395 let mut d = doc_with("sel_inline", "a **bold** c\n");
13396 d.anchor = Some(2);
13397 d.caret = 10; // `**bold**`, inside the paragraph
13398 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
13399 }
13400
13401 #[test]
13402 fn a_whole_block_selection_keeps_its_paragraph() {
13403 let mut d = doc_with("sel_block", "a **bold** c\n");
13404 d.anchor = Some(0);
13405 d.caret = 12; // the entire paragraph
13406 assert_eq!(
13407 d.selection_html().as_deref(),
13408 Some("<p>a <strong>bold</strong> c</p>")
13409 );
13410 }
13411
13412 #[test]
13413 fn a_multi_block_selection_keeps_its_structure() {
13414 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
13415 d.select_all();
13416 let html = d.selection_html().expect("renders");
13417 assert!(html.contains("<p>para</p>"), "{html:?}");
13418 assert!(html.contains("<li>one</li>"), "{html:?}");
13419 }
13420
13421 #[test]
13422 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
13423 // The fragment `Head` is a paragraph standalone; the *document* says it
13424 // sits inside one block, so the wrapper is an artifact either way.
13425 let mut d = doc_with("sel_heading", "# Head line\n");
13426 d.anchor = Some(2);
13427 d.caret = 6;
13428 assert_eq!(d.selection_html().as_deref(), Some("Head"));
13429 }
13430
13431 #[test]
13432 fn no_selection_publishes_no_html() {
13433 let mut d = doc_with("sel_none", "a b\n");
13434 d.caret = 1;
13435 assert_eq!(d.selection_html(), None);
13436 }
13437
13438 #[test]
13439 fn pasting_html_converts_it_and_is_one_undo_step() {
13440 let mut d = doc_with("paste_html", "x\n");
13441 d.caret = 1;
13442 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
13443 assert_eq!(d.source, "xa **b** c\n");
13444 d.undo();
13445 assert_eq!(d.source, "x\n", "the whole paste, in one step");
13446 }
13447
13448 #[test]
13449 fn pasting_html_replaces_the_selection() {
13450 let mut d = doc_with("paste_html_sel", "keep drop\n");
13451 d.anchor = Some(5);
13452 d.caret = 9;
13453 assert!(d.paste_html("<em>new</em>"));
13454 assert_eq!(d.source, "keep *new*\n");
13455 }
13456
13457 #[test]
13458 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
13459 let mut d = doc_with("paste_html_bad", "x\n");
13460 d.caret = 1;
13461 // twig builds no table from HTML; raw `<table>` in prose is worse than
13462 // the plain flavor the caller still holds.
13463 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
13464 assert_eq!(d.source, "x\n", "declined edits nothing");
13465 }
13466
13467 #[test]
13468 fn copy_then_paste_round_trips_through_the_html_flavor() {
13469 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
13470 d.select_all();
13471 let html = d.selection_html().expect("renders");
13472 let mut into = doc_with("clip_round_dst", "\n");
13473 into.caret = 0;
13474 assert!(into.paste_html(&html));
13475 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
13476 }
13477
13478 #[test]
13479 fn moving_the_caret_starts_a_new_undo_group() {
13480 let mut d = doc_with("break", "\n");
13481 d.caret = 0;
13482 d.insert("a");
13483 d.insert("b"); // "ab\n", caret at 2
13484 d.move_left(false); // breaks the run
13485 d.insert("X"); // "aXb\n"
13486 assert_eq!(d.source, "aXb\n");
13487 d.undo();
13488 assert_eq!(
13489 d.source, "ab\n",
13490 "first undo removes only the post-move insert"
13491 );
13492 d.undo();
13493 assert_eq!(d.source, "\n", "second undo removes the earlier run");
13494 }
13495
13496 #[test]
13497 fn undo_reverses_a_format_toggle() {
13498 let mut d = doc_with("fmt_undo", "a word b\n");
13499 d.anchor = Some(2);
13500 d.caret = 6;
13501 d.toggle(InlineKind::Strong);
13502 assert_eq!(d.source, "a **word** b\n");
13503 d.undo();
13504 assert_eq!(d.source, "a word b\n");
13505 }
13506
13507 #[test]
13508 fn undo_back_to_the_saved_state_clears_dirty() {
13509 let mut d = doc_with("dirty_undo", "hello\n");
13510 assert!(!d.dirty);
13511 d.caret = 5;
13512 d.insert("!");
13513 assert!(d.dirty);
13514 d.undo();
13515 assert!(
13516 !d.dirty,
13517 "undoing to the saved source is not a modification"
13518 );
13519 }
13520
13521 #[test]
13522 fn a_new_edit_invalidates_redo() {
13523 let mut d = doc_with("redo_inv", "\n");
13524 d.caret = 0;
13525 d.insert("a");
13526 d.undo();
13527 d.insert("b"); // diverges — the redo of "a" is now gone
13528 d.redo();
13529 assert_eq!(d.source, "b\n");
13530 }
13531
13532 #[test]
13533 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
13534 let mut d = doc_with("can_undo", "hello\n");
13535 assert!(
13536 !d.can_undo() && !d.can_redo(),
13537 "a fresh document has no history"
13538 );
13539 d.caret = 5;
13540 d.insert("!");
13541 assert!(
13542 d.can_undo() && !d.can_redo(),
13543 "an edit is a step to take back"
13544 );
13545 d.undo();
13546 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
13547 d.redo();
13548 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
13549 d.undo();
13550 d.insert("?");
13551 assert!(
13552 d.can_undo() && !d.can_redo(),
13553 "a fresh edit ends the redo chain"
13554 );
13555 // A coalesced run over-counts steps — the bound is what a menu needs,
13556 // and it reconciles the moment twig reports the history empty.
13557 d.insert("a");
13558 d.insert("b");
13559 while d.can_undo() {
13560 d.undo();
13561 }
13562 assert_eq!(d.source, "hello\n");
13563 assert!(!d.can_undo());
13564 // A reading surface has nothing to undo, whatever the history holds.
13565 d.redo();
13566 d.set_read_only(true);
13567 assert!(!d.can_undo() && !d.can_redo());
13568 }
13569
13570 #[test]
13571 fn undo_on_empty_history_is_a_no_op() {
13572 let mut d = doc_with("undo_empty", "hi\n");
13573 d.undo();
13574 assert_eq!(d.source, "hi\n");
13575 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
13576 }
13577
13578 #[test]
13579 fn a_one_character_paste_is_its_own_undo_step() {
13580 for view in [View::Source, View::Wysiwyg] {
13581 let mut d = doc_in(view, "paste_step", "ab\n");
13582 d.caret = 0;
13583 d.insert("x");
13584 d.insert("y"); // a run of typing
13585 d.paste("z"); // one character, but pasted — not part of that run
13586 assert_eq!(d.source, "xyzab\n");
13587 d.undo();
13588 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
13589 assert_eq!(d.caret, 2, "and hands back the caret it found");
13590 d.undo();
13591 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
13592 }
13593 }
13594
13595 #[test]
13596 fn the_same_character_typed_still_joins_the_run() {
13597 // The other half of the pair: `z` is a keystroke here and a paste above,
13598 // and the two undo differently. Nothing about the *string* says which —
13599 // which is why provenance has to come from the door the caller uses.
13600 for view in [View::Source, View::Wysiwyg] {
13601 let mut d = doc_in(view, "typed_run", "ab\n");
13602 d.caret = 0;
13603 d.insert("x");
13604 d.insert("y");
13605 d.insert("z");
13606 d.undo();
13607 assert_eq!(d.source, "ab\n", "one run, one step");
13608 }
13609 }
13610
13611 #[test]
13612 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
13613 for view in [View::Source, View::Wysiwyg] {
13614 let mut d = doc_in(view, "undo_caret", "hello world\n");
13615 d.caret = 11; // standing at the end of "world", away from the edit
13616 d.edit(0, 5, "goodbye");
13617 assert_eq!(d.source, "goodbye world\n");
13618 d.undo();
13619 assert_eq!(d.source, "hello world\n");
13620 // The undone edit ends at offset 5; the user was at 11.
13621 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
13622 }
13623 }
13624
13625 #[test]
13626 fn undo_restores_the_selection_the_edit_replaced() {
13627 for view in [View::Source, View::Wysiwyg] {
13628 let mut d = doc_in(view, "undo_sel", "a word b\n");
13629 d.anchor = Some(2);
13630 d.caret = 6; // "word" selected
13631 d.insert("X");
13632 assert_eq!(d.source, "a X b\n");
13633 d.undo();
13634 assert_eq!(d.source, "a word b\n");
13635 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
13636 }
13637 }
13638
13639 #[test]
13640 fn redo_restores_the_caret_the_edit_left_behind() {
13641 for view in [View::Source, View::Wysiwyg] {
13642 let mut d = doc_in(view, "redo_caret", "hello world\n");
13643 d.caret = 11;
13644 d.edit(0, 5, "goodbye");
13645 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
13646 d.undo();
13647 d.redo();
13648 assert_eq!(d.source, "goodbye world\n");
13649 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
13650 }
13651 }
13652
13653 #[test]
13654 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
13655 for view in [View::Source, View::Wysiwyg] {
13656 let mut d = doc_in(view, "run_caret", "hi\n");
13657 d.caret = 2;
13658 d.insert("a");
13659 d.insert("b");
13660 d.insert("c");
13661 assert_eq!(d.source, "hiabc\n");
13662 d.undo();
13663 assert_eq!(d.source, "hi\n");
13664 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
13665 d.redo();
13666 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
13667 }
13668 }
13669
13670 #[test]
13671 fn undo_restores_the_caret_across_a_format_toggle() {
13672 // A toggle reaches twig without going through `splice`, so it has to
13673 // record its own step — miss it and every stack depth below it is off by
13674 // one, and undo starts handing back another edit's caret.
13675 for view in [View::Source, View::Wysiwyg] {
13676 let mut d = doc_in(view, "fmt_caret", "a word b\n");
13677 d.caret = 8;
13678 d.anchor = Some(2);
13679 d.caret = 6;
13680 d.toggle(InlineKind::Strong);
13681 assert_eq!(d.source, "a **word** b\n");
13682 d.undo();
13683 assert_eq!(d.source, "a word b\n");
13684 assert_eq!(
13685 d.selection(),
13686 Some((2, 6)),
13687 "the toggled selection comes back"
13688 );
13689 }
13690 }
13691
13692 #[test]
13693 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
13694 // The drift that would never announce itself: twig drops its redo stack
13695 // on any fresh edit, so a leaf redo entry that outlives it would restore
13696 // a caret from the timeline that edit abandoned.
13697 for view in [View::Source, View::Wysiwyg] {
13698 let mut d = doc_in(view, "redo_trunc", "hello world\n");
13699 d.caret = 11;
13700 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
13701 d.undo();
13702 assert_eq!(d.caret, 11);
13703 d.caret = 0;
13704 d.insert("X"); // diverges: A's redo is gone from twig
13705 assert_eq!(d.source, "Xhello world\n");
13706
13707 d.redo();
13708 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
13709 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
13710 d.undo();
13711 assert_eq!(d.source, "hello world\n");
13712 assert_eq!(
13713 d.caret, 0,
13714 "the surviving step's caret, not the dropped one"
13715 );
13716 }
13717 }
13718
13719 #[test]
13720 fn indent_and_outdent_move_the_caret_line_with_its_text() {
13721 for view in [View::Source, View::Wysiwyg] {
13722 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
13723 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
13724 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
13725 // Indentation the caret is standing *in* collapses to the line start
13726 // rather than dragging the caret into the text.
13727 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
13728 // A line with none to give back is left exactly as it was.
13729 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
13730 // Less than a full level gives back what it has.
13731 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
13732 // A tab is one level however many spaces it isn't.
13733 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
13734 }
13735 }
13736
13737 #[test]
13738 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
13739 // Why the level is two spaces and not the four both frontends type
13740 // today. Four is markdown's indented-code-block marker, so a Tab on a
13741 // paragraph would silently restyle it as code — a width that changes
13742 // what the document *means* isn't an indent. Pinned because the number
13743 // is the kind of thing a later list-aware pass would reach for.
13744 let mut d = doc_with("indent_kind", "hello\n");
13745 d.caret = 2;
13746 d.indent();
13747 assert_eq!(d.source, " hello\n");
13748 assert!(
13749 d.nodes().iter().any(|n| n.kind == Kind::Para),
13750 "still prose after a Tab"
13751 );
13752 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
13753
13754 // The four-space level this replaces, for contrast: same text, and twig
13755 // reparses the paragraph into a code block.
13756 let mut wide = doc_with("indent_kind_4", " hello\n");
13757 wide.build_visual(80);
13758 assert!(
13759 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
13760 "four spaces is a code block, not an indented paragraph"
13761 );
13762 }
13763
13764 #[test]
13765 fn indent_nests_a_list_item_under_its_parent() {
13766 // Tab indents a list item by its own marker width, landing its marker at
13767 // the parent's content column so twig reparses it as a nested list.
13768 for view in [View::Source, View::Wysiwyg] {
13769 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
13770 d.caret = 6; // on the second item
13771 d.indent();
13772 assert_eq!(d.source, "- a\n - b\n");
13773 let lists = d
13774 .nodes()
13775 .iter()
13776 .filter(|n| n.kind == Kind::BulletList)
13777 .count();
13778 assert_eq!(lists, 2, "the indented item is a nested list");
13779 }
13780 }
13781
13782 #[test]
13783 fn indent_nests_an_ordered_item_at_its_marker_width() {
13784 // An ordered marker `1. ` is three columns wide, so a two-space step
13785 // (which nests a bullet) leaves it flat. Regression: Tab must use the
13786 // marker width, three, so the item actually nests — and the source
13787 // renumbers so the sub-list restarts at 1 and the outer list resumes.
13788 for view in [View::Source, View::Wysiwyg] {
13789 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
13790 d.caret = d.source.find('b').unwrap();
13791 d.indent();
13792 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
13793 let lists = d
13794 .nodes()
13795 .iter()
13796 .filter(|n| n.kind == Kind::OrderedList)
13797 .count();
13798 assert_eq!(lists, 2, "the indented item is a nested ordered list");
13799 }
13800 }
13801
13802 #[test]
13803 fn indent_leaves_a_lists_first_item_put() {
13804 // The first item of a list has no sibling above it to nest under, so Tab
13805 // is a no-op there — the marker stays at column zero rather than being
13806 // shoved into indentation twig can't read as a sub-list.
13807 for view in [View::Source, View::Wysiwyg] {
13808 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
13809 d.caret = 1; // on the FIRST item
13810 d.indent();
13811 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
13812 // The sibling below still nests, proving the guard is per-item.
13813 d.caret = d.source.find('b').unwrap();
13814 d.indent();
13815 assert_eq!(d.source, "- a\n - b\n");
13816 }
13817 }
13818
13819 #[test]
13820 fn hidden_mode_keeps_typed_markup_literal() {
13821 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
13822 // twig escapes what would open markup, so the source is `\*hi\*` and the
13823 // AST is a plain string. Formatting is the commands' job in this mode.
13824 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
13825 d.insert("*hi*");
13826 assert_eq!(d.source, "\\*hi\\*");
13827 assert!(
13828 d.nodes()
13829 .iter()
13830 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
13831 );
13832 }
13833
13834 #[test]
13835 fn hidden_mode_escapes_a_line_start_block_marker() {
13836 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
13837 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
13838 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
13839 d.insert("# hi");
13840 assert_eq!(d.source, "\\# hi");
13841 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
13842 }
13843
13844 #[test]
13845 fn authoring_modes_keep_typed_markup_live() {
13846 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
13847 // (no escape), the same as source view — escaping is `None`'s alone, and
13848 // it's the axis, not the reveal, that decides.
13849 for (view, mode) in [
13850 (View::Wysiwyg, MarkupMode::Shortcuts),
13851 (View::Wysiwyg, MarkupMode::Full),
13852 (View::Source, MarkupMode::None),
13853 ] {
13854 let mut d = doc_in(view, "live_markup", "");
13855 d.set_markup_mode(mode);
13856 d.insert("*hi*");
13857 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
13858 }
13859 }
13860
13861 #[test]
13862 fn hidden_mode_overwrite_undoes_in_one_step() {
13863 // Typing over a selection escapes the replacement *and* stays a single
13864 // undo — the selection-delete and the literal insert fold together, so
13865 // one undo brings the whole selection back, like a plain overwrite.
13866 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
13867 d.anchor = Some(2);
13868 d.caret = 6; // "word"
13869 d.insert("*");
13870 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
13871 d.undo();
13872 assert_eq!(d.source, "a word b\n");
13873 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
13874 }
13875
13876 #[test]
13877 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
13878 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
13879 // the whole visual character, never stranding the hidden `\`.
13880 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
13881 d.insert("*");
13882 assert_eq!(d.source, "\\*");
13883 d.backspace();
13884 assert_eq!(d.source, "", "the escape backslash went with the *");
13885 // A *literal* backslash (source view, no escape) is an ordinary char.
13886 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
13887 s.caret = 3; // after `b`
13888 s.backspace();
13889 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
13890 }
13891
13892 #[test]
13893 fn hidden_mode_leaves_structural_markup_alone() {
13894 // Enter continues a bullet list by writing a real `- ` marker (an
13895 // `insert_raw`, not the typing path), so Hidden mode's escaping never
13896 // touches it — the list keeps working.
13897 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
13898 d.caret = 6;
13899 d.newline();
13900 d.insert("two");
13901 assert_eq!(d.source, "- item\n- two\n");
13902 }
13903
13904 #[test]
13905 fn markup_mode_defaults_to_none_and_round_trips() {
13906 // Diaryx's default is the clean `None` surface; a markup-fluent
13907 // frontend can climb the ladder, and the choice sticks.
13908 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
13909 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
13910 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
13911 d.set_markup_mode(mode);
13912 assert_eq!(d.markup_mode(), mode);
13913 }
13914 }
13915
13916 #[test]
13917 fn full_mode_reveals_only_the_caret_line() {
13918 // The mode's whole claim: the caret's line shows its raw delimiters and
13919 // every other line stays resolved. Two paragraphs with identical markup
13920 // so the only difference between the rows is where the caret is.
13921 let mut d = doc_in(
13922 View::Wysiwyg,
13923 "reveal_caret_line",
13924 "*one* here\n\n*two* there\n",
13925 );
13926 d.set_markup_mode(MarkupMode::Full);
13927
13928 caret_at(&mut d, "one");
13929 let rows = drawn_rows(&d);
13930 assert!(
13931 rows.iter().any(|r| r == "*one* here"),
13932 "caret's line raw: {rows:?}"
13933 );
13934 assert!(
13935 rows.iter().any(|r| r == "two there"),
13936 "other line resolved: {rows:?}"
13937 );
13938
13939 // Move to the other paragraph: the reveal follows, and the line just
13940 // left goes back to being resolved.
13941 caret_at(&mut d, "two");
13942 let rows = drawn_rows(&d);
13943 assert!(
13944 rows.iter().any(|r| r == "*two* there"),
13945 "caret's line raw: {rows:?}"
13946 );
13947 assert!(
13948 rows.iter().any(|r| r == "one here"),
13949 "left line resolved: {rows:?}"
13950 );
13951 }
13952
13953 #[test]
13954 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
13955 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
13956 // the same treatment as an emphasis's `*`: hidden while the caret is
13957 // elsewhere, shown in full where the caret lands. That falls out of
13958 // `delims` reading the bytes between the mark's span and its content
13959 // span, which is exactly `==🔴 ` and `==`, rather than from a table
13960 // of spellings — so the no-space form `==🟢green==` reveals right too.
13961 let mut d = doc_in(
13962 View::Wysiwyg,
13963 "reveal_coloured_mark",
13964 "a ==🔴 red== one\n\nb ==plain== two\n",
13965 );
13966 d.set_markup_mode(MarkupMode::Full);
13967
13968 caret_at(&mut d, "red");
13969 let rows = drawn_rows(&d);
13970 assert!(
13971 rows.iter().any(|r| r == "a ==🔴 red== one"),
13972 "the caret's line shows the colour it was written with: {rows:?}"
13973 );
13974 assert!(
13975 rows.iter().any(|r| r == "b plain two"),
13976 "and every other line stays resolved: {rows:?}"
13977 );
13978
13979 // Away from it, the emoji goes back to being markup — the reader sees
13980 // the words and the wash.
13981 caret_at(&mut d, "two");
13982 let rows = drawn_rows(&d);
13983 assert!(
13984 rows.iter().any(|r| r == "a red one"),
13985 "resolved again: {rows:?}"
13986 );
13987 }
13988
13989 #[test]
13990 fn hidden_modes_never_reveal_wherever_the_caret_is() {
13991 // The two rungs below `Full` share a rendering: delimiters stay hidden
13992 // even under the caret. `Shortcuts` differing from `None` only in what
13993 // typing does is exactly the point of splitting the axes.
13994 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
13995 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
13996 d.set_markup_mode(mode);
13997 caret_at(&mut d, "one");
13998 let rows = drawn_rows(&d);
13999 assert!(
14000 rows.iter().any(|r| r == "one here"),
14001 "{mode:?} hides: {rows:?}"
14002 );
14003 assert!(
14004 !rows.iter().any(|r| r.contains('*')),
14005 "{mode:?} shows no `*`: {rows:?}"
14006 );
14007 }
14008 }
14009
14010 #[test]
14011 fn revealed_delimiters_are_the_authors_own_spelling() {
14012 // Delimiters are re-read from the source rather than synthesized per
14013 // kind, so a line comes back spelled the way it was written: `_em_` does
14014 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
14015 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
14016 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
14017 d.set_markup_mode(MarkupMode::Full);
14018 caret_at(&mut d, "em");
14019 let rows = drawn_rows(&d);
14020 assert!(
14021 rows.iter().any(|r| r == body.trim_end()),
14022 "the revealed line is its own source: {rows:?}"
14023 );
14024 }
14025
14026 #[test]
14027 fn revealed_heading_shows_its_hashes() {
14028 // The `# ` marker is a block-level prefix, not an inline delimiter, so
14029 // it takes its own path — but it reveals on the same rule.
14030 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
14031 d.set_markup_mode(MarkupMode::Full);
14032
14033 caret_at(&mut d, "Title");
14034 assert!(
14035 drawn_rows(&d).iter().any(|r| r == "# Title"),
14036 "{:?}",
14037 drawn_rows(&d)
14038 );
14039
14040 caret_at(&mut d, "body");
14041 let rows = drawn_rows(&d);
14042 assert!(
14043 rows.iter().any(|r| r == "Title"),
14044 "hashes hidden again: {rows:?}"
14045 );
14046 }
14047
14048 #[test]
14049 fn revealed_delimiters_are_caret_stops() {
14050 // A delimiter that is drawn but can't be reached is worse than one
14051 // that's hidden: the mode exists so the markup can be *edited*. Every
14052 // revealed byte must be somewhere the caret can stand.
14053 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
14054 d.set_markup_mode(MarkupMode::Full);
14055 caret_at(&mut d, "em");
14056 let opener = d.source.find('*').unwrap();
14057 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
14058 assert!(
14059 d.vmap.is_stop(opener + 3),
14060 "the closing `*` is a caret stop"
14061 );
14062 }
14063
14064 #[test]
14065 fn setext_heading_reveals_nothing_across_its_newline() {
14066 // A setext heading's underline is on another line, so it is not the
14067 // caret line's to reveal — and emitting it would inject a `\n` glyph
14068 // that splits the row where the author wrote no break.
14069 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
14070 d.set_markup_mode(MarkupMode::Full);
14071 caret_at(&mut d, "Title");
14072 let rows = drawn_rows(&d);
14073 assert!(
14074 rows.iter().any(|r| r == "Title"),
14075 "title renders alone: {rows:?}"
14076 );
14077 assert!(
14078 !rows.iter().any(|r| r.contains('=')),
14079 "no underline leaks in: {rows:?}"
14080 );
14081 }
14082
14083 #[test]
14084 fn markup_mode_axes_split_the_ladder() {
14085 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
14086 // that authors markup but still hides it, and it's the only rung where
14087 // the two axes disagree.
14088 assert!(!MarkupMode::None.authors());
14089 assert!(!MarkupMode::None.reveals_caret_line());
14090 assert!(MarkupMode::Shortcuts.authors());
14091 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
14092 assert!(MarkupMode::Full.authors());
14093 assert!(MarkupMode::Full.reveals_caret_line());
14094 }
14095
14096 #[test]
14097 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
14098 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
14099 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
14100 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
14101 // nested bullet and `hello` stays prose: the file round-trips instead of
14102 // hiding a heading the user never asked for.
14103 for view in [View::Source, View::Wysiwyg] {
14104 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
14105 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
14106 d.indent();
14107 assert_eq!(d.source, "- hello\n * \n");
14108 assert!(
14109 d.nodes().iter().all(|n| n.kind != Kind::Heading),
14110 "no heading"
14111 );
14112 // And it's genuinely a nested list, not a flat one.
14113 assert_eq!(
14114 d.nodes()
14115 .iter()
14116 .filter(|n| n.kind == Kind::BulletList)
14117 .count(),
14118 2
14119 );
14120 }
14121 }
14122
14123 #[test]
14124 fn indenting_a_dash_item_with_content_keeps_its_dash() {
14125 // With content, `- x` can't be a setext underline, so there's nothing to
14126 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
14127 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
14128 d.caret = d.source.find('x').unwrap();
14129 d.indent();
14130 assert_eq!(d.source, "- hello\n - x\n");
14131 }
14132
14133 #[test]
14134 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
14135 // The dash→`*` repair coalesces into the Tab, so a single undo restores
14136 // the whole pre-Tab state rather than stranding a half-collapsed doc.
14137 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
14138 d.caret = d.source.find("- \n").unwrap() + 2;
14139 d.indent();
14140 assert_eq!(d.source, "- hello\n * \n");
14141 d.undo();
14142 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
14143 }
14144
14145 #[test]
14146 fn indent_leaves_a_nested_lists_first_item_put_too() {
14147 // The guard is about siblings, not depth: the first item of an *inner*
14148 // list (already nested under `a`) still has nothing before it at its own
14149 // level, so Tab can't take it deeper.
14150 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
14151 d.caret = d.source.find('b').unwrap();
14152 d.indent();
14153 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
14154 // But `c` (a sibling of `b`) nests under `b`.
14155 d.caret = d.source.find('c').unwrap();
14156 d.indent();
14157 assert_eq!(d.source, "- a\n - b\n - c\n");
14158 }
14159
14160 #[test]
14161 fn backspace_at_a_nested_item_start_outdents_it() {
14162 // Backspace with the caret right after a nested item's marker gives back
14163 // one level of nesting, the mirror of Tab — and renumbers the flattened
14164 // ordered list back to a clean run.
14165 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
14166 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
14167 d.backspace();
14168 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
14169 }
14170
14171 #[test]
14172 fn backspace_at_a_top_level_item_start_strips_the_marker() {
14173 // At the outermost level there's no nesting left to give back, so the same
14174 // keystroke drops the bullet and leaves a plain paragraph.
14175 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
14176 d.caret = d.source.find('b').unwrap(); // right after `- `
14177 d.backspace();
14178 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
14179 }
14180
14181 #[test]
14182 fn backspace_mid_item_still_deletes_a_character() {
14183 // The list behaviour is armed only at the item's content start; anywhere
14184 // else Backspace is the ordinary character delete.
14185 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
14186 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
14187 d.backspace();
14188 assert_eq!(d.source, "- b\n");
14189 }
14190
14191 #[test]
14192 fn backspace_at_a_heading_start_strips_the_marker() {
14193 // The `# ` is markup the rich view hides, so Backspace over it takes the
14194 // whole marker and leaves a paragraph. Deleting a byte of it instead left
14195 // `#Title` — no longer a heading, with the hash now literal text the user
14196 // never typed and has to delete again.
14197 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
14198 d.caret = d.source.find('T').unwrap(); // right after `## `
14199 d.backspace();
14200 assert_eq!(d.source, "Title\n");
14201 assert_eq!(
14202 d.caret, 0,
14203 "the caret stays with the text it was in front of"
14204 );
14205 }
14206
14207 #[test]
14208 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
14209 // Only the heading's own marker goes — the quote (or list) it sits in is
14210 // untouched, exactly as un-heading it should be.
14211 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
14212 d.caret = d.source.find('T').unwrap();
14213 d.backspace();
14214 assert_eq!(d.source, "> Title\n");
14215 }
14216
14217 #[test]
14218 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
14219 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
14220 // behind would surface the same stray hash the marker delete just avoided.
14221 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
14222 d.caret = d.source.find('T').unwrap();
14223 d.backspace();
14224 assert_eq!(d.source, "Title\n");
14225 // And it's one edit: a single undo puts the whole heading back.
14226 d.undo();
14227 assert_eq!(d.source, "# Title #\n");
14228 }
14229
14230 #[test]
14231 fn backspace_mid_heading_still_deletes_a_character() {
14232 // The heading behaviour is armed only at the content's start; anywhere
14233 // else Backspace is the ordinary character delete.
14234 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
14235 d.caret = d.source.find('b').unwrap();
14236 d.backspace();
14237 assert_eq!(d.source, "# b\n");
14238 }
14239
14240 #[test]
14241 fn source_view_backspace_still_edits_the_heading_marker_literally() {
14242 // In source view the `# ` is text on the screen the user is deleting a
14243 // byte of, so it keeps its literal meaning — the same split the list
14244 // ladder and Enter draw between the two views.
14245 let mut d = doc_with("bsp_head_src", "# Title\n");
14246 d.caret = d.source.find('T').unwrap();
14247 d.backspace();
14248 assert_eq!(d.source, "#Title\n");
14249 }
14250
14251 #[test]
14252 fn outdent_unnests_an_ordered_item_in_one_press() {
14253 // Shift+Tab gives back exactly the marker width the indent added, so a
14254 // nested ordered item unnests in a single press, and the flattened list
14255 // renumbers back to a clean 1, 2, 3.
14256 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
14257 d.caret = d.source.find('b').unwrap();
14258 d.outdent();
14259 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
14260 let lists = d
14261 .nodes()
14262 .iter()
14263 .filter(|n| n.kind == Kind::OrderedList)
14264 .count();
14265 assert_eq!(lists, 1, "back to one flat list");
14266 }
14267
14268 #[test]
14269 fn table_insert_row_adds_a_row_below_the_caret() {
14270 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14271 d.caret = d.source.find('1').unwrap(); // in the body row
14272 d.table_insert_row(true);
14273 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
14274 }
14275
14276 #[test]
14277 fn table_insert_and_delete_column_at_the_caret() {
14278 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14279 d.caret = d.source.find('a').unwrap(); // column 0
14280 d.table_insert_column(true); // add a column to the right of `a`
14281 assert_eq!(
14282 d.source,
14283 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
14284 );
14285 d.caret = d.source.find('b').unwrap(); // now the third column
14286 d.table_delete_column();
14287 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
14288 }
14289
14290 // ── ragged formats ───────────────────────────────────────────────────────
14291 // No format spells every gesture. HTML writes the inline marks as a tag pair
14292 // and no heading, list, quote or link; Markdown spells five of the eight
14293 // marks — the highlight only because leaf parses with `highlight`, which is
14294 // why the question is asked with the extensions; djot spells all eight and
14295 // no in-cell break. leaf asks twig per
14296 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
14297 // op discover the fact on its own — one of them didn't.
14298
14299 /// An HTML document in the rich view, ready for a gesture.
14300 fn html_doc(body: &str) -> Doc {
14301 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
14302 d.view = View::Wysiwyg;
14303 d.build_visual(80);
14304 d
14305 }
14306
14307 #[test]
14308 fn a_table_gesture_leaves_an_html_table_alone() {
14309 // The regression this guard exists for. twig's table editor consults no
14310 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
14311 // HTML `<table>` as a *pipe table* and reported success: the whole
14312 // element replaced by `| a | b |`, silently, on one press of a toolbar
14313 // button. Every grid op went the same way.
14314 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
14315 // A table of named operations, which is what it looks like.
14316 #[allow(clippy::type_complexity)]
14317 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
14318 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
14319 ("delete row", &|d: &mut Doc| d.table_delete_row()),
14320 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
14321 ("delete column", &|d: &mut Doc| d.table_delete_column()),
14322 ("align", &|d: &mut Doc| {
14323 d.table_set_alignment(Alignment::Right)
14324 }),
14325 ("move row", &|d: &mut Doc| d.table_move_row(true)),
14326 ("move column", &|d: &mut Doc| d.table_move_column(true)),
14327 ];
14328 for (name, op) in ops {
14329 let mut d = html_doc(src);
14330 d.caret = d.source.find('a').unwrap();
14331 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
14332 op(&mut d);
14333 assert_eq!(d.source, src, "{name} rewrote an HTML table");
14334 assert!(
14335 !d.dirty,
14336 "{name} marked the document dirty without editing it"
14337 );
14338 assert!(d.status.is_some(), "{name} refused without saying why");
14339 }
14340 }
14341
14342 #[test]
14343 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
14344 // A task box is a form control in HTML and a footnote has no native
14345 // spelling at all — the two gestures twig 3.5 still spells nothing
14346 // for, now that a quote, a list, a link and an image print through
14347 // its renderer (see the test below).
14348 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
14349 // A table of named operations, which is what it looks like.
14350 #[allow(clippy::type_complexity)]
14351 let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
14352 ("task item", &|d: &mut Doc| d.toggle_task_item()),
14353 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
14354 ("footnote", &|d: &mut Doc| d.insert_footnote()),
14355 ];
14356 for (name, op) in ops {
14357 let mut d = html_doc(src);
14358 let at = d.source.find("Hello").unwrap();
14359 d.caret = at;
14360 d.anchor = Some(at + 5); // a selection, for the ops that want one
14361 op(&mut d);
14362 assert_eq!(d.source, src, "{name} edited an HTML document");
14363 assert!(
14364 !d.dirty,
14365 "{name} marked the document dirty without editing it"
14366 );
14367 let status = d.status.as_deref().unwrap_or("");
14368 assert!(
14369 status.contains("html"),
14370 "{name}: the refusal should name the format, got {status:?}"
14371 );
14372 }
14373 }
14374
14375 #[test]
14376 fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
14377 // twig 3.5: where HTML has no marker alphabet it prints the fresh
14378 // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
14379 // the paragraph as its item, an `<a>` or `<img>` over the selection.
14380 // Until then every one of these was a refusal; now each is a real
14381 // edit, which is what the toolbar's capability flags say too.
14382 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
14383 #[allow(clippy::type_complexity)]
14384 let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
14385 (
14386 "quote",
14387 &|d: &mut Doc| d.toggle_blockquote(),
14388 "<blockquote>",
14389 ),
14390 ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
14391 (
14392 "ordered list",
14393 &|d: &mut Doc| d.toggle_list(true),
14394 "<ol>\n<li>",
14395 ),
14396 (
14397 "link",
14398 &|d: &mut Doc| d.insert_link("https://example.dev"),
14399 "<a href=\"https://example.dev\">Hello</a>",
14400 ),
14401 (
14402 "image",
14403 &|d: &mut Doc| d.insert_image("pic.png", "alt"),
14404 "<img alt=\"Hello\" src=\"pic.png\">",
14405 ),
14406 ];
14407 for (name, op, expect) in ops {
14408 let mut d = html_doc(src);
14409 let at = d.source.find("Hello").unwrap();
14410 d.caret = at;
14411 d.anchor = Some(at + 5);
14412 op(&mut d);
14413 assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
14414 assert!(d.dirty, "{name}: a real edit");
14415 assert_eq!(
14416 d.status, None,
14417 "{name}: a supported gesture reports nothing"
14418 );
14419 }
14420 }
14421
14422 #[test]
14423 fn html_spells_a_heading_as_its_tag_pair() {
14424 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
14425 // along — the one block gesture whose HTML shape it can write. So ⌘2
14426 // in an HTML document is a real edit, and ⌘0 takes it back.
14427 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
14428 let mut d = html_doc(src);
14429 d.caret = d.source.find("Hello").unwrap();
14430 d.toggle_heading(2);
14431 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
14432 assert!(d.dirty);
14433 assert_eq!(d.status, None, "a supported gesture reports nothing");
14434 d.toggle_heading(2);
14435 assert_eq!(d.source, src, "the same level again is back to a paragraph");
14436 }
14437
14438 #[test]
14439 fn html_spells_the_inline_marks_and_the_rule() {
14440 // The other half, and why one per-document flag stopped being enough:
14441 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
14442 // already emits and the parser reads straight back as the same mark —
14443 // and the rule button writes an `<hr>`. Refusing these on the old
14444 // "HTML is parse-only" reading would now be leaf's own limitation.
14445 let mut d = html_doc("<p>Hello world</p>\n");
14446 let at = d.source.find("world").unwrap();
14447 d.caret = at;
14448 d.anchor = Some(at + 5);
14449 d.toggle(InlineKind::Strong);
14450 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
14451 assert!(d.dirty);
14452 assert_eq!(d.status, None, "a supported gesture reports nothing");
14453
14454 // And off again — the toggle reverses, which is the property that makes
14455 // authoring in HTML worth offering rather than a one-way trip.
14456 d.toggle(InlineKind::Strong);
14457 assert_eq!(d.source, "<p>Hello world</p>\n");
14458
14459 let mut d = html_doc("<p>Hello world</p>\n");
14460 d.caret = d.source.find("world").unwrap();
14461 d.insert_thematic_break();
14462 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
14463 }
14464
14465 #[test]
14466 fn a_mark_the_format_cannot_spell_arms_nothing() {
14467 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
14468 // sticky mark for the next text typed. Guarding only the twig call
14469 // leaves that path live, promising a mark the gesture will not write and
14470 // then swallowing the error inside `insert`.
14471 //
14472 // Markdown carries this, on the superscript now rather than on the
14473 // highlight: `^x^` is text there in any configuration, whereas twig
14474 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
14475 // which every leaf document does.
14476 let mut d = doc_with("mark", "Hello world\n");
14477 d.view = View::Wysiwyg;
14478 d.build_visual(80);
14479 d.caret = d.source.find("world").unwrap();
14480 d.toggle(InlineKind::Superscript);
14481 assert!(d.pending_marks.is_empty(), "no mark should be armed");
14482 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
14483 d.insert("X");
14484 assert_eq!(d.source, "Hello Xworld\n");
14485 }
14486
14487 #[test]
14488 fn markdown_authors_a_highlight_and_a_strikethrough() {
14489 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
14490 // write. `==x==` is authorable because leaf's own `parse_extensions`
14491 // turns `highlight` on — twig will only mint bytes this editor's reparse
14492 // reads back — and `~~x~~` because GFM strikethrough is parsed by
14493 // default, so the refusal there was never right for any leaf document.
14494 for (kind, marked) in [
14495 (InlineKind::Mark, "a ==word== b\n"),
14496 (InlineKind::Delete, "a ~~word~~ b\n"),
14497 ] {
14498 let mut d = doc_with("author_mark", "a word b\n");
14499 d.anchor = Some(2);
14500 d.caret = 6;
14501 d.toggle(kind);
14502 assert_eq!(d.source, marked, "{kind:?}");
14503 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
14504 assert!(d.dirty, "{kind:?}");
14505 // The region stays selected, so the second press reverses it — the
14506 // property that separates authoring from a one-way trip.
14507 d.toggle(kind);
14508 assert_eq!(d.source, "a word b\n", "{kind:?}");
14509 }
14510 }
14511
14512 #[test]
14513 fn an_authored_highlight_reads_back_as_a_mark() {
14514 // The round trip the extension gate exists to protect: what the toggle
14515 // writes, the reparse must read back as a `mark` rather than as two
14516 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
14517 // rebuilt map rather than from the source text.
14518 let mut d = doc_with("mark_roundtrip", "a word b\n");
14519 d.view = View::Wysiwyg;
14520 d.build_visual(80);
14521 d.anchor = Some(2);
14522 d.caret = 6;
14523 d.toggle(InlineKind::Mark);
14524 assert_eq!(d.source, "a ==word== b\n");
14525 d.build_visual(80);
14526 let w = d
14527 .vmap
14528 .rows
14529 .iter()
14530 .flat_map(|r| r.glyphs.iter())
14531 .find(|g| g.ch == 'w')
14532 .expect("the highlighted word");
14533 assert_eq!(w.style.role, crate::Role::Mark(None));
14534 }
14535
14536 #[test]
14537 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
14538 // The three states of one gesture, in the order a palette is pressed:
14539 // an uncoloured highlight takes the prefix, a coloured one has it
14540 // replaced, and `None` takes it away with the space that was part of the
14541 // spelling.
14542 let mut d = doc_with("mark_colour", "a ==word== b\n");
14543 d.caret = d.source.find("word").unwrap();
14544 d.set_mark_color(Some(MarkColor::Red));
14545 assert_eq!(d.source, "a ==🔴 word== b\n");
14546 assert_eq!(d.status, None);
14547 assert!(d.dirty);
14548
14549 d.set_mark_color(Some(MarkColor::Blue));
14550 assert_eq!(d.source, "a ==🔵 word== b\n");
14551
14552 d.set_mark_color(None);
14553 assert_eq!(d.source, "a ==word== b\n");
14554 }
14555
14556 #[test]
14557 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
14558 // The prefix is written *before* the word, so an offset in the word has
14559 // to ride its width — a caret that stayed put would be a caret that
14560 // walked backwards through the text it was standing in.
14561 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
14562 let word = d.source.find("word").unwrap();
14563 d.caret = word + 2; // between `wo` and `rd`
14564 d.set_mark_color(Some(MarkColor::Red));
14565 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
14566
14567 // And back the other way when the prefix goes.
14568 d.set_mark_color(None);
14569 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
14570 }
14571
14572 #[test]
14573 fn the_colour_at_the_caret_is_what_the_palette_lights() {
14574 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
14575 d.caret = d.source.find("red").unwrap();
14576 assert!(d.caret_in_mark());
14577 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
14578
14579 d.caret = d.source.find("plain").unwrap();
14580 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
14581 assert_eq!(d.mark_color_at_caret(), None);
14582
14583 d.caret = d.source.find(" and ").unwrap() + 2;
14584 assert!(!d.caret_in_mark());
14585 assert_eq!(d.mark_color_at_caret(), None);
14586 }
14587
14588 #[test]
14589 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
14590 // The gesture colours a highlight that exists; it does not make one.
14591 // Two presses is the price of a coloured highlight from bare text, and
14592 // the reason is undo — one press that spliced twice would take two
14593 // presses to take back.
14594 let mut d = doc_with("mark_colour_none", "a word b\n");
14595 d.caret = d.source.find("word").unwrap();
14596 d.set_mark_color(Some(MarkColor::Red));
14597 assert_eq!(d.source, "a word b\n");
14598 assert!(d.status.is_some(), "it should say why");
14599 assert!(!d.dirty);
14600
14601 // Clearing where there is nothing to clear is the same refusal, not a
14602 // quiet success — the caret is in no highlight either way.
14603 d.status = None;
14604 d.set_mark_color(None);
14605 assert_eq!(d.source, "a word b\n");
14606 assert!(d.status.is_some());
14607 }
14608
14609 #[test]
14610 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
14611 // twig answers this one *successfully* with a `Change` describing some
14612 // earlier edit, so a caller that trusted the change would jump the caret
14613 // to wherever that was. Core answers it before asking.
14614 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
14615 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
14616 d.caret = d.source.find("word").unwrap();
14617 let (source, caret) = (d.source.clone(), d.caret);
14618 d.set_mark_color(None);
14619 assert_eq!(d.source, source);
14620 assert_eq!(
14621 d.caret, caret,
14622 "the caret must not ride a change that isn't one"
14623 );
14624 assert_eq!(d.status, None, "and it is not an error either");
14625 }
14626
14627 #[test]
14628 fn djot_spells_the_highlight_and_not_its_colour() {
14629 // The reason the palette is its own capability rather than the Highlight
14630 // button's: `{=word=}` is a highlight djot writes happily, and there is
14631 // no djot spelling for a colour on it.
14632 assert!(Capabilities::of(Format::Djot).mark);
14633 assert!(!Capabilities::of(Format::Djot).mark_color);
14634 assert!(Capabilities::of(Format::Markdown).mark_color);
14635
14636 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
14637 d.caret = d.source.find("word").unwrap();
14638 assert!(
14639 d.caret_in_mark(),
14640 "the caret is in a highlight all the same"
14641 );
14642 d.set_mark_color(Some(MarkColor::Red));
14643 assert_eq!(d.source, "a {=word=} b\n");
14644 assert!(
14645 d.status.as_deref().unwrap_or("").contains("djot"),
14646 "and the refusal names the document's format: {:?}",
14647 d.status
14648 );
14649 }
14650
14651 #[test]
14652 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
14653 // The round trip that matters for a palette: the bytes twig writes are
14654 // bytes its own reparse reads back as a colour, so the swatch that was
14655 // pressed is the swatch that lights afterwards.
14656 let mut d = doc_with("mark_colour_undo", "a word b\n");
14657 d.anchor = Some(2);
14658 d.caret = 6;
14659 d.toggle(InlineKind::Mark);
14660 d.caret = d.source.find("word").unwrap();
14661 d.set_mark_color(Some(MarkColor::Green));
14662 assert_eq!(d.source, "a ==🟢 word== b\n");
14663 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
14664
14665 // One splice, one step: the colour comes off and the highlight stays.
14666 d.undo();
14667 assert_eq!(d.source, "a ==word== b\n");
14668 d.undo();
14669 assert_eq!(d.source, "a word b\n");
14670 }
14671
14672 #[test]
14673 fn every_colour_leaf_names_is_one_twig_writes() {
14674 // The two enums are one vocabulary, and this is what says so: each of
14675 // leaf's colours writes an emoji twig's reparse reads back as *that*
14676 // colour, so `twig_mark_color`'s table cannot quietly pair red with
14677 // orange.
14678 for color in MarkColor::ALL {
14679 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
14680 d.caret = d.source.find("word").unwrap();
14681 d.set_mark_color(Some(color));
14682 assert_eq!(d.status, None, "{color:?}");
14683 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
14684 }
14685 }
14686
14687 #[test]
14688 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
14689 // The two presses a coloured highlight is made of, in the state the
14690 // first one leaves: `toggle` selects the whole `==word==` and puts the
14691 // caret one past the closing `==`, which is *not* in the mark. Asking at
14692 // the caret alone would refuse to colour the highlight just written —
14693 // the selection's start is what answers.
14694 let mut d = doc_with("mark_colour_fresh", "a word b\n");
14695 d.anchor = Some(2);
14696 d.caret = 6;
14697 d.toggle(InlineKind::Mark);
14698 assert_eq!(d.source, "a ==word== b\n");
14699 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
14700
14701 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
14702 d.set_mark_color(Some(MarkColor::Yellow));
14703 assert_eq!(d.source, "a ==🟡 word== b\n");
14704 assert_eq!(d.status, None);
14705 }
14706
14707 #[test]
14708 fn one_press_highlights_a_selection_and_colours_it() {
14709 // What a toolbar swatch means over a plain selection, and the undo it
14710 // has to have: one press, one step. Two steps would leave an uncoloured
14711 // highlight behind on the way back, which is a state the author never
14712 // asked for and never saw.
14713 let mut d = doc_with("highlight_one", "a word b\n");
14714 d.anchor = Some(2);
14715 d.caret = 6;
14716 d.highlight(Some(MarkColor::Purple));
14717 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
14718 assert_eq!(d.status, None);
14719
14720 d.undo();
14721 assert_eq!(d.source, "a word b\n", "one press, one undo");
14722 }
14723
14724 #[test]
14725 fn one_press_on_an_existing_highlight_only_recolours_it() {
14726 // The other half: inside a highlight there is nothing to make, so the
14727 // compound is the plain gesture and the text is untouched.
14728 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
14729 d.caret = d.source.find("word").unwrap();
14730 d.highlight(Some(MarkColor::Blue));
14731 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
14732 d.undo();
14733 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
14734 }
14735
14736 #[test]
14737 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
14738 // `None` means "no colour", and over bare text that is the Highlight
14739 // button's own job. The fold must not happen here — there is no second
14740 // splice, and folding would take the *previous* edit into this one.
14741 let mut d = doc_with("highlight_none", "a word b and more\n");
14742 d.caret = d.source.find("more").unwrap() + 4; // after "more"
14743 d.insert("!"); // an earlier edit for a wrong fold to swallow
14744 d.anchor = Some(2);
14745 d.caret = 6;
14746 d.highlight(None);
14747 assert_eq!(d.source, "a ==word== b and more!\n");
14748
14749 d.undo();
14750 assert_eq!(
14751 d.source, "a word b and more!\n",
14752 "only the highlight came off"
14753 );
14754 d.undo();
14755 assert_eq!(
14756 d.source, "a word b and more\n",
14757 "and the edit before it survived"
14758 );
14759 }
14760
14761 #[test]
14762 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
14763 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
14764 // a colour cannot be armed with it — so the compound declines rather
14765 // than leaving half a promise.
14766 let mut d = doc_with("highlight_bare", "a word b\n");
14767 d.caret = 4;
14768 d.highlight(Some(MarkColor::Red));
14769 assert_eq!(d.source, "a word b\n");
14770 assert!(d.pending_marks.is_empty(), "and nothing armed");
14771 assert!(d.status.is_some());
14772 }
14773
14774 #[test]
14775 fn a_read_only_document_takes_no_colour() {
14776 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
14777 d.caret = d.source.find("word").unwrap();
14778 d.set_read_only(true);
14779 d.set_mark_color(Some(MarkColor::Red));
14780 assert_eq!(d.source, "a ==word== b\n");
14781 }
14782
14783 #[test]
14784 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
14785 // The other door into `toggle`: no selection, so nothing reaches twig
14786 // until `insert` realises the armed mark. It is armed now — the guard
14787 // above asks `Doc::supports`, which asks with the extensions — and what
14788 // it writes is the same `==…==`.
14789 let mut d = doc_with("sticky_mark", "xy\n");
14790 d.caret = 1;
14791 d.toggle(InlineKind::Mark);
14792 assert!(d.pending_marks.contains(InlineKind::Mark));
14793 d.insert("Z");
14794 assert_eq!(d.source, "x==Z==y\n");
14795 }
14796
14797 #[test]
14798 fn html_documents_still_take_typed_text() {
14799 // The guard covers *markup* gestures and must not touch plain editing:
14800 // twig's splicer is language-neutral, and typing into an HTML document
14801 // is the thing that does work today.
14802 let mut d = html_doc("<p>Hello world</p>\n");
14803 d.caret = d.source.find("world").unwrap();
14804 d.insert("big ");
14805 assert_eq!(d.source, "<p>Hello big world</p>\n");
14806 assert!(d.dirty);
14807 d.backspace();
14808 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
14809 d.undo();
14810 d.undo();
14811 assert_eq!(d.source, "<p>Hello world</p>\n");
14812 }
14813
14814 #[test]
14815 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
14816 // `authorable` only separates "there is a door in" from "there is not",
14817 // and HTML is on the near side of that line — which is exactly why a
14818 // toolbar must not be built from it.
14819 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
14820 assert!(html.authorable());
14821 assert!(
14822 !Doc::from_source("<r>x</r>".into(), Format::Xml)
14823 .unwrap()
14824 .authorable()
14825 );
14826
14827 let caps = html.capabilities();
14828 assert!(caps.bold && caps.italic && caps.code && caps.mark);
14829 assert!(caps.thematic_break && caps.cell_line_break);
14830 // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
14831 // quote, a list, a code block's language, a link and an image — each
14832 // printed as a fresh node where HTML has no marker to rewrite. A task
14833 // box is a form control and a footnote has no spelling, so those two
14834 // are what keeps the record ragged.
14835 assert!(caps.heading && caps.blockquote && caps.bullet_list);
14836 assert!(caps.link && caps.image && caps.code_language);
14837 assert!(!caps.task && !caps.footnote);
14838 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
14839 // twig's table editor would happily re-emit as `| a | b |`.
14840 assert!(!caps.table);
14841
14842 // The two lightweight formats spell everything leaf offers — and still
14843 // differ from each other, which is the other half of why one boolean
14844 // can't serve.
14845 for fmt in [Format::Markdown, Format::Djot] {
14846 let caps = Capabilities::of(fmt);
14847 assert!(
14848 caps.heading && caps.blockquote && caps.ordered_list,
14849 "{fmt:?}"
14850 );
14851 assert!(
14852 caps.task && caps.link && caps.image && caps.table,
14853 "{fmt:?}"
14854 );
14855 }
14856 // Both spell the highlight and the strikethrough: djot natively, and
14857 // Markdown because `Capabilities` asks with `parse_extensions` rather
14858 // than with twig's defaults — `==x==` is text under those, and a mark
14859 // under the `highlight` leaf always parses with.
14860 for fmt in [Format::Markdown, Format::Djot] {
14861 let caps = Capabilities::of(fmt);
14862 assert!(caps.mark && caps.strike, "{fmt:?}");
14863 }
14864 // What still separates them, now that the highlight doesn't: djot has
14865 // no in-cell break, and Markdown spells neither of the scripts.
14866 assert!(Capabilities::of(Format::Djot).superscript);
14867 assert!(!Capabilities::of(Format::Markdown).superscript);
14868 assert!(Capabilities::of(Format::Markdown).cell_line_break);
14869 assert!(!Capabilities::of(Format::Djot).cell_line_break);
14870
14871 // A parse-only format answers no to every one of them, so the coarse
14872 // predicate and the record agree there.
14873 let caps = Capabilities::of(Format::Xml);
14874 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
14875 }
14876
14877 #[test]
14878 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
14879 // The guard exists to name the *document's* format rather than twig's
14880 // internals, so the message has to survive being one leaf writes itself.
14881 // Checked against a gesture twig also refuses, since that is the pair
14882 // most at risk of drifting apart — the task box, once the code
14883 // language stopped being one (twig 3.5).
14884 let mut d = html_doc("<p>Hello</p>\n");
14885 d.caret = d.source.find("Hello").unwrap();
14886 d.toggle_task_item();
14887 assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
14888 assert!(!d.dirty);
14889 }
14890
14891 #[test]
14892 fn table_set_alignment_respells_the_delimiter() {
14893 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14894 d.caret = d.source.find('b').unwrap();
14895 d.table_set_alignment(Alignment::Right);
14896 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
14897 }
14898
14899 #[test]
14900 fn each_empty_table_cell_has_its_own_editable_home() {
14901 // Regression: an empty cell has no twig content_span, so both cells of a
14902 // `| | |` row collapsed onto the row's start (before the first `│`).
14903 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
14904 // tell the cells apart. Each empty cell must now have a distinct home
14905 // inside it.
14906 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
14907 let (c0, c1) = {
14908 let cells = &d.vmap.tables[0].grid[1].cells;
14909 (cells[0].start, cells[1].start)
14910 };
14911 assert!(
14912 c0 < c1,
14913 "the two empty cells have distinct homes: {c0} < {c1}"
14914 );
14915 d.caret = c0;
14916 d.insert("x");
14917 assert_eq!(
14918 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
14919 "typed inside the cell"
14920 );
14921 }
14922
14923 #[test]
14924 fn arrows_step_into_each_empty_table_cell() {
14925 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
14926 let (c0, c1) = {
14927 let cells = &d.vmap.tables[0].grid[1].cells;
14928 (cells[0].start, cells[1].start)
14929 };
14930 d.caret = d.source.find('b').unwrap(); // in the header's second cell
14931 let mut seen = std::collections::HashSet::new();
14932 for _ in 0..6 {
14933 d.move_right(false);
14934 seen.insert(d.caret);
14935 }
14936 assert!(
14937 seen.contains(&c0),
14938 "right arrow reaches the first empty cell"
14939 );
14940 assert!(
14941 seen.contains(&c1),
14942 "right arrow reaches the second empty cell"
14943 );
14944 }
14945
14946 #[test]
14947 fn table_op_off_a_table_is_a_no_op_with_a_status() {
14948 let mut d = doc_with("tbl_none", "just text\n");
14949 d.caret = 3;
14950 d.table_insert_row(true);
14951 assert_eq!(d.source, "just text\n", "nothing changed");
14952 assert!(d.status.is_some(), "a status explains why");
14953 assert!(!d.caret_in_table());
14954 }
14955
14956 #[test]
14957 fn enter_in_an_ordered_list_renumbers_the_following_items() {
14958 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
14959 // the renumber pass keeps them sequential, matching what the view draws.
14960 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
14961 d.caret = d.source.find('a').unwrap() + 1; // end of item a
14962 d.newline();
14963 d.insert("x");
14964 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
14965 }
14966
14967 #[test]
14968 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
14969 for view in [View::Source, View::Wysiwyg] {
14970 let mut d = doc_in(view, "outdent_noop", "hello\n");
14971 d.caret = 2;
14972 d.outdent();
14973 assert_eq!(d.source, "hello\n");
14974 assert!(!d.dirty, "a no-op is not a modification");
14975 d.undo();
14976 assert_eq!(
14977 d.status.as_deref(),
14978 Some("nothing to undo"),
14979 "spends no undo step"
14980 );
14981 assert_eq!(d.source, "hello\n");
14982 }
14983 }
14984
14985 #[test]
14986 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
14987 for view in [View::Source, View::Wysiwyg] {
14988 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
14989 d.anchor = Some(0);
14990 d.caret = 7; // through "two"
14991 d.indent();
14992 assert_eq!(
14993 d.source, " one\n\n two\n",
14994 "the blank line keeps no trailing pad"
14995 );
14996 // Selected, so a second Tab lands on the same lines rather than on
14997 // whatever the shifted offsets now cover.
14998 assert_eq!(d.selection(), Some((0, 12)));
14999 d.indent();
15000 assert_eq!(d.source, " one\n\n two\n");
15001 }
15002 }
15003
15004 #[test]
15005 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
15006 for view in [View::Source, View::Wysiwyg] {
15007 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
15008 d.anchor = Some(0);
15009 d.caret = 15;
15010 d.outdent();
15011 assert_eq!(d.source, "two\none\nnone\n");
15012 }
15013 }
15014
15015 #[test]
15016 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
15017 for view in [View::Source, View::Wysiwyg] {
15018 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
15019 d.anchor = Some(0);
15020 d.caret = 7;
15021 d.indent();
15022 assert_eq!(d.source, " one\n\n two\n");
15023 d.undo();
15024 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
15025 assert_eq!(
15026 d.selection(),
15027 Some((0, 7)),
15028 "with the selection it was aimed at"
15029 );
15030 d.redo();
15031 assert_eq!(d.source, " one\n\n two\n");
15032 assert_eq!(
15033 d.selection(),
15034 Some((0, 12)),
15035 "redo replays the caret the indent placed, not the one splice left"
15036 );
15037 }
15038 }
15039
15040 #[test]
15041 fn vertical_motion_keeps_the_column() {
15042 let mut d = doc_with("move", "abcd\nef\n");
15043 d.caret = 3; // "abc|d" on row 0, col 3
15044 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
15045 assert_eq!(d.caret, 7); // just after "ef"
15046 }
15047
15048 // ── goal column ──────────────────────────────────────────────────────────
15049
15050 #[test]
15051 fn vertical_motion_goal_column_survives_a_short_line() {
15052 // Regression: re-deriving the column from the clamped position on
15053 // every step permanently forgets it once a short line clamps it.
15054 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
15055 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
15056 assert_eq!(
15057 g("abcd|ef\nxy\nghijkl\n", |d| {
15058 d.move_down(false); // clamps to end of "xy"
15059 d.move_down(false); // restores col 4 on the long line
15060 }),
15061 "abcdef\nxy\nghij|kl\n"
15062 );
15063 }
15064
15065 #[test]
15066 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
15067 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
15068 assert_eq!(d.goal_col, None);
15069 d.caret = 4; // row 0, col 4
15070 d.move_down(false); // clamps into "xy"; goal stays the original col
15071 assert_eq!(d.goal_col, Some(4));
15072 assert_eq!(d.caret_pos(), (1, 2));
15073
15074 // A horizontal motion drops the goal column...
15075 d.move_left(false);
15076 assert_eq!(d.goal_col, None);
15077
15078 // ...so the next vertical motion picks up the *new* column (1), not
15079 // the stale one (4).
15080 d.move_down(false);
15081 assert_eq!(d.goal_col, Some(1));
15082 assert_eq!(d.caret_pos(), (2, 1));
15083 }
15084
15085 #[test]
15086 fn editing_clears_the_goal_column() {
15087 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
15088 d.caret = 4;
15089 d.move_down(false);
15090 assert_eq!(d.goal_col, Some(4));
15091 d.insert("Z");
15092 assert_eq!(d.goal_col, None);
15093 }
15094
15095 #[test]
15096 fn vertical_motion_on_an_empty_document_is_a_no_op() {
15097 let mut d = doc_with("empty_vert", "");
15098 d.move_down(false);
15099 assert_eq!(d.caret, 0);
15100 d.move_up(false);
15101 assert_eq!(d.caret, 0);
15102 }
15103
15104 // ── the document's edges ─────────────────────────────────────────────────
15105
15106 #[test]
15107 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
15108 // The reproduction, and the disagreement: Down on the last line ran to
15109 // the end of the document in the source view — by accident, an
15110 // out-of-range row clamping to the end of the string — and did nothing
15111 // whatever in the view leaf opens in. One rule now, in both.
15112 for (view, tag) in VIEWS {
15113 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
15114 d.caret = 1;
15115 d.move_down(false);
15116 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
15117 d.move_up(false);
15118 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
15119 }
15120 }
15121
15122 #[test]
15123 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
15124 // Down off the bottom is a motion like any other, so it latches a goal
15125 // column — and Up comes back to the column the caret left, not to the
15126 // one the document's end happened to be in.
15127 for (view, tag) in VIEWS {
15128 let gap = if view == View::Source { "\n" } else { "\n\n" };
15129 let src = format!("abcdef{gap}ghijkl");
15130 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
15131 d.caret = 2; // row 0, col 2
15132 d.move_down(false);
15133 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
15134 d.move_down(false);
15135 assert_eq!(
15136 d.caret,
15137 src.len(),
15138 "{tag}: Down off the bottom reaches the end"
15139 );
15140 d.move_up(false);
15141 assert_eq!(
15142 d.caret_pos().1,
15143 2,
15144 "{tag}: Up returns to the column Down left"
15145 );
15146 }
15147 }
15148
15149 #[test]
15150 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
15151 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
15152 // Up that did nothing still armed a goal column, and the next Down aimed
15153 // at a column the caret had never been in.
15154 for (view, tag) in VIEWS {
15155 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
15156 d.caret = 0;
15157 d.move_up(false);
15158 assert_eq!(d.caret, 0, "{tag}: already at the start");
15159 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
15160
15161 d.caret = d.source.len();
15162 d.move_down(false);
15163 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
15164 assert_eq!(
15165 d.goal_col, None,
15166 "{tag}: a no-op Down latched a goal column"
15167 );
15168 }
15169 }
15170
15171 // ── soft wrap ────────────────────────────────────────────────────────────
15172 // Every other test here builds the map at 80 columns, where no fixture is
15173 // long enough to fold. A wrap is where one offset belongs to two rows at
15174 // once, and it broke everything that asks the caret what row it is on.
15175
15176 /// The wrapped fixture these cases share, folded at 12 columns into
15177 /// `one two ` / `three four ` / `five six ` / `seven eight`.
15178 fn wrapped_doc(name: &str) -> Doc {
15179 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
15180 d.build_visual(12);
15181 d
15182 }
15183
15184 #[test]
15185 fn home_and_end_work_from_a_wrapped_row() {
15186 // The reproduction: offset 19 is the `f` of "five", the first character
15187 // of the third row — and also the offset the second row ends at. It
15188 // resolved to the *second* row, so End aimed at a place the caret was
15189 // already in and did nothing, while Home walked backwards onto a row the
15190 // caret had left.
15191 let mut d = wrapped_doc("wrap_home_end");
15192 d.caret = 19;
15193 assert_eq!(
15194 d.caret_pos(),
15195 (2, 0),
15196 "the wrap boundary opens the third row"
15197 );
15198 d.move_end(false);
15199 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
15200 d.move_home(false);
15201 assert_eq!(d.caret, 19, "Home left the row the caret was on");
15202 }
15203
15204 #[test]
15205 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
15206 // The row's end is the last offset that is only ever its own: the offset
15207 // past it opens the row below, and aiming there would send a second
15208 // press on to *that* row's end, and a third to the next — End walking
15209 // down the paragraph rather than sitting where it landed.
15210 let mut d = wrapped_doc("wrap_end_twice");
15211 d.caret = 12; // inside "three", on the second row
15212 d.move_end(false);
15213 assert_eq!(
15214 d.caret, 18,
15215 "the end of `three four`, before the space the wrap ate"
15216 );
15217 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
15218 d.move_end(false);
15219 assert_eq!(d.caret, 18, "a second End moved the caret");
15220 d.move_home(false);
15221 assert_eq!(d.caret, 8, "Home takes the row's own start");
15222 }
15223
15224 #[test]
15225 fn vertical_motion_crosses_a_soft_wrap() {
15226 // Down aimed at the row below's column 0, an offset that resolved *up*
15227 // to the row above's end — so it landed on the offset it already had and
15228 // the caret could never leave a paragraph's first row.
15229 let mut d = wrapped_doc("wrap_down");
15230 d.caret = 0;
15231 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
15232 d.move_down(false);
15233 assert_eq!(d.caret, want, "Down stalled");
15234 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
15235 }
15236 d.move_down(false);
15237 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
15238
15239 // ...and back up, one row per press. The goal column is the end of the
15240 // last row, past every other row's width, so each press clamps to the
15241 // row's own last offset rather than to the one that opens the next.
15242 let mut d = wrapped_doc("wrap_up");
15243 d.caret = 39;
15244 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
15245 d.move_up(false);
15246 assert_eq!(d.caret, want, "Up stalled");
15247 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
15248 }
15249 }
15250
15251 #[test]
15252 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
15253 // The kills take the same line Home and End do, so in WYSIWYG they take
15254 // the visual row — and a soft wrap has no newline in it to delete, so
15255 // nothing is joined by reaching the end of one.
15256 let mut d = wrapped_doc("wrap_kill");
15257 d.caret = 19; // the `f` of "five", opening the third row
15258 d.delete_to_line_end();
15259 // The space the wrap ate goes with the row it was drawn on: sparing it
15260 // would leave "four seven", two spaces where the row had been.
15261 assert_eq!(d.source, "one two three four seven eight");
15262
15263 // Backwards from the row's last caret position — which is *before* that
15264 // space, so this one survives, being on the far side of the caret.
15265 let mut d = wrapped_doc("wrap_kill_back");
15266 d.caret = 27;
15267 d.delete_to_line_start();
15268 assert_eq!(d.source, "one two three four seven eight");
15269 }
15270
15271 // ── document start / end ────────────────────────────────────────────────
15272
15273 #[test]
15274 fn move_doc_start_and_end_jump_to_the_edges() {
15275 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
15276 assert_eq!(
15277 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
15278 "|hello\nworld\n"
15279 );
15280 assert_eq!(
15281 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
15282 "hello\nworld\n|"
15283 );
15284 // Already at the edge: a no-op.
15285 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
15286 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
15287 }
15288
15289 #[test]
15290 fn move_doc_start_and_end_extend_the_selection() {
15291 assert_eq!(
15292 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
15293 .move_doc_end(true)),
15294 "hello wor[ld\n|]"
15295 );
15296 assert_eq!(
15297 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
15298 .move_doc_start(true)),
15299 "[|hello wor]ld\n"
15300 );
15301 }
15302
15303 #[test]
15304 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
15305 let mut d = doc_with("empty_edges", "");
15306 d.move_doc_end(false);
15307 assert_eq!(d.caret, 0);
15308 d.move_doc_start(false);
15309 assert_eq!(d.caret, 0);
15310 }
15311
15312 // ── arrow collapses an active selection ─────────────────────────────────
15313
15314 #[test]
15315 fn arrow_collapses_selection_to_its_near_edge() {
15316 let mut d = doc_with("collapse", "hello world\n");
15317
15318 // Forward selection (anchor before caret): Right -> end, Left -> start.
15319 d.anchor = Some(2);
15320 d.caret = 7;
15321 d.move_right(false);
15322 assert_eq!((d.caret, d.anchor), (7, None));
15323
15324 d.anchor = Some(2);
15325 d.caret = 7;
15326 d.move_left(false);
15327 assert_eq!((d.caret, d.anchor), (2, None));
15328
15329 // Backward selection (anchor after caret): edges are the same
15330 // regardless of which end the caret started on.
15331 d.anchor = Some(7);
15332 d.caret = 2;
15333 d.move_right(false);
15334 assert_eq!((d.caret, d.anchor), (7, None));
15335
15336 d.anchor = Some(7);
15337 d.caret = 2;
15338 d.move_left(false);
15339 assert_eq!((d.caret, d.anchor), (2, None));
15340 }
15341
15342 #[test]
15343 fn arrow_with_extend_keeps_growing_the_selection() {
15344 let mut d = doc_with("collapse_extend", "hello world\n");
15345 d.anchor = Some(2);
15346 d.caret = 7;
15347 d.move_right(true); // extend: no collapse, caret steps one further
15348 assert_eq!((d.caret, d.anchor), (8, Some(2)));
15349 }
15350
15351 #[test]
15352 fn arrow_without_a_selection_moves_one_character_as_before() {
15353 let mut d = doc_with("no_collapse", "hello\n");
15354 d.caret = 2;
15355 d.move_right(false);
15356 assert_eq!(d.caret, 3);
15357 d.move_left(false);
15358 assert_eq!(d.caret, 2);
15359 }
15360
15361 /// Press Right until it stops, collecting the offsets walked through. Every
15362 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
15363 /// two stops sharing one source offset can't be moved between, so the caret
15364 /// stalls on the first of them and the walk never reaches the rest.
15365 fn walk_right(d: &mut Doc) -> Vec<usize> {
15366 let mut seen = vec![d.caret];
15367 for _ in 0..2000 {
15368 let before = d.caret;
15369 d.move_right(false);
15370 if d.caret == before {
15371 break;
15372 }
15373 seen.push(d.caret);
15374 }
15375 seen
15376 }
15377
15378 #[test]
15379 fn the_caret_crosses_a_soft_break() {
15380 // A newline inside a paragraph is a `soft_break`, which twig gives no
15381 // span of its own — the space it renders as used to borrow the offset of
15382 // the character before it, and a caret can't move without changing
15383 // offset. Right must walk clean off the end of the first line.
15384 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
15385 d.caret = 0;
15386 let seen = walk_right(&mut d);
15387 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
15388 }
15389
15390 #[test]
15391 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
15392 // The paragraph holds one soft break. Folded (the default) it lays out as
15393 // a single reflowed row; Preserve re-lays it as a row per source line.
15394 // The setter must invalidate the cached map for the change to show, and
15395 // again on the way back — so a round trip returns to the folded layout.
15396 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
15397 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
15398 d.build_visual(80);
15399 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
15400
15401 d.set_line_flow(LineFlow::Preserve);
15402 d.build_visual(80);
15403 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
15404
15405 d.set_line_flow(LineFlow::Fold);
15406 d.build_visual(80);
15407 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
15408 }
15409
15410 #[test]
15411 fn the_caret_still_crosses_a_preserved_soft_break() {
15412 // Preserve renders the soft break as a row boundary rather than a space,
15413 // but the caret must still reach every offset — the break's own offset is
15414 // the first row's end stop, so Right walks clean off the end of line one
15415 // onto line two, exactly as it does when the break is folded.
15416 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
15417 d.set_line_flow(LineFlow::Preserve);
15418 d.build_visual(80);
15419 d.caret = 0;
15420 let seen = walk_right(&mut d);
15421 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
15422 }
15423
15424 #[test]
15425 fn the_caret_walks_a_code_block() {
15426 // Every glyph of a code block used to map to the block's start, so the
15427 // whole block was a single offset and the caret couldn't move inside it.
15428 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
15429 let mut d = wysiwyg_doc("code_walk", src);
15430 d.caret = 0;
15431 let seen = walk_right(&mut d);
15432 // The fences are markup: hidden, and no caret stop. The code between
15433 // them is reached a character at a time.
15434 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
15435 for off in code.clone() {
15436 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
15437 }
15438 assert!(seen.contains(&code.end), "no stop after the last line");
15439 }
15440
15441 #[test]
15442 fn the_caret_walks_an_indented_code_block() {
15443 // An indented block's text has the four-space indent stripped, so it
15444 // isn't a verbatim slice and its lines have to be re-found. The caret
15445 // lands on the code, never in the indent.
15446 let src = " indented\n code\n";
15447 let mut d = wysiwyg_doc("indent_code_walk", src);
15448 d.caret = 0;
15449 let seen = walk_right(&mut d);
15450 assert!(seen.contains(&src.find("indented").unwrap()));
15451 assert!(seen.contains(&src.find("code").unwrap()));
15452 assert!(
15453 !seen.contains(&0) || seen[0] == 0,
15454 "the caret starts where it was put"
15455 );
15456 // Nothing in the stripped indent is a stop.
15457 for off in [1, 2, 3] {
15458 assert!(!seen.contains(&off), "landed in the indent at {off}");
15459 }
15460 }
15461
15462 #[test]
15463 fn the_caret_leaves_a_tight_heading() {
15464 // "# H" with text directly under it: the heading row's end and the
15465 // separator row's end are the same offset. Right used to find the
15466 // separator's copy, set the caret to where it already was, and stop.
15467 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
15468 d.caret = 2; // the "H"
15469 let seen = walk_right(&mut d);
15470 assert!(
15471 seen.len() > 2,
15472 "Right stalled at the heading's end: {seen:?}"
15473 );
15474 assert!(
15475 seen.contains(&8),
15476 "never reached the end of \"text\": {seen:?}"
15477 );
15478 }
15479
15480 #[test]
15481 fn the_caret_skips_the_gap_between_two_paragraphs() {
15482 // The blank line between two paragraphs is the boundary itself. The
15483 // caret used to be able to sit on it, and typing there landed in the
15484 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
15485 // soft break, so the text visibly snapped back up.
15486 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
15487 d.caret = 1; // the end of "A"
15488 d.move_right(false);
15489 assert_eq!(d.caret, 3, "Right stopped in the gap");
15490 d.insert("x");
15491 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
15492 }
15493
15494 #[test]
15495 fn down_from_a_paragraph_lands_on_the_next_one() {
15496 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
15497 d.caret = 0;
15498 d.move_down(false);
15499 assert_eq!(d.caret, 3, "Down stopped in the gap");
15500 }
15501
15502 #[test]
15503 fn clicking_the_gap_lands_on_real_text() {
15504 // A click can still *reach* the gap — it's drawn, so it's clickable.
15505 // It has to resolve to somewhere the caret can be.
15506 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
15507 d.click(1, 0, false); // the gap row
15508 assert!(
15509 d.caret == 1 || d.caret == 3,
15510 "click left the caret in the gap at {}",
15511 d.caret
15512 );
15513 d.insert("x");
15514 // Either edge of the boundary is a fair place to land; inside it isn't.
15515 assert!(
15516 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
15517 "click in the gap typed into the boundary: {:?}",
15518 d.source
15519 );
15520 }
15521
15522 #[test]
15523 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
15524 // Enter inserts a paragraph break, which leaves a blank line spare on
15525 // either side of a new one. That middle line is a real empty paragraph:
15526 // the caret lands there, and typing makes a paragraph rather than
15527 // extending a neighbour.
15528 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
15529 d.caret = 1;
15530 d.newline();
15531 assert_eq!(d.source, "A\n\n\n\nB\n");
15532 d.build_visual(80);
15533 let (row, _) = d.caret_pos();
15534 assert!(
15535 d.vmap.row_is_navigable(row),
15536 "the caret landed on a gap row"
15537 );
15538 d.insert("x");
15539 assert_eq!(
15540 d.source, "A\n\nx\n\nB\n",
15541 "the new paragraph merged into a neighbour"
15542 );
15543 }
15544
15545 #[test]
15546 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
15547 let mut d = wysiwyg_doc("gap_eof", "A\n");
15548 d.caret = 1;
15549 d.newline();
15550 d.build_visual(80);
15551 let (row, _) = d.caret_pos();
15552 assert!(
15553 d.vmap.row_is_navigable(row),
15554 "the caret landed on a gap row"
15555 );
15556 d.insert("x");
15557 assert!(
15558 d.source.starts_with("A\n\n") && d.source.contains('x'),
15559 "typing at the end merged into A: {:?}",
15560 d.source
15561 );
15562 }
15563
15564 // ── click_past_end ───────────────────────────────────────────────────────
15565
15566 /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
15567 /// rendered as `|`.
15568 fn past_end(name: &str, body: &str) -> (Doc, String) {
15569 let mut d = wysiwyg_doc(name, body);
15570 d.click_past_end();
15571 let out = render_caret(&d);
15572 (d, out)
15573 }
15574
15575 #[test]
15576 fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
15577 let (mut d, out) = past_end("pe_para", "A\n");
15578 assert_eq!(out, "A\n\n|");
15579 d.build_visual(80);
15580 let (row, _) = d.caret_pos();
15581 assert!(
15582 d.vmap.row_is_navigable(row),
15583 "the caret landed on a gap row"
15584 );
15585 d.insert("x");
15586 assert_eq!(d.source, "A\n\nx", "typing merged into A");
15587 }
15588
15589 #[test]
15590 fn click_past_end_writes_both_newlines_when_the_file_has_none() {
15591 assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
15592 }
15593
15594 #[test]
15595 fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
15596 let (d, out) = past_end("pe_there", "A\n\n");
15597 assert_eq!(out, "A\n\n|");
15598 assert!(!d.dirty, "a click wrote to a document it did not need to");
15599 assert!(
15600 !d.can_undo(),
15601 "a click that changed nothing left an undo step"
15602 );
15603 }
15604
15605 #[test]
15606 fn click_past_end_leaves_a_closed_fence() {
15607 let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
15608 assert_eq!(out, "```\ncode\n```\n\n|");
15609 d.build_visual(80);
15610 let (row, _) = d.caret_pos();
15611 assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
15612 d.insert("x");
15613 assert_eq!(d.source, "```\ncode\n```\n\nx");
15614 }
15615
15616 #[test]
15617 fn click_past_end_closes_an_unclosed_fence_first() {
15618 assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
15619 assert_eq!(
15620 past_end("pe_open2", "````\ncode").1,
15621 "````\ncode\n````\n\n|"
15622 );
15623 assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
15624 assert_eq!(
15625 past_end("pe_quoted", "> ```\n> code\n").1,
15626 "> ```\n> code\n> ```\n\n|"
15627 );
15628 }
15629
15630 #[test]
15631 fn click_past_end_does_not_close_an_indented_block() {
15632 assert_eq!(past_end("pe_indent", " code\n").1, " code\n\n|");
15633 }
15634
15635 #[test]
15636 fn click_past_end_under_a_list_and_a_table_leaves_them() {
15637 let (mut d, out) = past_end("pe_list", "- a\n- b\n");
15638 assert_eq!(out, "- a\n- b\n\n|");
15639 d.insert("x");
15640 assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
15641 assert_eq!(
15642 past_end("pe_table", "| a |\n|---|\n| b |\n").1,
15643 "| a |\n|---|\n| b |\n\n|"
15644 );
15645 }
15646
15647 #[test]
15648 fn click_past_end_on_an_empty_document_writes_nothing() {
15649 let (d, out) = past_end("pe_empty", "");
15650 assert_eq!(out, "|");
15651 assert!(!d.dirty);
15652 }
15653
15654 #[test]
15655 fn click_past_end_is_one_undo_step() {
15656 let (mut d, _) = past_end("pe_undo", "A\n");
15657 d.undo();
15658 assert_eq!(d.source, "A\n");
15659 }
15660
15661 #[test]
15662 fn click_past_end_in_the_source_view_only_moves_the_caret() {
15663 let mut d = doc_with("pe_source", "A\n");
15664 d.click_past_end();
15665 assert_eq!(render_caret(&d), "A\n|");
15666 assert!(!d.dirty);
15667 }
15668
15669 #[test]
15670 fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
15671 let mut d = wysiwyg_doc("pe_ro", "A\n");
15672 d.set_read_only(true);
15673 d.click_past_end();
15674 assert_eq!(render_caret(&d), "A\n|");
15675 }
15676
15677 // ── toggle_code_block ────────────────────────────────────────────────────
15678
15679 #[test]
15680 fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
15681 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
15682 assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
15683 assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
15684 assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
15685 assert_eq!(
15686 g("cb_wrap", "aaa\nb|bb\nccc\n"),
15687 "```\naaa\nb|bb\nccc\n```\n"
15688 );
15689 assert_eq!(
15690 g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
15691 "aaa\nb|bb\nccc\n"
15692 );
15693 // An indented block dedents, and the lines stay one-to-one.
15694 assert_eq!(g("cb_indent", " co|de\n"), "co|de\n");
15695 // A quote's marker is kept on every line, the fence's included.
15696 assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
15697 }
15698
15699 #[test]
15700 fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
15701 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
15702 assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
15703 assert_eq!(
15704 g("cb_blank_mid", "A\n\n|\n\nB\n"),
15705 "A\n\n```\n|\n```\n\nB\n"
15706 );
15707 // Tight against a neighbour, a blank line goes in on that side.
15708 assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
15709 // Inside a quote, on a quoted blank line.
15710 assert_eq!(
15711 g("cb_blank_quote", "> A\n>\n> |\n"),
15712 "> A\n>\n> ```\n> |\n> ```\n"
15713 );
15714 }
15715
15716 #[test]
15717 fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
15718 let mut d = wysiwyg_doc("cb_type", "A\n\n");
15719 d.click_past_end();
15720 d.toggle_code_block();
15721 assert!(
15722 d.caret_in_code_block(),
15723 "the caret is not in the block it opened"
15724 );
15725 d.insert("let x = 1;");
15726 d.newline();
15727 d.insert("x");
15728 assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
15729 d.build_visual(80);
15730 let (row, _) = d.caret_pos();
15731 assert!(d.vmap.rows[row].code, "typed text is not on a code row");
15732 // And back out: the button reverses what it did, text kept.
15733 d.toggle_code_block();
15734 assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
15735 assert!(!d.caret_in_code_block());
15736 }
15737
15738 #[test]
15739 fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
15740 let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
15741 d.anchor = Some(1);
15742 d.caret = 7;
15743 d.toggle_code_block();
15744 assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
15745 assert!(d.selection().is_some(), "the block came back unselected");
15746 d.toggle_code_block();
15747 assert_eq!(
15748 d.source, "one\n\ntwo\n\nthree\n",
15749 "a second press did not reverse the first"
15750 );
15751 }
15752
15753 #[test]
15754 fn toggle_code_block_inside_a_list_item_is_refused_and_reported() {
15755 let mut d = wysiwyg_doc("cb_list", "- it|em\n".replace('|', "").as_str());
15756 d.caret = 3;
15757 d.toggle_code_block();
15758 assert_eq!(d.source, "- item\n");
15759 assert!(
15760 d.status
15761 .as_deref()
15762 .is_some_and(|s| s.starts_with("code block:"))
15763 );
15764 }
15765
15766 #[test]
15767 fn caret_in_code_block_reads_fenced_and_indented_blocks() {
15768 let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n more\n");
15769 d.caret = 2;
15770 assert!(!d.caret_in_code_block());
15771 d.caret = 11;
15772 assert!(d.caret_in_code_block());
15773 d.caret = 25;
15774 assert!(d.caret_in_code_block(), "an indented block is a code block");
15775 }
15776
15777 #[test]
15778 fn toggle_code_block_is_one_undo_step() {
15779 let mut d = wysiwyg_doc("cb_undo", "hello\n");
15780 d.caret = 2;
15781 d.toggle_code_block();
15782 d.undo();
15783 assert_eq!(render_caret(&d), "he|llo\n");
15784 }
15785
15786 #[test]
15787 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
15788 // A paragraph broken over two source lines is one paragraph. Selecting
15789 // it must not stop at the newline inside it — that newline is markup the
15790 // rich-text view exists to hide.
15791 let src = "one two\nthree four\n\nnext\n";
15792 let mut d = wysiwyg_doc("triple_para", src);
15793 d.select_block_at(2);
15794 assert_eq!(
15795 d.selected_text(),
15796 Some("one two\nthree four"),
15797 "stopped at the soft break"
15798 );
15799 }
15800
15801 #[test]
15802 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
15803 // The reader scrolls down past the caret's row. Nothing moved the
15804 // caret, so the view must stay where it was put — the old code revealed
15805 // the caret every frame, which dragged the view straight back and made
15806 // the document unscrollable past the caret.
15807 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
15808 d.caret = 0;
15809 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
15810 d.scroll = 4; // the wheel
15811 d.follow_caret(0, 3, 9);
15812 assert_eq!(
15813 d.scroll, 4,
15814 "the wheel was overruled by a caret that never moved"
15815 );
15816 }
15817
15818 #[test]
15819 fn moving_the_caret_brings_the_view_back_to_it() {
15820 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
15821 d.caret = 0;
15822 d.follow_caret(0, 3, 9);
15823 d.scroll = 6; // scrolled away
15824 d.move_right(false); // ...and now the caret moves
15825 let (row, _) = d.caret_pos();
15826 d.follow_caret(row, 3, 9);
15827 assert!(
15828 d.scroll <= row && row < d.scroll + 3,
15829 "caret row {row} off screen at scroll {}",
15830 d.scroll
15831 );
15832 }
15833
15834 #[test]
15835 fn scrolling_stops_at_the_last_row() {
15836 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
15837 d.caret = 0;
15838 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
15839 d.scroll = 999; // the wheel, spun hard
15840 d.follow_caret(0, 3, 3);
15841 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
15842 }
15843
15844 #[test]
15845 fn every_cell_of_a_wide_table_is_reachable() {
15846 // A table whose cells are far wider than the surface: the columns are
15847 // cut to fit and the text wraps inside them, so no cell hangs off the
15848 // right edge where the caret can never go.
15849 let src = "| Ingredient | Notes |\n|---|---|\n\
15850 | flour milled coarse | sift it twice before folding it in |\n";
15851 let mut d = wysiwyg_doc("wide_table_walk", src);
15852 d.build_visual(30);
15853 d.caret = 0;
15854 let seen = walk_right(&mut d);
15855 for word in ["Ingredient", "Notes", "coarse", "folding"] {
15856 let at = src.find(word).unwrap();
15857 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
15858 }
15859 }
15860
15861 // ── view parity ──────────────────────────────────────────────────────────
15862 // `doc_with` pins the source view, so everything above tests a view users
15863 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
15864 // deletion golden cases through *both*, plus the WYSIWYG cases the two
15865 // can't share: where the source carries markup the rendered text is a
15866 // different string, and the views agreeing would itself be the bug.
15867
15868 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
15869
15870 /// Run `action` in both views on one `|`-marked fixture and assert they
15871 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
15872 /// source verbatim, so the two views are looking at the same text and any
15873 /// disagreement is one of them having lost the plot.
15874 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
15875 let (src, caret) = parse_caret(marked);
15876 let run = |view: View, tag: &str| {
15877 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
15878 d.caret = caret;
15879 action(&mut d);
15880 render_caret(&d)
15881 };
15882 let source = run(VIEWS[0].0, VIEWS[0].1);
15883 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
15884 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
15885 source
15886 }
15887
15888 #[test]
15889 fn word_motion_agrees_across_the_views_on_plain_prose() {
15890 let g = both_views;
15891 assert_eq!(
15892 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
15893 "hello |world"
15894 );
15895 assert_eq!(
15896 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
15897 "|hello world"
15898 );
15899 assert_eq!(
15900 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
15901 "hello| world"
15902 );
15903 assert_eq!(
15904 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
15905 "hello world|"
15906 );
15907 assert_eq!(
15908 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
15909 "foo|.bar"
15910 );
15911 assert_eq!(
15912 g("par_ext", "hello |world", |d| d.move_word_right(true)),
15913 "hello [world|]"
15914 );
15915 }
15916
15917 #[test]
15918 fn word_deletion_agrees_across_the_views_on_plain_prose() {
15919 let g = both_views;
15920 assert_eq!(
15921 g("par_db", "hello world|", |d| d.delete_word_back()),
15922 "hello |"
15923 );
15924 assert_eq!(
15925 g("par_df", "hello |world", |d| d.delete_word_forward()),
15926 "hello |"
15927 );
15928 assert_eq!(
15929 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
15930 "|bar baz"
15931 );
15932 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
15933 }
15934
15935 #[test]
15936 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
15937 let g = both_views;
15938 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
15939 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
15940 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
15941 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
15942 }
15943
15944 #[test]
15945 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
15946 // The reproduction: the stop table was built one stop per `char`, so
15947 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
15948 // source view, which steps by grapheme, can't reach and backspace can't
15949 // survive. The two views must land on the same offset.
15950 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
15951 for (view, tag) in VIEWS {
15952 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
15953 d.caret = 1;
15954 d.move_right(false);
15955 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
15956
15957 // ...and the edit that used to sever a joiner off the front of it.
15958 d.backspace();
15959 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
15960 assert_eq!(d.caret, 1);
15961 }
15962 }
15963
15964 #[test]
15965 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
15966 for (view, tag) in VIEWS {
15967 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
15968 d.caret = 0;
15969 d.move_right(false);
15970 assert_eq!(
15971 d.caret,
15972 "e\u{0301}".len(),
15973 "{tag} stopped on the combining mark"
15974 );
15975 }
15976 }
15977
15978 #[test]
15979 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
15980 // The general form: whatever route the caret takes through a document
15981 // full of clusters, it never lands between the codepoints of one — so no
15982 // motion-then-backspace sequence can leave a dangling joiner behind.
15983 use unicode_segmentation::UnicodeSegmentation;
15984
15985 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
15986 let mut d = wysiwyg_doc("cluster_walk", src);
15987 d.caret = 0;
15988 let boundaries: Vec<usize> = src
15989 .grapheme_indices(true)
15990 .map(|(i, _)| i)
15991 .chain(std::iter::once(src.len()))
15992 .collect();
15993 for off in walk_right(&mut d) {
15994 assert!(
15995 boundaries.contains(&off),
15996 "Right stopped at {off}, inside a grapheme cluster"
15997 );
15998 }
15999 }
16000
16001 #[test]
16002 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
16003 // The reproduction: ⌥→ from inside the opening `**` computed its
16004 // boundary over the raw source and landed on byte 8 — inside the
16005 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
16006 // "bold". The caret drew past the bold word and sat inside it.
16007 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
16008 d.caret = 2;
16009 d.move_word_right(false);
16010 assert!(
16011 d.vmap.is_stop(d.caret),
16012 "landed at {}, not a caret stop",
16013 d.caret
16014 );
16015 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
16016 // The rendered row is "a bold c": column 6 is the space just past "bold",
16017 // and now the caret is really there rather than only drawn there.
16018 assert_eq!(d.caret_pos(), (0, 6));
16019
16020 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
16021 d.move_word_left(false);
16022 assert_eq!(d.caret, 4);
16023 assert_eq!(d.caret_pos(), (0, 2));
16024 }
16025
16026 #[test]
16027 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
16028 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
16029 // inside the closing `**`, and left "a ** c\n" — delimiters with no
16030 // opener. Glyph space covers the word alone, which would leave
16031 // "a **** c": markup wrapped around nothing. The word and the styling
16032 // that was only ever the word's go together.
16033 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
16034 d.caret = 10;
16035 d.delete_word_back();
16036 assert_eq!(d.source, "a c\n");
16037 assert_eq!(d.caret, 2);
16038
16039 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
16040 d.caret = 4; // the "b"
16041 d.delete_word_forward();
16042 assert_eq!(d.source, "a c\n");
16043 }
16044
16045 #[test]
16046 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
16047 let src = "a ***bold*** c\n";
16048 let mut d = wysiwyg_doc("wys_word_del_nest", src);
16049 d.caret = src.find(" c").unwrap();
16050 d.delete_word_back();
16051 assert_eq!(
16052 d.source, "a c\n",
16053 "the emph inside the strong empties it too"
16054 );
16055
16056 let src = "a `code` c\n";
16057 let mut d = wysiwyg_doc("wys_word_del_code", src);
16058 d.caret = src.find(" c").unwrap();
16059 d.delete_word_back();
16060 assert_eq!(d.source, "a c\n");
16061 }
16062
16063 #[test]
16064 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
16065 // Only an *emptied* node goes. Take one word of two and the `**` still
16066 // has a job to do — over the word that's left, with the space the delete
16067 // pushed against the opening delimiter moved out in front of it, or the
16068 // run would be no run at all (`** words**` is literal asterisks — see
16069 // the mark-edge rule on `splice`).
16070 let src = "a **two words** c\n";
16071 let mut d = wysiwyg_doc("wys_word_del_partial", src);
16072 d.caret = src.find(" words").unwrap();
16073 d.delete_word_back();
16074 assert_eq!(d.source, "a **words** c\n");
16075 }
16076
16077 #[test]
16078 fn source_view_word_motion_still_walks_the_markup() {
16079 // The other half of the decision: in the source view the `**` are
16080 // characters like any other — they're on the screen, so word motion has
16081 // to stop at them and a word-delete has to leave them behind. Only
16082 // WYSIWYG hides them, so only WYSIWYG steps over them.
16083 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
16084 assert_eq!(
16085 g("src_word_motion", "a |**bold** c\n", |d| d
16086 .move_word_right(false)),
16087 "a **bold|** c\n"
16088 );
16089 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
16090 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
16091 assert_eq!(
16092 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
16093 "a **| c\n"
16094 );
16095 }
16096
16097 #[test]
16098 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
16099 // The single invariant both bugs violated: the caret draws and edits at
16100 // the same place only when it's on a stop. `debug_assert_on_a_stop`
16101 // makes the same claim in-place; this pins it from the outside, over a
16102 // document with every kind of thing the map has to be careful about.
16103 // At two widths: the wide one every other test builds at, where no
16104 // fixture folds, and one narrow enough that they all do. A soft wrap is
16105 // where an offset stops being on exactly one row, and testing only the
16106 // width that never wraps is how the caret came to be pinned at the first
16107 // one Down reached.
16108 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
16109 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
16110 // A table of named operations, which is what it looks like.
16111 #[allow(clippy::type_complexity)]
16112 let motions: [(&str, fn(&mut Doc)); 8] = [
16113 ("right", |d| d.move_right(false)),
16114 ("left", |d| d.move_left(false)),
16115 ("word_right", |d| d.move_word_right(false)),
16116 ("word_left", |d| d.move_word_left(false)),
16117 ("down", |d| d.move_down(false)),
16118 ("up", |d| d.move_up(false)),
16119 ("home", |d| d.move_home(false)),
16120 ("end", |d| d.move_end(false)),
16121 ];
16122 for width in [80, 12] {
16123 let mut d = wysiwyg_doc("stop_invariant", src);
16124 d.build_visual(width);
16125 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16126 assert!(stops.len() > 20, "fixture should have plenty of stops");
16127 for start in stops {
16128 for (name, motion) in &motions {
16129 d.caret = start;
16130 d.anchor = None;
16131 motion(&mut d);
16132 assert!(
16133 d.vmap.is_stop(d.caret),
16134 "{name} from {start} at width {width} landed at {} — not a caret stop",
16135 d.caret
16136 );
16137 }
16138 }
16139 }
16140 }
16141
16142 #[test]
16143 fn no_wysiwyg_motion_is_a_dead_end() {
16144 // Down held to the bottom of a document reaches the bottom, and Up held
16145 // to the top reaches the top — from anywhere, at a width that wraps. The
16146 // invariant above says a motion lands somewhere legal; this one says it
16147 // gets somewhere at all, which is what a caret pinned at a wrap boundary
16148 // was quietly failing to do while every assertion around it held.
16149 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
16150 - item one two three four five\n\nlast\n";
16151 for width in [80, 12] {
16152 let mut d = wysiwyg_doc("no_dead_end", src);
16153 d.build_visual(width);
16154 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16155 let (first, last) = (stops[0], stops[stops.len() - 1]);
16156 for &start in &stops {
16157 for (name, motion, want) in [
16158 (
16159 "down",
16160 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
16161 last,
16162 ),
16163 ("up", |d: &mut Doc| d.move_up(false), first),
16164 ] {
16165 d.caret = start;
16166 d.anchor = None;
16167 d.goal_col = None;
16168 // Every row, plus the presses the edges take, plus slack.
16169 for _ in 0..d.vmap.num_rows() + 4 {
16170 motion(&mut d);
16171 }
16172 assert_eq!(
16173 d.caret, want,
16174 "{name} held from {start} at width {width} never arrived"
16175 );
16176 }
16177 }
16178 }
16179 }
16180 // ── display columns ──────────────────────────────────────────────────────
16181 // A `col` is a terminal cell, not a character. The two are the same number
16182 // for the ASCII the fixtures above are written in, which is how they came
16183 // apart in the first place: `你` is one character drawn in two cells, so a
16184 // column counted in characters names a cell the text isn't in — one earlier
16185 // for every wide character to its left.
16186
16187 #[test]
16188 fn a_wide_character_is_two_columns_wide() {
16189 // The reproduction: `你` is one char and two cells, so the caret just
16190 // past it drew at column 1 — inside the character it had already left.
16191 for (view, tag) in VIEWS {
16192 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
16193 d.caret = "你".len();
16194 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
16195 d.caret = "你好".len();
16196 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
16197 }
16198 }
16199
16200 #[test]
16201 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
16202 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
16203 // two-cell — measuring six cells one at a time, but the character they
16204 // spell is drawn in two. Width belongs to the cluster, not the glyph,
16205 // and the frontends measure it the same way.
16206 let family = "👨👩👧";
16207 for (view, tag) in VIEWS {
16208 let src = format!("a{family}b\n");
16209 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
16210 d.caret = 1 + family.len();
16211 assert_eq!(
16212 d.caret_pos(),
16213 (0, 3),
16214 "{tag}: 'a' is one cell, the family two"
16215 );
16216 }
16217 }
16218
16219 #[test]
16220 fn both_cells_of_a_wide_character_mean_the_character() {
16221 // Clicking the far half of `好` is still clicking `好`: half a character
16222 // is not a place the caret can be, so it comes to rest at the
16223 // character's start — the column it would have been drawn at anyway.
16224 for (view, tag) in VIEWS {
16225 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
16226 for col in [2, 3] {
16227 d.caret = 0;
16228 d.click(0, col, false);
16229 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
16230 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
16231 }
16232 // Past the last cell is the line's end, as it is for ASCII.
16233 d.click(0, 9, false);
16234 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
16235 }
16236 }
16237
16238 #[test]
16239 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
16240 // The mapping is only a mapping if it inverts: the cell the caret is
16241 // drawn in has to be the cell that brings it back to the same offset.
16242 // Over a fixture where a character may be one cell or two, and one
16243 // codepoint or five.
16244 use unicode_segmentation::UnicodeSegmentation;
16245
16246 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
16247
16248 let mut d = doc_in(View::Source, "roundtrip_source", src);
16249 // Every offset the source view's caret can occupy: it steps by grapheme
16250 // cluster, so those are its boundaries.
16251 for (off, _) in src
16252 .grapheme_indices(true)
16253 .chain(std::iter::once((src.len(), "")))
16254 {
16255 d.caret = off;
16256 let (row, col) = d.caret_pos();
16257 d.click(row, col, false);
16258 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
16259 }
16260
16261 // And in WYSIWYG, where the offsets the caret can occupy are the map's
16262 // stops rather than every boundary.
16263 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
16264 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16265 assert!(stops.len() > 20, "fixture should have plenty of stops");
16266 for off in stops {
16267 d.caret = off;
16268 let (row, col) = d.caret_pos();
16269 d.click(row, col, false);
16270 assert_eq!(
16271 d.caret, off,
16272 "wysiwyg: {off} → ({row}, {col}) → {}",
16273 d.caret
16274 );
16275 }
16276 }
16277
16278 #[test]
16279 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
16280 // Down from under `世` lands under the glyph in that cell, not two
16281 // characters further along the line. The goal is a column, so a line of
16282 // wide characters and a line of ASCII line up the way they're drawn.
16283 //
16284 // The gap differs by view: a bare newline inside a paragraph is a soft
16285 // break, which WYSIWYG draws as a space on a single row. The views share
16286 // a grid only where the source's lines are the renderer's rows too.
16287 for (view, tag) in VIEWS {
16288 let gap = if view == View::Source { "\n" } else { "\n\n" };
16289 let src = format!("你好世{gap}abcdef\n");
16290 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
16291 d.caret = "你好".len();
16292 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
16293 d.move_down(false);
16294 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
16295 assert!(
16296 d.source[d.caret..].starts_with('e'),
16297 "{tag}: landed on the wrong glyph"
16298 );
16299 }
16300 }
16301
16302 #[test]
16303 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
16304 // Down from column 3 onto `你好`, whose characters start at columns 0
16305 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
16306 // between the cells of one character, so the caret rests on it — and on
16307 // its start, which is the only offset there that is a caret stop.
16308 for (view, tag) in VIEWS {
16309 let gap = if view == View::Source { "\n" } else { "\n\n" };
16310 let src = format!("abcdef{gap}你好\n");
16311 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
16312 let line = src.find('你').unwrap();
16313 d.caret = 3;
16314 d.move_down(false);
16315 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
16316 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
16317 }
16318 }
16319
16320 #[test]
16321 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
16322 // The column the cell's text is laid out in is measured in cells, so the
16323 // caret walking that text has to be too — the two agreeing is the whole
16324 // point of the grid staying square.
16325 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
16326 let at = d.source.find("你").unwrap();
16327 d.caret = at;
16328 let (row, col) = d.caret_pos();
16329 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
16330 // cells further along.
16331 assert_eq!(col, 2, "the cell's first character");
16332 d.move_right(false);
16333 assert_eq!(
16334 d.caret_pos(),
16335 (row, 4),
16336 "`好` is drawn past `你`'s two cells"
16337 );
16338 assert_eq!(d.caret, at + "你".len());
16339 }
16340
16341 // ── active inline marks ───────────────────────────────────────────────────
16342
16343 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
16344 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
16345 let (src, caret) = parse_caret(marked);
16346 let mut d = doc_in(view, name, &src);
16347 d.caret = caret;
16348 d.active_inline_marks().iter().collect()
16349 }
16350
16351 /// The marks over the selection `[start, end)`.
16352 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
16353 let mut d = doc_in(view, name, src);
16354 d.anchor = Some(start);
16355 d.caret = end;
16356 d.active_inline_marks().iter().collect()
16357 }
16358
16359 #[test]
16360 fn a_caret_in_a_mark_reports_it() {
16361 for (view, tag) in VIEWS {
16362 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
16363 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
16364 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
16365 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
16366 // Plain text under no mark lights nothing — the toolbar's resting state.
16367 assert_eq!(m("a| **bold** b"), [], "{tag}");
16368 assert!(m("plain t|ext").is_empty(), "{tag}");
16369 }
16370 }
16371
16372 #[test]
16373 fn nested_marks_all_report() {
16374 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
16375 // the ancestor chain is a chain, and every mark on it is in force.
16376 for (view, tag) in VIEWS {
16377 assert_eq!(
16378 marks(
16379 view,
16380 &format!("marks_nested_{tag}"),
16381 "**bold and *bo|th*** end"
16382 ),
16383 [InlineKind::Strong, InlineKind::Emph],
16384 "{tag}"
16385 );
16386 }
16387 }
16388
16389 #[test]
16390 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
16391 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
16392 // the first byte of its text and the byte after its last — both inside
16393 // the mark's span, both places typing lands inside the bold. The offset
16394 // past the closing delimiter is the next text, and reports nothing.
16395 let src = "a **bold** b";
16396 let inner_start = src.find("bold").unwrap(); // 4
16397 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
16398 for (view, tag) in VIEWS {
16399 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
16400 for off in [2, 3, inner_start, inner_end, 9] {
16401 d.caret = off;
16402 assert!(
16403 d.active_inline_marks().contains(InlineKind::Strong),
16404 "{tag}: offset {off} is inside the strong span"
16405 );
16406 }
16407 for off in [0, 1, 10, 11, 12] {
16408 d.caret = off;
16409 assert!(
16410 !d.active_inline_marks().contains(InlineKind::Strong),
16411 "{tag}: offset {off} is outside the strong run"
16412 );
16413 }
16414 }
16415 }
16416
16417 #[test]
16418 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
16419 // Regression: twig resolves an offset that is one node's end and the
16420 // next one's start to the node that *starts* there, so `**bold**|\n`
16421 // isn't bold. With nothing following there's no tie to break and the
16422 // chain still ended at the mark, which made a trailing `\n` — not the
16423 // text — decide whether the caret after a bold word reported bold. It's
16424 // the offset past the mark either way, and typing there is plain either
16425 // way. A blank document typed into is exactly this shape.
16426 for (view, tag) in VIEWS {
16427 let m = |name: String, marked| marks(view, &name, marked);
16428 assert_eq!(
16429 m(format!("marks_eob_{tag}"), "**bold**|"),
16430 [],
16431 "{tag}: no trailing newline"
16432 );
16433 assert_eq!(
16434 m(format!("marks_eol_{tag}"), "**bold**|\n"),
16435 [],
16436 "{tag}: with one"
16437 );
16438 // And the last offset that *is* in the mark still is.
16439 assert_eq!(
16440 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
16441 [InlineKind::Strong],
16442 "{tag}"
16443 );
16444 }
16445 }
16446
16447 #[test]
16448 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
16449 let src = "a **bold** b";
16450 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
16451 for (view, tag) in VIEWS {
16452 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
16453 // The whole bold word, and a slice of it.
16454 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
16455 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
16456 // Ending exactly at the closing delimiter's start is still all-bold:
16457 // an exclusive end sits *past* the last selected character, so the
16458 // question is asked of the character, not the boundary.
16459 assert_eq!(
16460 m(b, d_ + 2),
16461 [InlineKind::Strong],
16462 "{tag}: through the close"
16463 );
16464 // Half in, half out: Bold lit here would claim a press turns it off.
16465 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
16466 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
16467 }
16468 }
16469
16470 #[test]
16471 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
16472 // Both ends are bold, but the space between them isn't — two runs are two
16473 // nodes, which is exactly what the node id catches and a kind-only
16474 // comparison would not.
16475 let src = "**one** **two**";
16476 for (view, tag) in VIEWS {
16477 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
16478 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
16479 }
16480 }
16481
16482 #[test]
16483 fn marks_read_the_document_as_it_is_edited() {
16484 // The point of asking twig every frame instead of caching: the answer has
16485 // to follow the toggle that changed it.
16486 let mut d = wysiwyg_doc("marks_live", "one two\n");
16487 d.anchor = Some(0);
16488 d.caret = 3;
16489 assert!(d.active_inline_marks().is_empty(), "plain to start");
16490 d.toggle(InlineKind::Strong);
16491 assert_eq!(d.source, "**one** two\n");
16492 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
16493 assert!(d.active_inline_marks().contains(InlineKind::Strong));
16494 d.toggle(InlineKind::Strong);
16495 assert!(d.active_inline_marks().is_empty(), "and off again");
16496 }
16497
16498 #[test]
16499 fn a_link_is_not_an_inline_mark() {
16500 // `link`/`str` are inline nodes, but nothing on the inline toolbar
16501 // toggles them — a set with a "link mark" in it would have no button.
16502 for (view, tag) in VIEWS {
16503 assert_eq!(
16504 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
16505 [],
16506 "{tag}"
16507 );
16508 }
16509 }
16510
16511 // ── blank documents ───────────────────────────────────────────────────────
16512
16513 #[test]
16514 fn a_blank_document_is_untitled_empty_and_markdown() {
16515 let mut d = Doc::blank().unwrap();
16516 assert!(d.is_untitled());
16517 assert_eq!(d.path, PathBuf::new());
16518 assert_eq!(
16519 d.file_name(),
16520 "untitled",
16521 "the header has to show something"
16522 );
16523 assert_eq!(d.format_name(), "markdown");
16524 assert_eq!(d.source, "");
16525 assert!(!d.dirty, "nothing typed yet is nothing to lose");
16526 assert_eq!(d.disk_state(), DiskState::Untitled);
16527 // And it's a document you can be in: the default view renders it.
16528 d.build_visual(80);
16529 assert_eq!(d.caret, 0);
16530 }
16531
16532 #[test]
16533 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
16534 let mut d = Doc::blank().unwrap();
16535 d.insert("hello");
16536 assert!(d.dirty);
16537 d.save();
16538 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
16539 assert!(d.dirty, "it must not come away believing it saved");
16540 assert!(d.is_untitled(), "and it still has no file");
16541 }
16542
16543 #[test]
16544 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
16545 let p = temp_path("blank_save_as");
16546 let mut d = Doc::blank().unwrap();
16547 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
16548 // be kept literal (`\#`); this test is about save-as, not escaping (which
16549 // has its own test), so it types nothing that escaping would touch.
16550 d.insert("hi");
16551 d.save_as(p.clone());
16552 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
16553 assert!(!d.is_untitled());
16554 assert!(!d.dirty);
16555 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
16556 assert_eq!(
16557 d.disk_state(),
16558 DiskState::Unchanged,
16559 "the watermark is stamped"
16560 );
16561 // And ⌘S is a plain save from here on.
16562 d.insert("!");
16563 d.save();
16564 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
16565 let _ = std::fs::remove_file(&p);
16566 }
16567
16568 // ── a file that isn't there yet ───────────────────────────────────────────
16569
16570 /// A unique path in the temp dir with the given extension, guaranteed not to
16571 /// exist — what `leaf notes.md` is handed when the file has never been made.
16572 fn missing_path(name: &str, ext: &str) -> PathBuf {
16573 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16574 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
16575 let mut p = std::env::temp_dir();
16576 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
16577 let _ = std::fs::remove_file(&p);
16578 p
16579 }
16580
16581 #[test]
16582 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
16583 let p = missing_path("named", "md");
16584 let mut d = Doc::open_or_create(p.clone()).unwrap();
16585
16586 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
16587 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
16588 assert!(
16589 !d.is_untitled(),
16590 "it has the name the user asked for — ^S must not detour to Save As"
16591 );
16592 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
16593 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
16594 assert!(!p.exists(), "and opening it wrote nothing");
16595 // And it's a document you can be in.
16596 d.build_visual(80);
16597 assert_eq!(d.caret, 0);
16598 }
16599
16600 #[test]
16601 fn a_new_file_is_created_by_its_first_save() {
16602 let p = missing_path("first_save", "md");
16603 let mut d = Doc::open_or_create(p.clone()).unwrap();
16604 d.insert("hello\n");
16605 assert!(d.dirty);
16606 d.save();
16607
16608 assert_eq!(
16609 std::fs::read_to_string(&p).unwrap(),
16610 "hello\n",
16611 "a plain ^S wrote it — no Save As, no name to invent"
16612 );
16613 assert!(!d.dirty);
16614 assert_eq!(d.disk_state(), DiskState::Unchanged);
16615 let _ = std::fs::remove_file(&p);
16616 }
16617
16618 #[test]
16619 fn a_new_file_takes_its_format_from_the_extension() {
16620 // The one thing `blank` can't do: with no name it has to assume Markdown,
16621 // and typing djot into a Markdown parse is the wrong buffer.
16622 let dj = missing_path("format", "dj");
16623 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
16624 let md = missing_path("format", "md");
16625 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
16626 }
16627
16628 #[test]
16629 fn a_new_file_reports_itself_missing_until_it_is_saved() {
16630 // Not `Untitled` — that's the answer for a document with no path, and it
16631 // would tell a frontend there is nothing a save could collide with. Here
16632 // there is a path, and the file simply isn't at it yet.
16633 let p = missing_path("disk_state", "md");
16634 let mut d = Doc::open_or_create(p.clone()).unwrap();
16635 assert_eq!(d.disk_state(), DiskState::Missing);
16636
16637 // Somebody else creates it while the buffer is open: that's an overwrite
16638 // the frontend has to be able to prompt about, exactly as for an opened
16639 // file. Their bytes, not ours, so `Changed`.
16640 std::fs::write(&p, "theirs\n").unwrap();
16641 assert_eq!(d.disk_state(), DiskState::Changed);
16642
16643 // Saving makes the file ours and re-stamps the watermark.
16644 d.insert("ours\n");
16645 d.save();
16646 assert_eq!(d.disk_state(), DiskState::Unchanged);
16647 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
16648 let _ = std::fs::remove_file(&p);
16649 }
16650
16651 #[test]
16652 fn open_or_create_still_opens_a_file_that_is_there() {
16653 let d = doc_with("open_or_create_existing", "body\n");
16654 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
16655 assert_eq!(reopened.source, "body\n");
16656 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
16657 }
16658
16659 #[test]
16660 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
16661 // A mistyped flag or a stray argument must not become a buffer promising
16662 // to save somewhere — the same refusal `open` gives a real file.
16663 let mut p = std::env::temp_dir();
16664 p.push("leaf_test_new_bad_ext.wat");
16665 assert!(Doc::open_or_create(p).is_err());
16666 let mut none = std::env::temp_dir();
16667 none.push("leaf_test_new_no_ext");
16668 assert!(Doc::open_or_create(none).is_err());
16669 }
16670
16671 #[test]
16672 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
16673 // Opening reads nothing, so there is nothing to fail on yet; the write is
16674 // where it fails, and it says so rather than claiming a save.
16675 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
16676 let mut d = Doc::open_or_create(p).unwrap();
16677 d.insert("x");
16678 d.save();
16679 assert!(
16680 d.status.as_deref().unwrap().starts_with("save failed:"),
16681 "got {:?}",
16682 d.status
16683 );
16684 assert!(d.dirty, "it must not come away believing it saved");
16685 }
16686
16687 // ── save as ───────────────────────────────────────────────────────────────
16688
16689 /// A unique path in the temp dir that no fixture wrote — a Save As target.
16690 fn temp_path(name: &str) -> PathBuf {
16691 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16692 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
16693 let mut p = std::env::temp_dir();
16694 p.push(format!("leaf_test_target_{name}_{seq}.md"));
16695 let _ = std::fs::remove_file(&p);
16696 p
16697 }
16698
16699 #[test]
16700 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
16701 let mut d = doc_with("save_as_move", "original\n");
16702 let old = d.path.clone();
16703 let new = temp_path("save_as_move");
16704 d.insert("edited: ");
16705 d.save_as(new.clone());
16706
16707 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
16708 assert_eq!(
16709 std::fs::read_to_string(&old).unwrap(),
16710 "original\n",
16711 "Save As doesn't touch the file it came from"
16712 );
16713 assert_eq!(d.path, new, "the document moved");
16714 assert!(!d.dirty);
16715 assert_eq!(
16716 d.status.as_deref(),
16717 Some(&*format!("saved {}", d.file_name()))
16718 );
16719
16720 // Every later save follows it, which is the whole difference from a copy.
16721 d.caret = 0;
16722 d.insert("re-");
16723 d.save();
16724 assert_eq!(
16725 std::fs::read_to_string(&new).unwrap(),
16726 "re-edited: original\n"
16727 );
16728 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
16729 let _ = std::fs::remove_file(&new);
16730 }
16731
16732 #[test]
16733 fn save_as_overwrites_an_existing_target() {
16734 // The picker already asked; asking again down here is the same question
16735 // twice, and the second one has no way to be answered.
16736 let new = temp_path("save_as_over");
16737 std::fs::write(&new, "theirs\n").unwrap();
16738 let mut d = doc_with("save_as_over", "ours\n");
16739 d.save_as(new.clone());
16740 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
16741 let _ = std::fs::remove_file(&new);
16742 }
16743
16744 #[test]
16745 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
16746 let mut d = doc_with("save_as_fail", "body\n");
16747 let old = d.path.clone();
16748 d.insert("x");
16749 // A directory that doesn't exist: the write can't land.
16750 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
16751 d.save_as(bad);
16752
16753 assert_eq!(
16754 d.path, old,
16755 "the document must not move to a file that isn't there"
16756 );
16757 assert!(d.dirty, "and must not believe it saved");
16758 assert!(
16759 d.status.as_deref().unwrap().starts_with("save failed:"),
16760 "the same failure a plain save reports, got {:?}",
16761 d.status
16762 );
16763 // The original is still the document's file, and still saveable.
16764 d.save();
16765 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
16766 assert!(!d.dirty);
16767 }
16768
16769 #[test]
16770 fn save_as_renames_without_reparsing_the_format() {
16771 // `.dj` on the name doesn't make the buffer djot: it was parsed as
16772 // Markdown and still is, and saying otherwise would be a conversion the
16773 // user never asked for (and an undo history thrown away to do it).
16774 let mut d = doc_with("save_as_format", "**b**\n");
16775 let mut new = temp_path("save_as_format");
16776 new.set_extension("dj");
16777 d.save_as(new.clone());
16778 assert_eq!(d.format_name(), "markdown");
16779 let _ = std::fs::remove_file(&new);
16780 }
16781
16782 // ── external change / reload ──────────────────────────────────────────────
16783
16784 #[test]
16785 fn an_untouched_file_reports_unchanged() {
16786 let mut d = doc_with("disk_clean", "body\n");
16787 assert_eq!(d.disk_state(), DiskState::Unchanged);
16788 // Editing the buffer is not editing the file.
16789 d.insert("x");
16790 assert_eq!(d.disk_state(), DiskState::Unchanged);
16791 assert!(d.dirty);
16792 // Saving re-stamps the watermark rather than reporting our own bytes back.
16793 d.save();
16794 assert_eq!(d.disk_state(), DiskState::Unchanged);
16795 }
16796
16797 #[test]
16798 fn a_file_written_underneath_reports_changed() {
16799 let mut d = doc_with("disk_changed", "body\n");
16800 std::fs::write(&d.path, "someone else\n").unwrap();
16801 assert_eq!(d.disk_state(), DiskState::Changed);
16802 // Dirty *and* changed is the clobber: both halves are readable, and
16803 // leaf-core takes neither side.
16804 d.insert("x");
16805 assert!(d.dirty && d.disk_state() == DiskState::Changed);
16806 // Saving anyway is allowed — the frontend asked, or chose not to.
16807 d.save();
16808 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
16809 assert_eq!(d.disk_state(), DiskState::Unchanged);
16810 }
16811
16812 #[test]
16813 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
16814 // The hash is what makes this honest: the file was written (a fresh
16815 // mtime), and nothing about the document is stale.
16816 let d = doc_with("disk_same_bytes", "body\n");
16817 std::fs::write(&d.path, "body\n").unwrap();
16818 assert_eq!(d.disk_state(), DiskState::Unchanged);
16819 }
16820
16821 #[test]
16822 fn a_deleted_file_reports_missing() {
16823 let mut d = doc_with("disk_missing", "body\n");
16824 std::fs::remove_file(&d.path).unwrap();
16825 assert_eq!(d.disk_state(), DiskState::Missing);
16826 // A save recreates it, and the document is whole again.
16827 d.save();
16828 assert_eq!(d.disk_state(), DiskState::Unchanged);
16829 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
16830 }
16831
16832 #[test]
16833 fn reload_replaces_the_document_with_the_file() {
16834 for (view, tag) in VIEWS {
16835 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
16836 d.insert("edited ");
16837 assert!(d.dirty);
16838 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16839 d.reload();
16840
16841 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
16842 assert!(!d.dirty, "{tag}: the file is what we have");
16843 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
16844 assert_eq!(
16845 d.status.as_deref(),
16846 Some(&*format!("reloaded {}", d.file_name()))
16847 );
16848 // The reloaded tree is live, not the old parse.
16849 d.caret = d.source.find("three").unwrap();
16850 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
16851 }
16852 }
16853
16854 #[test]
16855 fn reload_clamps_the_caret_and_drops_the_selection() {
16856 let mut d = doc_with("reload_caret", "a long first line\n");
16857 d.caret = 12;
16858 d.anchor = Some(4);
16859 std::fs::write(&d.path, "short\n").unwrap();
16860 d.reload();
16861 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
16862 assert_eq!(
16863 d.anchor, None,
16864 "a selection over bytes that changed is a lie"
16865 );
16866 assert!(d.selection().is_none());
16867
16868 // A caret the file still has room for stays put.
16869 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
16870 d.caret = 2;
16871 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16872 d.reload();
16873 assert_eq!(d.caret, 2);
16874 }
16875
16876 /// A silent reload is something that happened *to* a reader — a formatter,
16877 /// a `git checkout` — so it has to be undoable like anything else that
16878 /// changes the document, and undoable as one step rather than as however
16879 /// many the file happens to differ by.
16880 #[test]
16881 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
16882 let mut d = doc_with("reload_undo", "body\n");
16883 d.insert("x");
16884 assert_eq!(d.source, "xbody\n");
16885 std::fs::write(&d.path, "replaced\n").unwrap();
16886 d.reload();
16887 assert_eq!(d.source, "replaced\n");
16888 assert!(!d.dirty, "a reload lands clean");
16889
16890 // One ^Z takes the whole swap off, and hands back the unsaved work it
16891 // replaced — which is unsaved again, because the file no longer says it.
16892 d.undo();
16893 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
16894 assert!(d.dirty, "and what it comes back to is unsaved");
16895 // …and the history under it is still there.
16896 d.undo();
16897 assert_eq!(
16898 d.source, "body\n",
16899 "the typing before the reload undoes too"
16900 );
16901 // Redo walks back up through the reload.
16902 d.redo();
16903 d.redo();
16904 assert_eq!(d.source, "replaced\n");
16905 }
16906
16907 /// A file rewritten with the bytes it already had is not an edit, so it
16908 /// must not leave an undo step behind for something nobody did.
16909 #[test]
16910 fn reloading_identical_bytes_pushes_no_undo_step() {
16911 let mut d = doc_with("reload_same", "body\n");
16912 d.insert("x");
16913 std::fs::write(&d.path, "xbody\n").unwrap();
16914 d.reload();
16915 assert_eq!(d.source, "xbody\n");
16916 assert!(!d.dirty, "the file now says what the buffer does");
16917 d.undo();
16918 assert_eq!(
16919 d.source, "body\n",
16920 "one step back is the typing, not a no-op"
16921 );
16922 }
16923
16924 #[test]
16925 fn a_reload_that_cant_read_leaves_the_document_alone() {
16926 let mut d = doc_with("reload_gone", "body\n");
16927 d.insert("x");
16928 std::fs::remove_file(&d.path).unwrap();
16929 d.reload();
16930 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
16931 assert!(d.dirty);
16932 assert!(
16933 d.status.as_deref().unwrap().starts_with("reload failed:"),
16934 "{:?}",
16935 d.status
16936 );
16937
16938 // And an untitled document has nothing to reload from.
16939 let mut d = Doc::blank().unwrap();
16940 d.insert("typed");
16941 d.reload();
16942 assert_eq!(d.source, "typed");
16943 assert_eq!(d.status.as_deref(), Some("no file to reload"));
16944 }
16945
16946 #[test]
16947 fn a_read_only_document_refuses_every_door() {
16948 let mut d = doc_with("readonly", "one two three\n");
16949 d.insert("x");
16950 assert!(d.dirty, "writable first, so the undo step exists");
16951 d.set_read_only(true);
16952 let before = d.source.clone();
16953 d.insert("y");
16954 d.backspace();
16955 d.undo();
16956 d.redo();
16957 assert_eq!(d.source, before, "no door moved a byte");
16958 d.set_read_only(false);
16959 d.undo();
16960 assert_ne!(d.source, before, "off again, the same doors work");
16961 }
16962
16963 /// The doors that go to twig's own verbs rather than through the splice.
16964 /// Typed text in the rendered view under the default markup mode is the
16965 /// everyday one — it is what a keystroke in leaf-web or the Apple views
16966 /// becomes — and it walked straight past the gate.
16967 #[test]
16968 fn a_read_only_document_refuses_the_doors_around_the_splice() {
16969 let mut d = wysiwyg_doc(
16970 "readonly-doors",
16971 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
16972 );
16973 d.set_markup_mode(MarkupMode::None);
16974 d.set_read_only(true);
16975 let before = d.source.clone();
16976 d.place_caret(3, false);
16977 d.insert("y");
16978 d.insert_link("https://example.com");
16979 d.insert_image("a.png", "alt");
16980 d.insert_thematic_break();
16981 d.insert_footnote();
16982 d.place_caret(0, false);
16983 d.place_caret(3, true);
16984 d.toggle(InlineKind::Strong);
16985 d.toggle_heading(2);
16986 d.set_block(BlockKind::Paragraph);
16987 d.toggle_list(false);
16988 d.toggle_blockquote();
16989 d.toggle_task_item();
16990 d.newline();
16991 d.indent();
16992 d.set_code_language("rust");
16993 let in_cell = d.source.find("| c").unwrap() + 2;
16994 d.place_caret(in_cell, false);
16995 assert!(d.caret_in_table(), "the caret is in the grid");
16996 assert!(!d.cell_line_break(), "the cell break reports the refusal");
16997 assert_eq!(d.source, before, "no door moved a byte");
16998 assert!(!d.dirty, "nothing to save");
16999 d.set_read_only(false);
17000 d.place_caret(3, false);
17001 d.insert("y");
17002 assert_ne!(d.source, before, "off again, the same doors work");
17003 }
17004
17005 #[test]
17006 fn a_selection_quote_carries_its_context_on_char_boundaries() {
17007 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
17008 let start = d.source.find("exact").unwrap();
17009 d.place_caret(start, false);
17010 d.place_caret(start + "exact".len(), true);
17011 let q = d.selection_quote(3).unwrap();
17012 assert_eq!(q.exact, "exact");
17013 assert_eq!(
17014 q.prefix, "你好 ",
17015 "chars, not bytes — the multibyte pair counts as two"
17016 );
17017 assert_eq!(q.suffix, " 世界");
17018 assert_eq!(&d.source[q.start..q.end], "exact");
17019 // At the edges the context clips rather than erring.
17020 d.place_caret(0, false);
17021 d.place_caret(6, true);
17022 let q = d.selection_quote(40).unwrap();
17023 assert_eq!(q.prefix, "");
17024 assert_eq!(q.exact, "before");
17025 // No selection is no quote.
17026 d.place_caret(0, false);
17027 assert!(d.selection_quote(3).is_none());
17028 }
17029
17030 #[test]
17031 fn highlights_are_kept_sorted_and_answer_point_queries() {
17032 let mut d = doc_with("hl", "one two three\n");
17033 d.set_highlights(vec![
17034 Highlight {
17035 start: 8,
17036 end: 13,
17037 id: "b".into(),
17038 color: None,
17039 marker: None,
17040 },
17041 Highlight {
17042 start: 0,
17043 end: 3,
17044 id: "a".into(),
17045 color: Some("#ffe066".into()),
17046 marker: None,
17047 },
17048 Highlight {
17049 start: 5,
17050 end: 5,
17051 id: "empty".into(),
17052 color: None,
17053 marker: None,
17054 },
17055 ]);
17056 assert_eq!(
17057 d.highlights()
17058 .iter()
17059 .map(|h| h.id.as_str())
17060 .collect::<Vec<_>>(),
17061 ["a", "b"],
17062 "sorted by start, the empty range dropped"
17063 );
17064 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
17065 assert_eq!(d.highlight_at(3), None, "end is exclusive");
17066 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
17067 d.set_highlights(Vec::new());
17068 assert!(d.highlights().is_empty(), "a replace is a replace");
17069 }
17070
17071 /// `Highlight::covering` and the cursor over it are what both painters ask
17072 /// per glyph, so they have to answer the same as the scan they replaced —
17073 /// including in the gaps, which is where most glyphs are.
17074 #[test]
17075 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
17076 let hl = |start: usize, end: usize, id: &str| Highlight {
17077 start,
17078 end,
17079 id: id.into(),
17080 color: None,
17081 marker: None,
17082 };
17083 // Disjoint, as search hits are: in a range, in a gap, and past the end.
17084 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
17085 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
17086 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
17087 assert_eq!(
17088 Highlight::covering(&hits, 105),
17089 None,
17090 "a gap covers nothing"
17091 );
17092 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
17093 assert_eq!(Highlight::covering(&hits, 9_999), None);
17094 assert_eq!(Highlight::covering(&[], 0), None);
17095
17096 // Nested: first by start, so a hit inside an annotation still resolves
17097 // to the annotation — and the range that stops short doesn't mask it.
17098 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
17099 assert_eq!(
17100 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
17101 Some("outer")
17102 );
17103 assert_eq!(
17104 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
17105 Some("outer")
17106 );
17107 }
17108
17109 /// The cursor is an optimisation, so the only thing worth asserting is that
17110 /// it is not also a change of answer — at every offset, over a list with a
17111 /// nest in it, walked forwards and then backwards.
17112 #[test]
17113 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
17114 let hl = |start: usize, end: usize, id: &str| Highlight {
17115 start,
17116 end,
17117 id: id.into(),
17118 color: None,
17119 marker: None,
17120 };
17121 let mut list = vec![
17122 hl(0, 20, "outer"),
17123 hl(5, 10, "inner"),
17124 hl(30, 33, "hit"),
17125 hl(40, 43, "hit"),
17126 ];
17127 list.sort_by_key(|h| (h.start, h.end));
17128
17129 let mut cursor = HighlightCursor::new(&list);
17130 for offset in 0..50 {
17131 assert_eq!(
17132 cursor.at(offset).map(|h| h.id.as_str()),
17133 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
17134 "cursor disagrees at {offset}"
17135 );
17136 }
17137 // Backwards: the cursor re-seats rather than answering from where it
17138 // had got to, so a painter that revisits a row is still told the truth.
17139 for offset in (0..50).rev() {
17140 assert_eq!(
17141 cursor.at(offset).map(|h| h.id.as_str()),
17142 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
17143 "cursor disagrees walking back at {offset}"
17144 );
17145 }
17146 }
17147
17148 // ── the presentation vocabulary ─────────────────────────────────────────
17149
17150 /// A document in `format`, for the gesture tests that want more than the
17151 /// Markdown `doc_with` writes.
17152 fn fmt_doc(body: &str, format: Format) -> Doc {
17153 Doc::from_source(body.to_string(), format).unwrap()
17154 }
17155
17156 /// Alignment is a block property, so the gesture is `set_block_attrs` on
17157 /// the caret's block whatever is selected — and each format spells it its
17158 /// own way: djot's `{…}` line above the block, a `<div>` around it in
17159 /// Markdown (the format has nowhere else to put it), the tag in HTML.
17160 #[test]
17161 fn set_alignment_spells_the_class_the_format_s_own_way() {
17162 let mut dj = fmt_doc("hello\n", Format::Djot);
17163 dj.caret = 1;
17164 dj.set_alignment(Some(Align::Center));
17165 assert_eq!(dj.source, "{.center}\nhello\n");
17166 assert!(dj.dirty);
17167 assert_eq!(dj.status, None);
17168
17169 let mut md = fmt_doc("hello\n", Format::Markdown);
17170 md.caret = 1;
17171 md.set_alignment(Some(Align::Right));
17172 assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
17173
17174 let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
17175 html.caret = html.source.find("hello").unwrap();
17176 html.set_alignment(Some(Align::Justify));
17177 assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
17178 }
17179
17180 /// Each gesture edits **one key and keeps the rest** — twig's contract is
17181 /// replace-not-merge, so leaf reads the node's attributes, edits its own
17182 /// key out of them, and passes the list back whole. A document from
17183 /// elsewhere passes through the editor unharmed.
17184 #[test]
17185 fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
17186 let mut d = fmt_doc(
17187 "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
17188 Format::Djot,
17189 );
17190 d.caret = d.source.find("hello").unwrap();
17191 d.set_alignment(Some(Align::Right));
17192 // `center` goes, `lead` stays, and neither the id nor the spacing is
17193 // touched.
17194 // The serializer picks the order; what matters is which keys survive.
17195 assert!(d.source.contains(".lead"), "{:?}", d.source);
17196 assert!(d.source.contains(".right"), "{:?}", d.source);
17197 assert!(!d.source.contains(".center"), "{:?}", d.source);
17198 assert!(d.source.contains("#intro"), "{:?}", d.source);
17199 assert!(
17200 d.source.contains("data-line-height=\"1.5\""),
17201 "{:?}",
17202 d.source
17203 );
17204 assert_eq!(d.alignment_at_caret(), Some(Align::Right));
17205 assert_eq!(
17206 d.line_spacing_at_caret(),
17207 Some(LineHeight::Step(LineSpacing::OneHalf))
17208 );
17209
17210 // And the other way round: the spacing gesture leaves the classes be.
17211 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17212 assert!(d.source.contains(".lead"), "{:?}", d.source);
17213 assert!(d.source.contains(".right"), "{:?}", d.source);
17214 assert_eq!(
17215 d.line_spacing_at_caret(),
17216 Some(LineHeight::Step(LineSpacing::Double))
17217 );
17218 }
17219
17220 /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
17221 /// owns go out of `class`, and a block left with nothing at all is spelled
17222 /// bare again — in Markdown by unwrapping the div twig wrapped it in.
17223 #[test]
17224 fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
17225 let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
17226 dj.caret = dj.source.find("hello").unwrap();
17227 dj.set_alignment(None);
17228 assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
17229 assert_eq!(dj.alignment_at_caret(), None);
17230
17231 let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
17232 bare.caret = bare.source.find("hello").unwrap();
17233 bare.set_alignment(None);
17234 assert_eq!(
17235 bare.source, "hello\n",
17236 "the last key takes the line with it"
17237 );
17238
17239 let mut md = fmt_doc("hello\n", Format::Markdown);
17240 md.caret = 1;
17241 md.set_alignment(Some(Align::Center));
17242 assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
17243 md.caret = md.source.find("hello").unwrap();
17244 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
17245 assert_eq!(
17246 md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
17247 "the second key rewrites the div rather than nesting a second"
17248 );
17249 md.caret = md.source.find("hello").unwrap();
17250 md.set_alignment(None);
17251 md.caret = md.source.find("hello").unwrap();
17252 md.set_line_spacing(None);
17253 assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
17254 }
17255
17256 /// Size, face and colour are the run's over a selection and the block's
17257 /// with none — so "make this paragraph larger" is a click with the caret in
17258 /// it rather than a select-all first.
17259 #[test]
17260 fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
17261 // With a selection: a span, in each format's own spelling.
17262 let mut dj = fmt_doc("a big b\n", Format::Djot);
17263 dj.anchor = Some(2);
17264 dj.caret = 5;
17265 dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17266 assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
17267 assert_eq!(
17268 dj.font_size_at_caret(),
17269 Some(FontSize::Step(SizeStep::Large))
17270 );
17271
17272 let mut md = fmt_doc("a big b\n", Format::Markdown);
17273 md.anchor = Some(2);
17274 md.caret = 5;
17275 md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
17276 assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
17277 assert_eq!(
17278 md.text_color_at_caret(),
17279 Some(TextColor::Named(MarkColor::Blue))
17280 );
17281
17282 // Without one: the caret's block, through the block gesture.
17283 let mut block = fmt_doc("a big b\n", Format::Djot);
17284 block.caret = 3;
17285 block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
17286 assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
17287 assert_eq!(
17288 block.font_family_at_caret(),
17289 Some(FontFace::Generic(FontFamily::Monospace))
17290 );
17291 }
17292
17293 /// The *Other…* row of each of the four menus: a value goes into the
17294 /// document in its canonical spelling and comes back out of the query as
17295 /// the same value. One round trip per property, because the four go out
17296 /// through different doors — two block gestures, and the run three through
17297 /// the span that `wrap_range_attrs` mints.
17298 #[test]
17299 fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
17300 // Size: the run three, over a selection.
17301 let mut d = fmt_doc("a big b\n", Format::Djot);
17302 d.anchor = Some(2);
17303 d.caret = 5;
17304 d.set_font_size(FontSize::points(14.0));
17305 assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
17306 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
17307
17308 // Colour, onto the same span — the gesture keeps the size it finds.
17309 d.set_text_color(Some(TextColor::Rgb {
17310 r: 0xc0,
17311 g: 0x30,
17312 b: 0x30,
17313 }));
17314 assert_eq!(
17315 d.source,
17316 "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
17317 );
17318 assert_eq!(
17319 d.text_color_at_caret(),
17320 Some(TextColor::Rgb {
17321 r: 0xc0,
17322 g: 0x30,
17323 b: 0x30
17324 })
17325 );
17326
17327 // Face: a family name, as given.
17328 d.set_font_family(Some(FontFace::Named("Garamond".into())));
17329 assert!(
17330 d.source.contains("data-font=\"Garamond\""),
17331 "{:?}",
17332 d.source
17333 );
17334 assert_eq!(
17335 d.font_family_at_caret(),
17336 Some(FontFace::Named("Garamond".into()))
17337 );
17338
17339 // Line spacing: a block gesture, and an exact ratio.
17340 let mut block = fmt_doc("hello\n", Format::Djot);
17341 block.caret = 1;
17342 block.set_line_spacing(LineHeight::ratio(1.3));
17343 assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
17344 assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
17345
17346 // And a value spelled long is written back short, so the same press
17347 // twice writes the same bytes: `14.0pt` in, `14pt` out.
17348 let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
17349 long.caret = long.source.find("hello").unwrap();
17350 assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
17351 let in_force = long.font_size_at_caret();
17352 long.set_font_size(in_force);
17353 assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
17354 }
17355
17356 /// A value the grammar does not cover is what it was before the vocabulary
17357 /// opened: carried untouched by the document, answered `None` by the query
17358 /// so the menu ticks *Default*, and rewritten only by a gesture on its own
17359 /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
17360 #[test]
17361 fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
17362 let src =
17363 "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
17364 let mut d = fmt_doc(src, Format::Djot);
17365 d.caret = d.source.find("hello").unwrap();
17366 assert_eq!(d.font_size_at_caret(), None);
17367 assert_eq!(d.text_color_at_caret(), None);
17368 assert_eq!(d.line_spacing_at_caret(), None);
17369
17370 // The keys are still there, untouched, after a gesture on a *different*
17371 // key — "edit one key and keep the rest" holds for a value it cannot
17372 // read as readily as for one it can.
17373 d.set_alignment(Some(Align::Center));
17374 assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
17375 assert!(
17376 d.source.contains("data-color=\"rgb(1,2,3)\""),
17377 "{:?}",
17378 d.source
17379 );
17380 assert!(
17381 d.source.contains("data-line-height=\"1.3em\""),
17382 "{:?}",
17383 d.source
17384 );
17385 // And the gesture on its *own* key replaces it, which is the one way a
17386 // carried value ever changes.
17387 d.caret = d.source.find("hello").unwrap();
17388 d.set_font_size(FontSize::points(12.0));
17389 assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
17390 assert!(!d.source.contains("huge"), "{:?}", d.source);
17391 }
17392
17393 /// The nearest node wins whichever *form* either node wrote: a value inside
17394 /// a name, a name inside a value. The fold has one rule and does not learn
17395 /// a second one for exact values.
17396 #[test]
17397 fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
17398 // A value inside a name: the block says `small`, the span says `14pt`.
17399 let mut d = fmt_doc(
17400 "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
17401 Format::Djot,
17402 );
17403 d.caret = d.source.find('y').unwrap();
17404 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
17405 d.caret = d.source.find('x').unwrap();
17406 assert_eq!(
17407 d.font_size_at_caret(),
17408 Some(FontSize::Step(SizeStep::Small))
17409 );
17410
17411 // And a name inside a value, which is the same rule read the other way.
17412 let mut e = fmt_doc(
17413 "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
17414 Format::Djot,
17415 );
17416 e.caret = e.source.find('y').unwrap();
17417 assert_eq!(
17418 e.font_size_at_caret(),
17419 Some(FontSize::Step(SizeStep::Small))
17420 );
17421 assert_eq!(
17422 e.text_color_at_caret(),
17423 Some(TextColor::Rgb {
17424 r: 0xc0,
17425 g: 0x30,
17426 b: 0x30
17427 }),
17428 "the block's colour still reaches the span"
17429 );
17430 e.caret = e.source.find('x').unwrap();
17431 assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
17432 }
17433
17434 /// twig re-styles the span a range already lies in rather than nesting a
17435 /// second, and an empty set unwraps it — so a second press of the menu
17436 /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
17437 /// means "the theme's own" takes the span away.
17438 #[test]
17439 fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
17440 let mut d = fmt_doc("a big b\n", Format::Djot);
17441 d.anchor = Some(2);
17442 d.caret = 5;
17443 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17444 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
17445
17446 // The selection `wrap_range_attrs` left behind covers the whole span;
17447 // colouring it now keeps the size, because the gesture reads the span's
17448 // attributes before it edits its own key.
17449 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
17450 assert_eq!(
17451 d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
17452 "one span, both keys"
17453 );
17454 assert_eq!(
17455 d.font_size_at_caret(),
17456 Some(FontSize::Step(SizeStep::Large))
17457 );
17458 assert_eq!(
17459 d.text_color_at_caret(),
17460 Some(TextColor::Named(MarkColor::Red))
17461 );
17462
17463 d.set_text_color(None);
17464 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
17465 d.set_font_size(None);
17466 assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
17467 assert_eq!(d.font_size_at_caret(), None);
17468 }
17469
17470 /// The queries read the nearest node that names the property: the span the
17471 /// caret is in, then its block, then the `div`s around it.
17472 #[test]
17473 fn a_presentation_query_reads_the_nearest_node_that_names_it() {
17474 let mut d = fmt_doc(
17475 "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
17476 Format::Djot,
17477 );
17478 // In the span: its own size, the block's face and alignment.
17479 d.caret = d.source.find('y').unwrap();
17480 assert_eq!(
17481 d.font_size_at_caret(),
17482 Some(FontSize::Step(SizeStep::XxLarge))
17483 );
17484 assert_eq!(
17485 d.font_family_at_caret(),
17486 Some(FontFace::Generic(FontFamily::Serif))
17487 );
17488 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
17489 assert_eq!(d.line_spacing_at_caret(), None);
17490 assert_eq!(d.text_color_at_caret(), None);
17491
17492 // Outside it: the block's size.
17493 d.caret = d.source.find('x').unwrap();
17494 assert_eq!(
17495 d.font_size_at_caret(),
17496 Some(FontSize::Step(SizeStep::Small))
17497 );
17498
17499 // And through a Markdown div, which is where a Markdown block's
17500 // attributes live.
17501 let mut md = fmt_doc(
17502 "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
17503 Format::Markdown,
17504 );
17505 md.caret = md.source.find("hello").unwrap();
17506 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17507 assert_eq!(
17508 md.font_size_at_caret(),
17509 Some(FontSize::Step(SizeStep::Large))
17510 );
17511
17512 // A document that names none of it answers `None` everywhere, which is
17513 // "the theme's own" and what every toolbar draws unlit.
17514 let mut plain = doc_with("plain_presentation", "hello\n");
17515 plain.caret = 1;
17516 assert_eq!(plain.alignment_at_caret(), None);
17517 assert_eq!(plain.line_spacing_at_caret(), None);
17518 assert_eq!(plain.font_size_at_caret(), None);
17519 assert_eq!(plain.font_family_at_caret(), None);
17520 assert_eq!(plain.text_color_at_caret(), None);
17521 }
17522
17523 /// A djot fenced div is anonymous the way an attributed span is, and is a
17524 /// block all the same — the *form* is the whole of what tells them apart.
17525 /// Read as a span it poisoned both halves: the run gesture copied the div's
17526 /// entire attribute set onto the span it minted, duplicating the `id`, and
17527 /// the run and block queries answered off a node the walker draws nothing
17528 /// for.
17529 #[test]
17530 fn a_djot_fenced_div_is_not_an_attributed_span() {
17531 let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
17532 let mut d = fmt_doc(src, Format::Djot);
17533 let at = d.source.find("world").unwrap();
17534 d.anchor = Some(at);
17535 d.caret = at + "world".len();
17536 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
17537 assert_eq!(
17538 d.source,
17539 "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
17540 "the span carries its own key and nothing of the div's"
17541 );
17542
17543 // And the queries stop at the block: a djot div is not a `<div>`, the
17544 // walker lends its keys to nothing inside it, and a query that said
17545 // otherwise would tick a menu entry no glyph on screen obeys.
17546 assert_eq!(
17547 d.text_color_at_caret(),
17548 Some(TextColor::Named(MarkColor::Red))
17549 );
17550 assert_eq!(d.font_size_at_caret(), None);
17551 assert_eq!(d.alignment_at_caret(), None);
17552 }
17553
17554 /// Clearing a property the block does not name and a `div` around it does
17555 /// would write nothing and change nothing — twig's `set_block_attrs`
17556 /// reaches one node, and the div is not it. The gesture says so instead of
17557 /// leaving the author pressing an entry that never ticks.
17558 #[test]
17559 fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
17560 // Markdown, two paragraphs in one div: not the sole-child shape twig
17561 // writes, so `block_attrs_at_caret` reads the paragraph and the
17562 // paragraph names none of it.
17563 let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
17564 let mut md = fmt_doc(src, Format::Markdown);
17565 md.caret = md.source.find("hello").unwrap();
17566 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17567
17568 md.set_alignment(None);
17569 assert_eq!(md.source, src, "nothing written");
17570 assert!(!md.dirty);
17571 assert_eq!(
17572 md.status.as_deref(),
17573 Some("alignment: set on the div around the block")
17574 );
17575 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17576
17577 // The same for a `data-` key, at both levels — the block pair and the
17578 // run three, the run three at a bare caret being the block gesture.
17579 md.set_line_spacing(None);
17580 assert_eq!(md.source, src);
17581 assert_eq!(
17582 md.status.as_deref(),
17583 Some("line spacing: set on the div around the block")
17584 );
17585 md.set_font_size(None);
17586 assert_eq!(md.source, src);
17587 assert_eq!(
17588 md.status.as_deref(),
17589 Some("size: set on the div around the block")
17590 );
17591
17592 // HTML has no sole-child fold at all: a block's attributes go on the
17593 // block, so the div around one is always out of reach.
17594 let html_src = "<div class=\"center\"><p>hi</p></div>\n";
17595 let mut html = fmt_doc(html_src, Format::Html);
17596 html.caret = html.source.find("hi").unwrap();
17597 assert_eq!(html.alignment_at_caret(), Some(Align::Center));
17598 html.set_alignment(None);
17599 assert_eq!(html.source, html_src);
17600 assert!(!html.dirty);
17601 assert_eq!(
17602 html.status.as_deref(),
17603 Some("alignment: set on the div around the block")
17604 );
17605
17606 // And it is a refusal, not a rule against clearing: a block that names
17607 // the property itself still loses it, div or no div.
17608 let mut own = fmt_doc(
17609 "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
17610 Format::Html,
17611 );
17612 own.caret = own.source.find("hi").unwrap();
17613 own.set_alignment(None);
17614 assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
17615 assert_eq!(own.status, None);
17616 }
17617
17618 /// An edited key is rewritten **where it stands**. The proposal's worked
17619 /// example is the test: a paragraph that came in as `id="intro"
17620 /// class="lead center" data-line-height="1.5"` and is right-aligned goes
17621 /// out as the same list with one token changed. Removing the key and
17622 /// pushing it back shuffled a document's attributes on every press.
17623 #[test]
17624 fn an_edited_key_keeps_its_place_among_the_attributes() {
17625 let mut html = fmt_doc(
17626 "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
17627 Format::Html,
17628 );
17629 html.caret = html.source.find("hello").unwrap();
17630 html.set_alignment(Some(Align::Right));
17631 assert_eq!(
17632 html.source,
17633 "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
17634 );
17635
17636 // A `data-` key the same way, and a key the block did not have still
17637 // goes on the end.
17638 html.caret = html.source.find("hello").unwrap();
17639 html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17640 assert_eq!(
17641 html.source,
17642 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
17643 );
17644 html.caret = html.source.find("hello").unwrap();
17645 html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17646 assert_eq!(
17647 html.source,
17648 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
17649 );
17650
17651 // Djot writes the same list in its own spelling, and the order is the
17652 // author's there too.
17653 let mut dj = fmt_doc(
17654 "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
17655 Format::Djot,
17656 );
17657 dj.caret = dj.source.find("hello").unwrap();
17658 dj.set_alignment(Some(Align::Right));
17659 assert_eq!(
17660 dj.source,
17661 "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
17662 );
17663 }
17664
17665 /// A page break is a block, so twig alone lands one after the caret's whole
17666 /// block; the paragraph is parted at the caret first, exactly as
17667 /// `insert_thematic_break` parts it, and each format spells the directive
17668 /// its own way.
17669 #[test]
17670 fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
17671 let mut md = doc_with("page_break_md", "hello world\n");
17672 md.caret = 5;
17673 md.insert_page_break();
17674 assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
17675 assert!(md.dirty);
17676 assert_eq!(md.status, None);
17677
17678 let mut dj = fmt_doc("hello world\n", Format::Djot);
17679 dj.caret = 5;
17680 dj.insert_page_break();
17681 assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
17682
17683 // At a block's end there is no second half to mint, so the break simply
17684 // follows the block — the rule the rule button already has.
17685 let mut end = doc_with("page_break_end", "hello\n");
17686 end.caret = 5;
17687 end.insert_page_break();
17688 assert_eq!(end.source, "hello\n\n::page-break\n");
17689
17690 // And it reaches the map as the placeholder row a frontend paginates on.
17691 end.view = View::Wysiwyg;
17692 end.build_visual(80);
17693 assert_eq!(
17694 end.vmap
17695 .rows
17696 .iter()
17697 .find_map(|r| r.leaf_directive.as_ref())
17698 .map(|m| m.name.as_str()),
17699 Some(PAGE_BREAK)
17700 );
17701 }
17702
17703 /// The vocabulary's capabilities, per format. The two block properties are
17704 /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
17705 /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
17706 /// keeps an id and a role and has no slot for a `data-` key.
17707 #[test]
17708 fn the_presentation_capabilities_are_ragged_per_format() {
17709 for fmt in [Format::Markdown, Format::Djot, Format::Html] {
17710 let c = Capabilities::of(fmt);
17711 assert!(c.alignment, "{fmt:?} alignment");
17712 assert!(c.line_spacing, "{fmt:?} line spacing");
17713 assert!(c.font_size, "{fmt:?} size");
17714 assert!(c.font_family, "{fmt:?} face");
17715 assert!(c.text_color, "{fmt:?} colour");
17716 }
17717 // Markdown spells both only under the extensions leaf parses with — a
17718 // `<div>` and a `<span>` read back as containers under `html_elements`,
17719 // and `::page-break` as a directive under `directives`. Ask twig's own
17720 // defaults and the answer is no, which is why `Capabilities` is built
17721 // with `supports_with`.
17722 assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
17723 assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
17724 assert!(!Format::Markdown.supports(Gesture::InsertDirective));
17725
17726 let adoc = Capabilities::of(Format::Asciidoc);
17727 assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
17728 assert!(
17729 !adoc.font_size && !adoc.font_family && !adoc.text_color,
17730 "AsciiDoc has no inline spelling that keeps a data- key"
17731 );
17732
17733 // XML spells none of it, and neither page break — nor has it blocks a
17734 // caret could name for a move.
17735 let xml = Capabilities::of(Format::Xml);
17736 assert!(!xml.alignment && !xml.font_size && !xml.page_break && !xml.move_block);
17737 for f in [Format::Markdown, Format::Djot, Format::Html] {
17738 assert!(Capabilities::of(f).move_block, "{f:?} moves blocks");
17739 }
17740 assert!(Capabilities::of(Format::Markdown).page_break);
17741 assert!(Capabilities::of(Format::Djot).page_break);
17742
17743 // And those two *only*, though twig spells the gesture in HTML and
17744 // AsciiDoc as well: it spells it differently there —
17745 // `<page-break></page-break>` and `<<<` — and the walker reads neither,
17746 // so the button would write a break that draws as nothing at all in
17747 // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
17748 // what leaf can show, not what twig can write. See
17749 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
17750 let exts = parse_extensions();
17751 assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
17752 assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
17753 assert!(!Capabilities::of(Format::Html).page_break);
17754 assert!(!Capabilities::of(Format::Asciidoc).page_break);
17755 }
17756
17757 /// A format that cannot spell a property refuses in its own words and
17758 /// writes nothing — the guard every other gesture has.
17759 #[test]
17760 fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
17761 let src = "<doc><p>hello</p></doc>\n";
17762 #[allow(clippy::type_complexity)]
17763 let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
17764 ("alignment", &|d: &mut Doc| {
17765 d.set_alignment(Some(Align::Center))
17766 }),
17767 ("line spacing", &|d: &mut Doc| {
17768 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
17769 }),
17770 ("size", &|d: &mut Doc| {
17771 d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
17772 }),
17773 ("face", &|d: &mut Doc| {
17774 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
17775 }),
17776 ("colour", &|d: &mut Doc| {
17777 d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
17778 }),
17779 ("page break", &|d: &mut Doc| d.insert_page_break()),
17780 ];
17781 for (name, op) in ops {
17782 let mut d = fmt_doc(src, Format::Xml);
17783 let at = d.source.find("hello").unwrap();
17784 d.caret = at;
17785 d.anchor = Some(at + 5);
17786 op(&mut d);
17787 assert_eq!(d.source, src, "{name} edited an XML document");
17788 assert!(!d.dirty, "{name} marked the document dirty");
17789 let status = d.status.as_deref().unwrap_or("");
17790 assert!(
17791 status.contains("xml"),
17792 "{name}: the refusal should name the format, got {status:?}"
17793 );
17794 }
17795
17796 // AsciiDoc is the ragged one: the block gesture works where the run
17797 // gesture does not, and a *selection* is what tells the two apart.
17798 let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
17799 adoc.anchor = Some(0);
17800 adoc.caret = 5;
17801 adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17802 assert_eq!(adoc.source, "hello world\n", "no inline spelling");
17803 assert!(adoc.status.is_some());
17804 }
17805
17806 /// A read-only document takes none of it, and a caret on a blank line has
17807 /// no block to carry an attribute — both say so rather than writing.
17808 #[test]
17809 fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
17810 let mut ro = fmt_doc("hello\n", Format::Djot);
17811 ro.read_only = true;
17812 ro.caret = 1;
17813 ro.set_alignment(Some(Align::Center));
17814 assert_eq!(ro.source, "hello\n");
17815
17816 let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
17817 blank.caret = 2; // the empty line between the two paragraphs
17818 blank.set_alignment(Some(Align::Center));
17819 assert_eq!(blank.source, "a\n\n\nb\n");
17820 assert!(
17821 blank.status.as_deref().unwrap_or("").contains("no block"),
17822 "got {:?}",
17823 blank.status
17824 );
17825 }
17826
17827 /// A block attribute gesture keeps the caret on the **text** it was on, not
17828 /// on the byte offset it had. Markdown has nowhere to put a paragraph's
17829 /// attributes but a `<div>` around it, and twig splices the div and the
17830 /// block it wraps as one region — so a caret that kept its offset landed in
17831 /// the markup, and every press after the first answered "no block at the
17832 /// caret" with the toolbar's queries reading nothing.
17833 #[test]
17834 fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
17835 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17836 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17837 md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
17838
17839 // Wrapping: the div and two blank lines open above the block.
17840 md.set_alignment(Some(Align::Center));
17841 assert_eq!(
17842 md.source,
17843 "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
17844 );
17845 assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
17846 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17847
17848 // Re-styling: the attribute line changes length under the same caret,
17849 // and the second press reaches the same block rather than nothing.
17850 md.set_alignment(Some(Align::Right));
17851 assert_eq!(
17852 md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
17853 "a second press re-styles the div"
17854 );
17855 assert_eq!(md.status, None);
17856 assert_eq!(word(&md), 2);
17857
17858 // A second key on the same div — the line grows, the caret rides it.
17859 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17860 assert_eq!(
17861 md.source,
17862 "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17863 );
17864 assert_eq!(word(&md), 2);
17865 assert_eq!(
17866 md.line_spacing_at_caret(),
17867 Some(LineHeight::Step(LineSpacing::Double))
17868 );
17869
17870 // Unwrapping: the line shrinks, and then the div goes altogether.
17871 md.set_alignment(None);
17872 assert_eq!(
17873 md.source,
17874 "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17875 );
17876 assert_eq!(word(&md), 2);
17877 md.set_line_spacing(None);
17878 assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
17879 assert_eq!(word(&md), 2, "the caret came back down with the block");
17880 assert_eq!(md.alignment_at_caret(), None);
17881 assert_eq!(md.status, None);
17882 }
17883
17884 /// The same rule in djot, where the spelling is a `{…}` line *above* the
17885 /// block rather than a wrapper around it: inserting it pushes the block
17886 /// down, re-styling it changes the line's length, and clearing the last key
17887 /// takes the line away again. The caret rides all three.
17888 #[test]
17889 fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
17890 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17891 let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
17892 dj.caret = dj.source.find("brown").unwrap() + 2;
17893
17894 dj.set_alignment(Some(Align::Center));
17895 assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
17896 assert_eq!(word(&dj), 2);
17897 assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
17898
17899 dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17900 assert_eq!(
17901 dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
17902 "a second press edits the line the first wrote"
17903 );
17904 assert_eq!(word(&dj), 2);
17905
17906 dj.set_alignment(None);
17907 assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
17908 assert_eq!(word(&dj), 2);
17909
17910 dj.set_line_spacing(None);
17911 assert_eq!(dj.source, "the quick brown fox\n");
17912 assert_eq!(word(&dj), 2);
17913 assert_eq!(dj.status, None);
17914 }
17915
17916 /// The run gestures with no selection are the block gesture, so they keep
17917 /// the caret the same way — and a heading keeps it inside the heading's own
17918 /// text, past the `# ` its content span starts after. A selection rides
17919 /// along whole: a block gesture is not a run gesture, and what was selected
17920 /// before the press is still selected after it.
17921 #[test]
17922 fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
17923 // No selection: the run gesture goes through the block door.
17924 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17925 md.caret = md.source.find("brown").unwrap() + 2;
17926 md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17927 assert_eq!(
17928 md.source,
17929 "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
17930 );
17931 assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
17932 assert_eq!(
17933 md.font_size_at_caret(),
17934 Some(FontSize::Step(SizeStep::Large))
17935 );
17936 md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
17937 assert_eq!(
17938 md.font_size_at_caret(),
17939 Some(FontSize::Step(SizeStep::Small)),
17940 "the second press reached the same block"
17941 );
17942
17943 // A selection: alignment is the block's whatever is selected, and the
17944 // words stay selected.
17945 let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
17946 let at = sel.source.find("brown").unwrap();
17947 sel.anchor = Some(at);
17948 sel.caret = at + 5;
17949 sel.set_alignment(Some(Align::Center));
17950 let now = sel.source.find("brown").unwrap();
17951 assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
17952
17953 // A heading: the content span starts past the `# `.
17954 let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
17955 h.caret = h.source.find("there").unwrap() + 1;
17956 h.set_alignment(Some(Align::Right));
17957 assert_eq!(
17958 h.source,
17959 "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
17960 );
17961 assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
17962 assert_eq!(h.alignment_at_caret(), Some(Align::Right));
17963 }
17964
17965 /// A djot document open in the rich view, with its map built as
17966 /// [`wysiwyg_doc`] builds a Markdown one's.
17967 fn wysiwyg_djot(body: &str) -> Doc {
17968 let mut d = fmt_doc(body, Format::Djot);
17969 d.view = View::Wysiwyg;
17970 d.build_visual(80);
17971 d
17972 }
17973
17974 /// Backspace at the start of a block whose presentation is spelled as
17975 /// hidden markup before it strips that presentation, the way Backspace at
17976 /// a heading's start strips its `#`. The ordinary delete fused djot's
17977 /// `{.center}` line onto the text and took the blank line a Markdown div
17978 /// needs between its tag and its paragraph.
17979 #[test]
17980 fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
17981 let mut md = wysiwyg_doc(
17982 "wys_attr_bksp",
17983 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
17984 );
17985 md.caret = md.source.find("hello").unwrap();
17986 md.backspace();
17987 assert_eq!(
17988 md.source, "above\n\nhello\n\nbelow\n",
17989 "the div is unwrapped"
17990 );
17991 assert_eq!(md.caret, 7, "the caret stays at the start of its text");
17992 md.backspace();
17993 assert_eq!(
17994 md.source, "above\nhello\n\nbelow\n",
17995 "the next press joins the paragraphs, as it always did"
17996 );
17997
17998 let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
17999 dj.caret = dj.source.find("hello").unwrap();
18000 dj.backspace();
18001 assert_eq!(
18002 dj.source, "above\n\nhello\n\nbelow\n",
18003 "the attribute line goes"
18004 );
18005 assert_eq!(dj.caret, 7);
18006
18007 // A heading's own marker is the nearer hidden markup, and goes first;
18008 // the attributes are the next press's.
18009 let mut dj = wysiwyg_djot("{.center}\n# Title\n");
18010 dj.caret = dj.source.find("Title").unwrap();
18011 dj.backspace();
18012 assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
18013 dj.build_visual(80);
18014 dj.backspace();
18015 assert_eq!(dj.source, "Title\n", "then the attributes");
18016 assert_eq!(dj.caret, 0);
18017 }
18018
18019 /// A div around several blocks has no sole child for twig to unwrap, so
18020 /// at its first block the caret steps back to the stop before rather than
18021 /// taking the div apart; a later block has an ordinary paragraph above it
18022 /// and joins as any paragraph does.
18023 #[test]
18024 fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
18025 let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
18026 let mut d = wysiwyg_doc("wys_div_first", src);
18027 d.caret = d.source.find("hello").unwrap();
18028 d.backspace();
18029 assert_eq!(d.source, src, "nothing is deleted");
18030 assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
18031
18032 let mut d = wysiwyg_doc("wys_div_later", src);
18033 d.caret = d.source.find("world").unwrap();
18034 d.backspace();
18035 assert_eq!(
18036 d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
18037 "a later block joins the one above it"
18038 );
18039 }
18040
18041 /// Backspace at the start of the paragraph after a Markdown div joins it
18042 /// into the div's last paragraph — the join any two paragraphs make, with
18043 /// the hidden `</div>` carried past the joined text. The ordinary delete
18044 /// took the newline under the tag, which drew nothing different, and the
18045 /// next press took the `>` and left the div unclosed.
18046 #[test]
18047 fn backspace_after_a_div_joins_the_paragraph_into_it() {
18048 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
18049 let mut d = wysiwyg_doc("wys_div_join", src);
18050 d.caret = d.source.find("below").unwrap();
18051 d.backspace();
18052 assert_eq!(
18053 d.source,
18054 "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
18055 );
18056 assert_eq!(
18057 d.caret,
18058 d.source.find("below").unwrap(),
18059 "the caret stays at the start of the joined text"
18060 );
18061 d.build_visual(80);
18062 assert_eq!(
18063 d.alignment_at_caret(),
18064 Some(Align::Center),
18065 "and is centred now"
18066 );
18067 d.undo();
18068 assert_eq!(d.source, src, "one undo step");
18069
18070 // A list closes the div: the paragraph joins the last item's text,
18071 // under the item's continuation indent, inside the div.
18072 let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
18073 let mut d = wysiwyg_doc("wys_div_list", src);
18074 d.caret = d.source.find("below").unwrap();
18075 d.backspace();
18076 assert_eq!(
18077 d.source, "<div class=\"center\">\n\n- item\n below\n\n</div>\n",
18078 "the paragraph joins the item"
18079 );
18080 assert_eq!(d.caret, d.source.find("below").unwrap());
18081
18082 // And where twig has nothing to join into — a code block above — the
18083 // caret steps back to the stop before, and nothing is deleted.
18084 let src = "```\ncode\n```\n\nbelow\n";
18085 let mut d = wysiwyg_doc("wys_code_then_para", src);
18086 d.caret = d.source.find("below").unwrap();
18087 d.backspace();
18088 assert_eq!(d.source, src, "nothing is deleted");
18089 assert!(
18090 d.caret < d.source.find("below").unwrap(),
18091 "the caret stepped back"
18092 );
18093 }
18094
18095 /// Backspace on a blank line collapses to the stop before it — but not
18096 /// across hidden markup, which that collapse deleted whole: a `</div>`,
18097 /// or a comment between two blocks. There the blank line goes alone, and
18098 /// the caret lands where the collapse would have put it.
18099 #[test]
18100 fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
18101 let mut d = wysiwyg_doc(
18102 "wys_div_blank",
18103 "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
18104 );
18105 d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
18106 assert!(
18107 d.vmap.is_stop(d.caret),
18108 "the empty paragraph is a caret home"
18109 );
18110 d.backspace();
18111 assert_eq!(
18112 d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
18113 "the blank line goes and the div stays closed"
18114 );
18115 assert_eq!(
18116 d.caret,
18117 d.source.find("hello").unwrap() + 5,
18118 "onto the end of `hello`"
18119 );
18120
18121 let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
18122 d.caret = d.source.find("below").unwrap() - 2;
18123 assert!(d.vmap.is_stop(d.caret));
18124 d.backspace();
18125 assert_eq!(
18126 d.source, "above\n\n<!-- note -->\n\nbelow\n",
18127 "the comment stays"
18128 );
18129 assert_eq!(d.caret, 5);
18130 }
18131
18132 /// Backspace at the end of an attributed span steps inside its hidden
18133 /// closing tag the way it steps inside a `**`, and takes the span with
18134 /// its last letter. Before, the byte-step took the `>` of `</span>`,
18135 /// which left the paragraph unparseable: it vanished from the rich view,
18136 /// and the Backspace after that joined the next block into the wreck.
18137 #[test]
18138 fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
18139 let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
18140 let mut d = wysiwyg_doc("wys_span_bs", src);
18141 d.caret = d.source.find("a test").unwrap() + 6;
18142 for _ in 0..7 {
18143 d.backspace();
18144 }
18145 assert_eq!(
18146 d.source,
18147 "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
18148 );
18149 d.backspace();
18150 assert_eq!(
18151 d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
18152 "the first Backspace after the tag takes the letter, not the `>`"
18153 );
18154 d.backspace();
18155 assert_eq!(
18156 d.source, "above\n\nThis \n\nTest 2\n",
18157 "the last letter takes the span with it"
18158 );
18159 assert_eq!(d.caret, 12, "the caret is where the letter was");
18160 d.backspace();
18161 assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
18162 assert_eq!(d.caret, 11);
18163 d.build_visual(80);
18164 assert!(
18165 d.vmap
18166 .rows
18167 .iter()
18168 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
18169 "the paragraph is still drawn"
18170 );
18171 }
18172
18173 #[test]
18174 fn backspace_walks_into_a_djot_sized_span_too() {
18175 let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
18176 d.caret = d.source.find("\n\nTest 2").unwrap();
18177 // The caret home at the paragraph's end is inside the span, before
18178 // its `]`: the map offers no stop after `]{…}`.
18179 d.build_visual(80);
18180 assert_eq!(d.vmap.stop_before(31), Some(8));
18181 d.caret = 8;
18182 d.backspace();
18183 assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
18184 d.backspace();
18185 assert_eq!(
18186 d.source, "This \n\nTest 2\n",
18187 "the attribute block outside the span goes with it"
18188 );
18189 d.backspace();
18190 assert_eq!(d.source, "This\n\nTest 2\n");
18191 assert_eq!(d.caret, 4);
18192 }
18193
18194 /// A span that is empty as the file was written has no stop of its own;
18195 /// Backspace reaching it from behind takes it with the character before
18196 /// it, the character the key looked aimed at.
18197 #[test]
18198 fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
18199 let src = "This <span data-size=\"x-large\"></span> a test\n";
18200 let mut d = wysiwyg_doc("wys_span_empty", src);
18201 d.caret = d.source.find(" a test").unwrap();
18202 d.backspace();
18203 assert_eq!(d.source, "This a test\n");
18204 assert_eq!(d.caret, 4);
18205 }
18206
18207 /// The mirror: Delete in front of a span's opening tag takes its first
18208 /// letter, and the span with its last.
18209 #[test]
18210 fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
18211 let src = "This <span data-size=\"x-large\">is</span> a test\n";
18212 let mut d = wysiwyg_doc("wys_span_del", src);
18213 d.caret = 5;
18214 d.delete_forward();
18215 assert_eq!(
18216 d.source,
18217 "This <span data-size=\"x-large\">s</span> a test\n"
18218 );
18219 d.delete_forward();
18220 assert_eq!(d.source, "This a test\n");
18221 assert_eq!(d.caret, 5);
18222 d.delete_forward();
18223 assert_eq!(d.source, "This a test\n");
18224 assert_eq!(d.caret, 5);
18225 }
18226
18227 /// The block version of the span's emptying rule. Centre a one-letter
18228 /// paragraph — Markdown spells that as a `<div>` around it — and
18229 /// Backspace the letter: the div goes with it, leaving a plain blank line
18230 /// the caret is at home on, in the incremental map and the from-scratch
18231 /// one alike. Before, the letter went alone; the emptied div drew a
18232 /// caret home only the stale map had, and the next Backspace collapsed
18233 /// the line and left `<div class="center">\n\n</div>` standing invisibly
18234 /// in the file.
18235 #[test]
18236 fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
18237 let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
18238 d.caret = d.source.len() - 1;
18239 d.set_alignment(Some(Align::Center));
18240 assert_eq!(
18241 d.source,
18242 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
18243 );
18244 d.backspace();
18245 assert_eq!(d.source, "Try the toolbar.\n\n\n");
18246 assert_eq!(d.caret, 18, "on the blank line where the letter was");
18247 // The host rebuilds the map after every key; the spliced map and a
18248 // fresh one both give the line a caret home.
18249 d.build_visual_unwrapped();
18250 assert!(d.vmap.is_stop(18));
18251 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
18252 d.backspace();
18253 assert_eq!(
18254 d.source, "Try the toolbar.\n",
18255 "then the blank line collapses"
18256 );
18257 assert_eq!(d.caret, 16);
18258
18259 // With a block after the div the blank line keeps a gap each side.
18260 let mut d = wysiwyg_doc(
18261 "wys_div_empty_mid",
18262 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
18263 );
18264 d.caret = d.source.find("T\n").unwrap() + 1;
18265 d.backspace();
18266 assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
18267 assert_eq!(d.caret, 18);
18268 d.build_visual_unwrapped();
18269 assert!(d.vmap.is_stop(18));
18270 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
18271 }
18272
18273 /// djot spells the same paragraph as a `{.center}` line above it, and
18274 /// twig has no node at all for that line once the paragraph is gone —
18275 /// so the line goes with the letter too.
18276 #[test]
18277 fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
18278 let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
18279 d.build_visual(80);
18280 d.caret = d.source.len() - 1;
18281 d.set_alignment(Some(Align::Center));
18282 assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
18283 d.backspace();
18284 assert_eq!(d.source, "Try the toolbar.\n\n\n");
18285 assert_eq!(d.caret, 18);
18286 d.build_visual(80);
18287 assert!(d.vmap.is_stop(18));
18288 }
18289
18290 /// The rule is for a block that would be no block: a div holding more
18291 /// keeps its tags, and an emptied heading is still a heading.
18292 #[test]
18293 fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
18294 let mut d = wysiwyg_doc(
18295 "wys_div_more",
18296 "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
18297 );
18298 d.caret = d.source.find("T\n").unwrap() + 1;
18299 d.backspace();
18300 assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
18301 assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
18302
18303 let mut d = wysiwyg_doc(
18304 "wys_div_heading",
18305 "<div class=\"center\">\n\n# T\n\n</div>\n",
18306 );
18307 d.caret = d.source.find("T\n").unwrap() + 1;
18308 d.backspace();
18309 assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
18310 assert_eq!(d.caret, 24);
18311 d.build_visual(80);
18312 assert!(
18313 d.vmap.is_stop(24),
18314 "the empty heading is still a caret home"
18315 );
18316 }
18317
18318 /// The mirror: Delete in front of the letter takes the div with it.
18319 #[test]
18320 fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
18321 let mut d = wysiwyg_doc(
18322 "wys_div_empty_del",
18323 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
18324 );
18325 d.caret = d.source.find("T\n").unwrap();
18326 d.delete_forward();
18327 assert_eq!(d.source, "Try the toolbar.\n\n\n");
18328 assert_eq!(d.caret, 18);
18329 d.build_visual(80);
18330 assert!(d.vmap.is_stop(18));
18331
18332 let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
18333 d.build_visual(80);
18334 d.caret = d.source.find("T\n").unwrap();
18335 d.delete_forward();
18336 assert_eq!(d.source, "Try the toolbar.\n\n\n");
18337 assert_eq!(d.caret, 18);
18338 }
18339
18340 /// Backspace at a block's start is twig's join, spelled per format — so
18341 /// the cases the one-newline delete got wrong come out right: a
18342 /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
18343 /// and a quote's prefix is written on the joined line.
18344 #[test]
18345 fn backspace_at_a_block_start_joins_it_the_format_s_way() {
18346 let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
18347 d.caret = d.source.find("below").unwrap();
18348 d.backspace();
18349 assert_eq!(d.source, "# Title below\n", "onto the heading's line");
18350 assert_eq!(d.caret, d.source.find("below").unwrap());
18351 d.undo();
18352 assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
18353
18354 let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
18355 d.caret = d.source.find('b').unwrap();
18356 d.backspace();
18357 assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
18358 assert_eq!(d.caret, d.source.find('b').unwrap());
18359
18360 let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
18361 h.view = View::Wysiwyg;
18362 h.build_visual(80);
18363 h.caret = h.source.find("below").unwrap();
18364 h.backspace();
18365 assert_eq!(
18366 h.source, "<p>above\nbelow</p>\n",
18367 "one paragraph, the tag gone whole"
18368 );
18369 assert_eq!(h.caret, h.source.find("below").unwrap());
18370 }
18371
18372 /// Delete at the end of a block's content is the same join aimed at the
18373 /// block after it, and the caret stays where the joined text now begins.
18374 #[test]
18375 fn delete_at_a_block_end_joins_the_next_block_into_it() {
18376 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
18377 let mut d = wysiwyg_doc("wys_del_join", src);
18378 d.caret = d.source.find("hello").unwrap() + 5;
18379 d.delete_forward();
18380 assert_eq!(
18381 d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
18382 "below joins hello inside the div"
18383 );
18384 assert_eq!(
18385 d.caret,
18386 d.source.find("hello").unwrap() + 5,
18387 "the caret stays"
18388 );
18389
18390 let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
18391 d.caret = 5;
18392 d.delete_forward();
18393 assert_eq!(
18394 d.source, "above\nTitle\n",
18395 "the heading's marker goes with the join"
18396 );
18397 assert_eq!(d.caret, 5);
18398
18399 // A code block after the paragraph: nothing to join, the caret steps
18400 // forward onto the next stop and nothing is deleted.
18401 let src = "above\n\n```\ncode\n```\n";
18402 let mut d = wysiwyg_doc("wys_del_code", src);
18403 d.caret = 5;
18404 d.delete_forward();
18405 assert_eq!(d.source, src);
18406 assert!(d.caret > 5, "the caret stepped forward");
18407 }
18408
18409 // ── Text statistics ──────────────────────────────────────────────────────
18410
18411 /// The counts of `body`, from a document open in the WYSIWYG view — the
18412 /// shape every case below starts from.
18413 fn counts_of(name: &str, body: &str) -> TextCounts {
18414 doc_in(View::Wysiwyg, name, body).counts()
18415 }
18416
18417 #[test]
18418 fn counts_tally_plain_prose() {
18419 let c = counts_of(
18420 "counts_prose",
18421 "The quick brown fox jumps over the lazy dog.\n",
18422 );
18423 assert_eq!(
18424 c,
18425 TextCounts {
18426 words: 9,
18427 characters: 44,
18428 characters_without_spaces: 36,
18429 paragraphs: 1,
18430 }
18431 );
18432 }
18433
18434 #[test]
18435 fn counts_read_the_text_and_not_the_markup() {
18436 // The `**` are four bytes of source and no part of the word.
18437 assert_eq!(
18438 counts_of("counts_marks", "a **bold** word\n"),
18439 TextCounts {
18440 words: 3,
18441 characters: 11,
18442 characters_without_spaces: 9,
18443 paragraphs: 1,
18444 }
18445 );
18446 // A link is its label; the destination is plumbing, however long.
18447 assert_eq!(
18448 counts_of(
18449 "counts_link",
18450 "see [the label](https://example.com/a/b/c) here\n"
18451 ),
18452 TextCounts {
18453 words: 4,
18454 characters: 18,
18455 characters_without_spaces: 15,
18456 paragraphs: 1,
18457 }
18458 );
18459 }
18460
18461 #[test]
18462 fn counts_spend_nothing_on_a_picture() {
18463 // A block image renders as a `🖼 alt` placeholder — a picture, not a
18464 // sentence, and not a paragraph either.
18465 assert_eq!(
18466 counts_of("counts_image", "\n"),
18467 TextCounts::default()
18468 );
18469 // And it adds nothing to the prose around it.
18470 assert_eq!(
18471 counts_of("counts_image_prose", "text\n\n\n"),
18472 TextCounts {
18473 words: 1,
18474 characters: 4,
18475 characters_without_spaces: 4,
18476 paragraphs: 1,
18477 }
18478 );
18479 }
18480
18481 #[test]
18482 fn counts_spend_nothing_on_drawn_furniture() {
18483 // A thematic break is drawn, not written, and an empty paragraph has
18484 // nothing in it — neither is a paragraph of the document.
18485 assert_eq!(
18486 counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
18487 TextCounts {
18488 words: 2,
18489 characters: 6,
18490 characters_without_spaces: 6,
18491 paragraphs: 2,
18492 }
18493 );
18494 }
18495
18496 #[test]
18497 fn counts_measure_characters_as_a_reader_does() {
18498 // Four Han characters (each its own word under UAX#29), one ZWJ emoji
18499 // family that is a single grapheme cluster, and two letters.
18500 let c = counts_of(
18501 "counts_graphemes",
18502 "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
18503 );
18504 assert_eq!(
18505 c,
18506 TextCounts {
18507 words: 5,
18508 characters: 9,
18509 characters_without_spaces: 7,
18510 paragraphs: 1,
18511 }
18512 );
18513 }
18514
18515 #[test]
18516 fn counts_take_a_code_block_as_one_paragraph() {
18517 let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
18518 assert_eq!(
18519 c,
18520 TextCounts {
18521 words: 6,
18522 characters: 20,
18523 characters_without_spaces: 14,
18524 paragraphs: 1,
18525 }
18526 );
18527 }
18528
18529 #[test]
18530 fn counts_take_a_table_as_one_paragraph() {
18531 // The box-drawn borders and the column padding are the renderer's, not
18532 // the author's; the cells are what was written.
18533 let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
18534 assert_eq!(
18535 c,
18536 TextCounts {
18537 words: 5,
18538 characters: 6,
18539 characters_without_spaces: 5,
18540 paragraphs: 1,
18541 }
18542 );
18543 }
18544
18545 #[test]
18546 fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
18547 let c = counts_of(
18548 "counts_blocks",
18549 "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
18550 );
18551 assert_eq!(
18552 c,
18553 TextCounts {
18554 words: 7,
18555 characters: 36,
18556 characters_without_spaces: 34,
18557 paragraphs: 5,
18558 }
18559 );
18560 }
18561
18562 #[test]
18563 fn counts_leave_the_frontmatter_out() {
18564 // The WYSIWYG view doesn't render it and a writer didn't write it.
18565 let c = counts_of(
18566 "counts_frontmatter",
18567 "---\ntitle: Hidden\n---\n\nvisible words here\n",
18568 );
18569 assert_eq!(
18570 c,
18571 TextCounts {
18572 words: 3,
18573 characters: 18,
18574 characters_without_spaces: 16,
18575 paragraphs: 1,
18576 }
18577 );
18578 }
18579
18580 #[test]
18581 fn counts_of_an_empty_document_are_all_zero() {
18582 assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
18583 }
18584
18585 /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
18586 /// words. Recorded rather than corrected: it is what the algorithm says,
18587 /// and what every other UAX#29 counter reports.
18588 #[test]
18589 fn counts_split_a_hyphenated_compound_in_two() {
18590 let c = counts_of("counts_hyphen", "well-known example\n");
18591 assert_eq!(c.words, 3);
18592 assert_eq!(c.characters, 18);
18593 // Punctuation on its own is no word, and an apostrophe doesn't split one.
18594 assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
18595 }
18596
18597 #[test]
18598 fn selection_counts_measure_the_selection_and_nothing_without_one() {
18599 let src = "alpha beta\n\ngamma delta\n";
18600 let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
18601 assert_eq!(d.selection_counts(), None, "no selection, no counts");
18602
18603 // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
18604 d.select_range(6, 17);
18605 assert_eq!(
18606 d.selection_counts(),
18607 Some(TextCounts {
18608 words: 2,
18609 characters: 9,
18610 characters_without_spaces: 9,
18611 paragraphs: 2,
18612 })
18613 );
18614 }
18615
18616 /// The count is of the document, not of the window it is shown in — so
18617 /// ⌘E must not move it, and neither must a resize or an edit made with no
18618 /// map built at all.
18619 #[test]
18620 fn counts_agree_across_the_views() {
18621 let src =
18622 "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
18623 let mut d = doc_in(View::Wysiwyg, "counts_views", src);
18624 let wysiwyg = d.counts();
18625 assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
18626
18627 d.toggle_view();
18628 assert_eq!(d.view, View::Source);
18629 d.build_source();
18630 assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
18631
18632 // A narrower measure is a narrower window, not a shorter document.
18633 d.toggle_view();
18634 d.build_visual(24);
18635 assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
18636
18637 // And an edit made in the source view, with the visual map left stale,
18638 // still counts the document as it now stands.
18639 d.toggle_view();
18640 d.caret = d.source.len();
18641 d.insert("\n\ntail words\n");
18642 let after = d.counts();
18643 assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
18644 assert_eq!(after.words, wysiwyg.words + 2);
18645 }
18646
18647 // ── math ─────────────────────────────────────────────────────────────────
18648
18649 #[test]
18650 fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
18651 // The rule the proposal states: a formula's content is its TeX, not
18652 // its picture, so it reveals on the caret's line in *every* mode —
18653 // and nothing else on that line does outside `Full`.
18654 let mut d = doc_in(
18655 View::Wysiwyg,
18656 "math_reveal",
18657 "*one* $x+y$ here\n\ntwo there\n",
18658 );
18659 d.set_inline_pictures(true);
18660 assert_eq!(d.markup_mode(), MarkupMode::None);
18661
18662 // Away from the formula's line: the atom, and no reveal at all.
18663 caret_at(&mut d, "two");
18664 assert!(
18665 drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
18666 "{:?}",
18667 drawn_rows(&d)
18668 );
18669 assert_eq!(d.vmap.math.len(), 1);
18670 assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
18671
18672 // On it: the formula is its source, the emphasis is still resolved.
18673 caret_at(&mut d, "here");
18674 assert!(
18675 drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
18676 "{:?}",
18677 drawn_rows(&d)
18678 );
18679 assert!(d.vmap.math.is_empty());
18680 assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
18681
18682 // Off again, and the picture is back.
18683 caret_at(&mut d, "two");
18684 assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
18685
18686 // The same in Shortcuts; and Full reveals the emphasis too.
18687 d.set_markup_mode(MarkupMode::Shortcuts);
18688 caret_at(&mut d, "here");
18689 assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
18690 d.set_markup_mode(MarkupMode::Full);
18691 caret_at(&mut d, "here");
18692 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
18693 }
18694
18695 #[test]
18696 fn a_formula_closed_by_typing_reveals_at_once() {
18697 // The reveal line is decided from the last build's layout, which
18698 // across an edit is stale: the keystroke that closes a `$…$` asks a
18699 // layout that knew no math. `build_map` asks again once the new
18700 // layout is in, so the formula does not snap to its picture under
18701 // the caret.
18702 let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
18703 d.set_inline_pictures(true);
18704 d.set_markup_mode(MarkupMode::Shortcuts);
18705 d.caret = 4;
18706 for ch in ["$", "x", "$"] {
18707 d.insert(ch);
18708 d.build_visual(80);
18709 }
18710 assert_eq!(d.source, "say $x$\n");
18711 assert_eq!(
18712 drawn_rows(&d)[0],
18713 "say $x$",
18714 "source, not a picture, under the caret"
18715 );
18716 assert!(d.vmap.math.is_empty());
18717 // Leaving the line folds it — there is only one line, so add one.
18718 d.newline();
18719 d.insert("more");
18720 d.build_visual(80);
18721 assert_eq!(drawn_rows(&d)[0], "say ∑");
18722 assert_eq!(d.vmap.math.len(), 1);
18723 // And deleting the formula while revealed drops the reveal with it.
18724 d.caret = 7;
18725 d.build_visual(80);
18726 assert_eq!(drawn_rows(&d)[0], "say $x$");
18727 for _ in 0..3 {
18728 d.backspace();
18729 }
18730 d.build_visual(80);
18731 assert_eq!(d.source, "say \n\nmore\n");
18732 assert_eq!(d.reveal_line(), None);
18733 }
18734
18735 #[test]
18736 fn a_display_block_is_edited_where_it_stands() {
18737 let mut d = doc_in(
18738 View::Wysiwyg,
18739 "math_block",
18740 "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
18741 );
18742 caret_at(&mut d, "end");
18743 assert_eq!(
18744 drawn_rows(&d),
18745 vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
18746 );
18747 // Up from `end` lands on the placeholder, whose glyphs all carry the
18748 // block's start — which is on its `$$` line, so the block reveals.
18749 d.move_up(false);
18750 d.build_visual(80);
18751 assert_eq!(d.caret, 7);
18752 assert_eq!(
18753 drawn_rows(&d),
18754 vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
18755 );
18756 // Down walks the source lines, still revealed; typing edits the TeX.
18757 d.move_down(false);
18758 d.build_visual(80);
18759 assert_eq!(d.caret, 10);
18760 d.move_end(false);
18761 d.insert("^2");
18762 d.build_visual(80);
18763 assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
18764 assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
18765 // Out below, and it folds to the placeholder with the new TeX.
18766 d.move_down(false);
18767 d.move_down(false);
18768 d.move_down(false);
18769 d.build_visual(80);
18770 assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
18771 assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
18772 }
18773
18774 #[test]
18775 fn set_math_rows_reserves_filler_rows_by_tex() {
18776 let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
18777 caret_at(&mut d, "end");
18778 assert_eq!(d.vmap.math[0].rows_span, 0..1);
18779 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18780 d.build_visual(80);
18781 assert_eq!(d.vmap.math[0].rows_span, 0..3);
18782 assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
18783 // Cheap when nothing changed.
18784 let key = d.visual_key();
18785 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18786 d.build_visual(80);
18787 assert_eq!(d.visual_key(), key);
18788 }
18789
18790 #[test]
18791 fn a_dollar_typed_in_shortcuts_authors_math() {
18792 // (In `None` the same keystrokes *also* mint a formula for now: twig's
18793 // `insert_literal` does not yet escape `$` under the math extension —
18794 // see `docs/tasks/a-typed-dollar-mints-math-in-the-hidden-mode.md`.)
18795 let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
18796 d.set_markup_mode(MarkupMode::Shortcuts);
18797 d.caret = 0;
18798 d.insert("$x$");
18799 assert_eq!(d.source, "$x$\n");
18800 d.caret = 1;
18801 assert_eq!(d.breadcrumb(), "doc › para › inline_math");
18802 }
18803
18804 #[test]
18805 fn counts_see_a_formula_as_a_picture_however_it_is_written() {
18806 let c = counts_of(
18807 "counts_math",
18808 "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
18809 );
18810 // `the sum … and` is three words; neither formula counts, and the
18811 // display block is not a paragraph of text.
18812 assert_eq!(c.words, 3);
18813 assert_eq!(c.paragraphs, 1);
18814 }
18815}